feat(hunt): track walking distance via GPS, show live locations on map
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s

Add foreground-only GPS distance tracking for hunts
This commit is contained in:
2026-09-27 20:55:58 +02:00
parent a0fc836c05
commit 61cc516f47
43 changed files with 2313 additions and 24 deletions
+203
View File
@@ -0,0 +1,203 @@
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
);
});
});