mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
b2f2aef016
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().
152 lines
7.9 KiB
TypeScript
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([]);
|
|
});
|