Files
TREK/server/tests/unit/nest/day-boundaries.test.ts
T
Maurice b2f2aef016 test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
2026-10-08 18:46:13 +02:00

152 lines
7.9 KiB
TypeScript

/**
* `DayBoundariesService`/`DayBoundariesController`/`DayBoundariesMcp` (Plan
* 3d Task 1). Rewritten off the hand-DDL `DatabaseService`-shaped facade
* (§15a/§15c test risk 2) onto real rows through `DayAssignmentsRepository`/
* `RoadtripDayBoundariesRepository` — the real schema, not a 4-column
* hand-written subset.
*/
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../helpers/factories';
import { DayBoundariesService } from '../../../src/nest/roadtrip/day-boundaries.service';
import { DayBoundariesController } from '../../../src/nest/roadtrip/day-boundaries.controller';
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
import { deleteRows } from '../../helpers/factories/rows';
import type { McpContext } from '../../../src/nest-mcp';
const testDb = createSnapshotTestDb();
let t: TestOrm;
beforeAll(async () => { t = await createTestOrm(testDb); });
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function setup() {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id);
const tripB = createTrip(testDb, user.id);
const dayA = createDay(testDb, tripA.id);
const dayB = createDay(testDb, tripB.id);
const placeA = createPlace(testDb, tripA.id);
const placeB = createPlace(testDb, tripB.id);
const from = createDayAssignment(testDb, dayA.id, placeA.id);
const to = createDayAssignment(testDb, dayA.id, placeA.id);
const foreign = createDayAssignment(testDb, dayB.id, placeB.id);
const dayAssignmentsRepo = t.repo(DayAssignments);
const boundariesRepo = t.repo(RoadtripDayBoundaries);
const service = new DayBoundariesService(dayAssignmentsRepo, boundariesRepo);
const realtime = { broadcast: vi.fn() };
const auth = { isDemoUser: vi.fn(() => false) };
const guards = { hasTripPermission: vi.fn(() => true) };
const tripsRepo = { findAccessible: vi.fn(async () => ({ id: tripA.id, user_id: user.id })) };
const mcp = new DayBoundariesMcp(service, tripsRepo as never, auth as never, guards as never, realtime as never, {} as never);
return {
service,
controller: new DayBoundariesController(service, realtime as never),
mcp,
tripsRepo,
auth,
guards,
realtime,
tripA,
tripB,
fromId: from.id,
toId: to.id,
foreignId: foreign.id,
};
}
it('replaces one day only, persists place snapping and cascades deleted visits', async () => {
const { service, tripA, tripB, fromId, toId } = setup();
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.4 };
await service.save(tripA.id, boundary);
await service.save(tripA.id, { ...boundary, day_number: 2, fraction: 0.8 });
expect(await service.save(tripA.id, { ...boundary, to_assignment_id: null })).toEqual([
{ ...boundary, to_assignment_id: null, fraction: 1 }, { ...boundary, day_number: 2, fraction: 0.8 },
]);
expect(await service.list(tripB.id)).toEqual([]);
await service.remove(tripB.id, 1);
expect(await service.list(tripA.id)).toHaveLength(2);
await deleteRows(t, DayAssignments, { id: toId });
expect(await service.list(tripA.id)).toEqual([{ ...boundary, to_assignment_id: null, fraction: 1 }]);
});
it('rejects either endpoint from a different trip without writing (RB2)', async () => {
const { service, tripA, fromId, foreignId } = setup();
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: foreignId, fraction: 0.4 };
await expect(service.save(tripA.id, { ...boundary, from_assignment_id: foreignId })).rejects.toThrow(HttpException);
await expect(service.save(tripA.id, boundary)).rejects.toThrow(HttpException);
expect(await service.list(tripA.id)).toEqual([]);
});
it('RB2 (`belongs`): a foreign `to_assignment_id` is refused with the exact legacy message', async () => {
// The async-closure-truthiness-guards ratchet
// (`tests/integration/async-closure-truthiness-guards.test.ts`, RB2-001)
// drives the real route end to end and is mutation-proved there (Task 0's
// report: dropping the `await` on `belongs` turns a 404 into a 200). This
// unit case pins the exact error string at the service boundary.
const { service, tripA, fromId, foreignId } = setup();
await expect(
service.save(tripA.id, { day_number: 1, from_assignment_id: fromId, to_assignment_id: foreignId, fraction: 0.4 }),
).rejects.toMatchObject({ response: { error: 'Stop not found' }, status: 404 });
});
it('REST changes broadcast the complete state and exclude the saving socket', async () => {
const { controller, realtime, tripA, fromId, toId } = setup();
const boundary = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.4 };
await controller.save(String(tripA.id), boundary, 'self');
expect(realtime.broadcast).toHaveBeenCalledWith(String(tripA.id), 'roadtripBoundary:changed', { boundaries: [boundary] }, 'self');
expect(await controller.remove(String(tripA.id), 1)).toEqual({ boundaries: [] });
});
// R7 (task-7-review.md L4 / 3d ledger's carry): `save`'s ownership check
// (RB2 `belongs`) and its upsert (RB3) are two un-transacted statements, the
// same class of race as `roadtrip.service.test.ts`'s R7 vias pin. Both
// concurrent callers pass their own `belongs` check independently (neither
// references the other's write), then race on `upsertBoundary`'s single
// `ON CONFLICT (trip, day_number) DO UPDATE` — that statement is atomic, so
// the row can never end up a hybrid of the two payloads; exactly one caller's
// fraction survives. Pinning today's actual outcome, not a fix.
it('R7: two concurrent saves for the same day_number both succeed; the upsert leaves one caller\'s row intact, never a hybrid (unserialized belongs-check-then-upsert)', async () => {
const { service, tripA, fromId, toId } = setup();
const boundaryA = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.25 };
const boundaryB = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.75 };
const results = await Promise.allSettled([
service.save(tripA.id, boundaryA),
service.save(tripA.id, boundaryB),
]);
expect(results.every((r) => r.status === 'fulfilled')).toBe(true);
const rows = await service.list(tripA.id);
// No duplicate row from the race, and no hybrid of the two fractions.
expect(rows).toHaveLength(1);
expect([0.25, 0.75]).toContain(rows[0].fraction);
});
it('MCP checks demo, trip access and edit permission before saving', async () => {
const s = setup();
const ctx = { userId: 1 } as McpContext;
const boundary = { day_number: 1, from_assignment_id: s.fromId, to_assignment_id: s.toId, fraction: 0.4 };
const request = { tripId: s.tripA.id, dayNumber: 1, boundary };
s.auth.isDemoUser.mockReturnValue(true);
await s.mcp.save(request, ctx);
s.auth.isDemoUser.mockReturnValue(false);
s.tripsRepo.findAccessible.mockResolvedValue(undefined);
await s.mcp.save(request, ctx);
s.tripsRepo.findAccessible.mockResolvedValue({ id: s.tripA.id, user_id: 1 });
s.guards.hasTripPermission.mockReturnValue(false);
await s.mcp.save(request, ctx);
expect(await s.service.list(s.tripA.id)).toEqual([]);
s.guards.hasTripPermission.mockReturnValue(true);
await s.mcp.save(request, ctx);
expect(await s.service.list(s.tripA.id)).toEqual([boundary]);
await s.mcp.save({ ...request, boundary: null }, ctx);
expect(await s.service.list(s.tripA.id)).toEqual([]);
});