import { computeTrackDistance, type GeoPoint } from '#shared/utils/geo'; import { beforeEach, describe, expect, it } from 'vitest'; import { newClient } from '../helpers/api'; import { resetDatabase } from '../helpers/database'; import { createHunt, createTeam, createUser, TEST_PASSWORD } from '../helpers/factories'; import { prisma } from './setup'; beforeEach(resetDatabase); const MINUTE = 60_000; const DAY = 24 * 60 * 60 * 1000; function point(lat: number, lon: number, recordedAt: Date, accuracy?: number) { return { lat, lon, recordedAt: recordedAt.toISOString(), ...(accuracy !== undefined ? { accuracy } : {}) }; } /** A small walk north along a meridian, one fix every 10 s (~11 m apart). */ function walk(count: number, startMs = Date.now() - count * 10_000) { return Array.from({ length: count }, (_, i) => point(i * 0.0001, 0, new Date(startMs + i * 10_000), 5) ); } async function seedTrackingHunt( ownerEmail: string, overrides: Parameters[1] = {} ) { const creator = await createUser(); const hunt = await createHunt(creator.id, { trackDistance: true, revealQuests: true, ...overrides }); const owner = await createUser({ email: ownerEmail }); const team = await createTeam(hunt.id, owner.id); return { hunt, owner, team }; } async function loggedInClient(email: string) { const client = newClient(); await client.login(email, TEST_PASSWORD); return client; } describe('track (POST /api/hunt/[huntId]/track)', () => { it('drops points without a session (2xx, nothing persisted)', async () => { const { hunt } = await seedTrackingHunt('owner@test.dev'); const res = await newClient().post(`/api/hunt/${hunt.id}/track`, walk(3)); expect(res.ok).toBe(true); expect(await prisma.trackPoint.count()).toBe(0); expect(await prisma.userTrack.count()).toBe(0); }); it('drops points from an authenticated non-member (2xx, nothing persisted)', async () => { const { hunt } = await seedTrackingHunt('owner@test.dev'); const loner = await createUser({ email: 'loner@test.dev' }); const client = await loggedInClient('loner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3)); expect(res.ok).toBe(true); expect(await prisma.trackPoint.count({ where: { userId: loner.id } })).toBe( 0 ); expect(await prisma.userTrack.count()).toBe(0); }); it('drops points when hunt.trackDistance is false', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev', { trackDistance: false }); const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3)); expect(res.ok).toBe(true); expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe( 0 ); expect(await prisma.userTrack.count()).toBe(0); }); it('drops points outside the time window', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev', { start: new Date(Date.now() - 2 * DAY), end: new Date(Date.now() - DAY) }); const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, walk(3)); expect(res.ok).toBe(true); expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe( 0 ); expect(await prisma.userTrack.count()).toBe(0); }); it('persists a valid batch and derives the distance', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev'); const batch = walk(4); const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, batch); expect(res.ok).toBe(true); const points = await prisma.trackPoint.findMany({ where: { userId: owner.id, huntId: hunt.id } }); expect(points).toHaveLength(4); const track = await prisma.userTrack.findUniqueOrThrow({ where: { userId_huntId: { userId: owner.id, huntId: hunt.id } } }); const expected = computeTrackDistance(batch as GeoPoint[]); expect(expected).toBeGreaterThan(0); expect(track.distanceMeters).toBe(expected); expect(track.lastFixAt).not.toBeNull(); }); it('is idempotent when the same batch is re-sent', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev'); const batch = walk(4); const client = await loggedInClient('owner@test.dev'); await client.post(`/api/hunt/${hunt.id}/track`, batch); const first = await prisma.userTrack.findUniqueOrThrow({ where: { userId_huntId: { userId: owner.id, huntId: hunt.id } } }); await client.post(`/api/hunt/${hunt.id}/track`, batch); const second = await prisma.userTrack.findUniqueOrThrow({ where: { userId_huntId: { userId: owner.id, huntId: hunt.id } } }); expect(second.distanceMeters).toBe(first.distanceMeters); }); it('truncates oversize batches but stays 2xx', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev'); const batch = walk(600); const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, batch); expect(res.ok).toBe(true); const count = await prisma.trackPoint.count({ where: { userId: owner.id, huntId: hunt.id } }); expect(count).toBeLessThanOrEqual(500); expect(count).toBe(500); }); it('filters invalid points but keeps the valid ones', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev'); const now = Date.now(); const batch = [ point(0, 0, new Date(now - 30_000), 5), // valid point(999, 0, new Date(now - 25_000), 5), // lat out of range point(0, 999, new Date(now - 24_000), 5), // lon out of range point(0.0001, 0, new Date(now + DAY), 5), // recordedAt in the future point(0.0002, 0, new Date(now - 2 * DAY - MINUTE), 5), // too old point(0.0003, 0, new Date(now - 22_000), -5), // negative accuracy point(0.0001, 0, new Date(now - 20_000), 5) // valid ]; const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, batch); expect(res.ok).toBe(true); const points = await prisma.trackPoint.findMany({ where: { userId: owner.id, huntId: hunt.id } }); expect(points).toHaveLength(2); }); it('drops a malformed (non-array) body silently', async () => { const { hunt, owner } = await seedTrackingHunt('owner@test.dev'); const client = await loggedInClient('owner@test.dev'); const res = await client.post(`/api/hunt/${hunt.id}/track`, { not: 'an array' }); expect(res.ok).toBe(true); expect(await prisma.trackPoint.count({ where: { userId: owner.id } })).toBe( 0 ); }); });