Files
TREK/server/tests/unit/nest/tour-revision.test.ts
T
Maurice d72352911e feat(tours): tour revisions, conflicts and a live editor (P2C-03)
A saved tour now has a technical revision, raised inside the transaction of
every write that changes it: the route and its controls, the plan laid onto a
recorded paddle track, going back to that track. A write that names the
revision it was based on (expected_revision) is refused with a 409
{ code: 'tour_revision_conflict', revision } before anything of it is
written, so a stale draft never overwrites a newer tour unseen. A write that
names none writes as before, so older clients and queued offline writes keep
working; the queue never carries a base. A copy starts at 1, a source's
version stays provenance. Migration 044800 adds the column; list_tours and
get_tour_plan read it.

The editor follows the trip's tours live: a newer revision of the open tour
loads at once while the editor has no changes of its own, and shows a notice
while it has. A refused save keeps the draft and asks: save it as a new tour,
load the newer version, or overwrite on purpose.

useTourPlanner was at the size limit; its derived facts, undo history and
draft storage move into tourPlannerFacts, usePlannerHistory and
useTourDraftStorage, unchanged in behaviour.
2026-10-19 10:22:04 +02:00

46 lines
1.8 KiB
TypeScript

/**
* advanceTourRevision (P2C-03): the one revision contract every tour write runs
* inside its transaction. The SQL is covered by Tours.repository.test.ts and
* the routes by tours-planned.e2e.test.ts; this pins the three answers.
*/
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
import { DomainError } from '../../../src/nest/common/domain-error';
import { advanceTourRevision } from '../../../src/nest/tours/tour-revision';
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
function repo(next: number | null, current: number | undefined) {
return {
advanceRevision: vi.fn(async () => next),
revisionOf: vi.fn(async () => current),
} as unknown as ToursRepository & {
advanceRevision: ReturnType<typeof vi.fn>;
revisionOf: ReturnType<typeof vi.fn>;
};
}
describe('advanceTourRevision', () => {
it('answers the raised revision and passes the expected one on', async () => {
const tours = repo(4, 4);
expect(await advanceTourRevision(tours, 7, 3)).toBe(4);
expect(tours.advanceRevision).toHaveBeenCalledWith(7, 3);
expect(tours.revisionOf).not.toHaveBeenCalled();
});
it('refuses a stale base with a 409 that names the revision the tour has now', async () => {
const refusal = await advanceTourRevision(repo(null, 5), 7, 3).catch((err: unknown) => err);
expect(refusal).toBeInstanceOf(DomainError);
expect((refusal as DomainError).getStatus()).toBe(409);
expect((refusal as DomainError).toBody()).toEqual({
error: 'This tour was changed in the meantime',
code: 'tour_revision_conflict',
revision: 5,
});
});
it('is a 404 when the tour went away meanwhile', async () => {
await expect(advanceTourRevision(repo(null, undefined), 7)).rejects.toBeInstanceOf(NotFoundException);
});
});