204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
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<typeof createHunt>[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
|
|
);
|
|
});
|
|
});
|