Files
TREK/server/tests/unit/db/repositories/TourSourceHints.repository.test.ts
T
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
2026-10-20 15:35:32 +02:00

198 lines
8.4 KiB
TypeScript

/**
* `TourSourceHintsRepository` (WAN-02): what the background watch over a
* user's own wanderer snapshots found, and which snapshots it may look at.
* Seeded through the ORM factories over the migrated snapshot schema.
*/
import { TourSourceHints } from '../../../../src/db/entities/TourSourceHints.entity';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
import { WandererConnections } from '../../../../src/db/entities/WandererConnections.entity';
import type { TourSourceHintsRepository } from '../../../../src/db/repositories/TourSourceHints.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makePlace } from '../../../helpers/factories/places';
import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
import { makeTour, makeTourSource } from '../../../helpers/factories/tours';
import { addTripMember, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const INSTANCE = 'https://wanderer.example.org';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourSourceHintsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourSourceHints);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** A wanderer snapshot in the trip, imported by `importedBy`. */
async function snapshot(tripId: number, importedBy: number | null, externalId: string, instance = INSTANCE) {
const place = await makePlace(t, tripId, { name: `Trail ${externalId}` });
await makeTour(t, place.id, { origin: 'wanderer' });
await makeTourSource(t, place.id, importedBy, { external_id: externalId, instance, external_updated: '2026-10-01' });
return place.id;
}
async function connect(userId: number, url = INSTANCE) {
await insertRow(t, WandererConnections, { user: userId, url, token: 'enc' });
}
const look = (placeId: number, userId: number, checkedAt: string, state: 'possible_change' | null = null) => ({
place_id: placeId,
user_id: userId,
state,
changes: null,
reason: null,
external_updated: '2026-10-02',
checked_at: checkedAt,
});
describe('TourSourceHintsRepository', () => {
it('THREPO-001: lists only the user own imports of the instance, in trips they can still open', async () => {
const { user } = await makeUser(t);
const { user: other } = await makeUser(t);
const own = await makeTrip(t, user.id, { title: 'Alps' });
const shared = await makeTrip(t, other.id, { title: 'Shared' });
await addTripMember(t, shared.id, user.id);
const left = await makeTrip(t, other.id, { title: 'Left' });
const mine = await snapshot(own.id, user.id, 'a');
const inShared = await snapshot(shared.id, user.id, 'b');
await snapshot(own.id, other.id, 'c');
await snapshot(own.id, user.id, 'd', 'https://elsewhere.example');
await snapshot(left.id, user.id, 'e');
const due = await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10);
expect(due.map((row) => row.place_id).sort()).toEqual([mine, inShared].sort());
expect(due[0]).toMatchObject({
content_hash: 'n.d.r',
external_updated: '2026-10-01',
seen_hash: null,
state: null,
});
});
it('THREPO-002: never looked at first, then the longest waiting, within the cutoff and the limit', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const fresh = await snapshot(trip.id, user.id, 'a');
const old = await snapshot(trip.id, user.id, 'b');
const older = await snapshot(trip.id, user.id, 'c');
const recent = await snapshot(trip.id, user.id, 'd');
await repo.record([
look(old, user.id, '2026-10-18 10:00:00'),
look(older, user.id, '2026-10-17 10:00:00'),
look(recent, user.id, '2026-10-19 12:00:00'),
]);
const due = await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 10);
expect(due.map((row) => row.place_id)).toEqual([fresh, older, old]);
expect((await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 2)).map((row) => row.place_id)).toEqual([
fresh,
older,
]);
});
it('THREPO-003: a look writes its finding and keeps what the user last saw', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
await repo.markSeen(placeId, user.id, {
contentHash: 'seen.hash.now',
externalUpdated: '2026-10-05',
checkedAt: '2026-10-05 08:00:00',
});
await repo.record([{ ...look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change'), changes: '["name"]' }]);
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({
state: 'possible_change',
changes: '["name"]',
external_updated: '2026-10-02',
seen_hash: 'seen.hash.now',
seen_updated: '2026-10-05',
checked_at: '2026-10-19 08:00:00',
});
await repo.record([]);
});
it('THREPO-004: a manual check clears the hint for the importer only, and starts a row when there was none', async () => {
const { user } = await makeUser(t);
const { user: member } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
const seen = { contentHash: 'x.y.z', externalUpdated: '2026-10-10', checkedAt: '2026-10-10 09:00:00' };
await repo.markSeen(placeId, member.id, seen);
expect(await findRow(t, TourSourceHints, { place: placeId })).toBeNull();
await repo.markSeen(placeId, user.id, seen);
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({ state: null, seen_hash: 'x.y.z' });
await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]);
await repo.markSeen(placeId, user.id, { ...seen, contentHash: 'x.y.w', externalUpdated: null });
expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({
state: null,
changes: null,
seen_hash: 'x.y.w',
seen_updated: null,
});
await repo.markSeen(999_999, user.id, seen);
});
it('THREPO-005: open hints show while the connection points at their instance, newest look first', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id, { title: 'Alps' });
const first = await snapshot(trip.id, user.id, 'a');
const second = await snapshot(trip.id, user.id, 'b');
const quiet = await snapshot(trip.id, user.id, 'c');
await repo.record([
look(first, user.id, '2026-10-18 08:00:00', 'possible_change'),
look(second, user.id, '2026-10-19 08:00:00', 'possible_change'),
look(quiet, user.id, '2026-10-19 09:00:00'),
]);
expect(await repo.listOpen(user.id)).toEqual([]);
await connect(user.id);
const open = await repo.listOpen(user.id);
expect(open.map((row) => row.place_id)).toEqual([second, first]);
expect(open[0]).toMatchObject({
trip_id: trip.id,
trip_title: 'Alps',
tour_name: 'Trail b',
state: 'possible_change',
});
expect(await repo.findOpen(user.id, trip.id, first)).toMatchObject({ place_id: first });
expect(await repo.findOpen(user.id, trip.id, quiet)).toBeUndefined();
expect(await repo.findOpen(user.id, trip.id + 1, first)).toBeUndefined();
});
it('THREPO-006: a connection to another instance, or a trip the user left, hides the hint', async () => {
const { user } = await makeUser(t);
const { user: owner } = await makeUser(t);
const trip = await makeTrip(t, owner.id);
await addTripMember(t, trip.id, user.id);
const placeId = await snapshot(trip.id, user.id, 'a');
await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]);
await connect(user.id, 'https://elsewhere.example');
expect(await repo.listOpen(user.id)).toEqual([]);
await updateRows(t, WandererConnections, { user: user.id }, { url: INSTANCE });
expect(await repo.listOpen(user.id)).toHaveLength(1);
await deleteRows(t, TripMembers, { trip: trip.id, user: user.id });
expect(await repo.listOpen(user.id)).toEqual([]);
expect(await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10)).toEqual([]);
});
});