Files
TREK/server/tests/unit/services/conflictUpdate.test.ts
T
jubnl 175eb3b11b fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate
Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.

PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.

DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.

Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.

Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.
2026-09-23 12:22:36 +02:00

207 lines
9.3 KiB
TypeScript

/**
* Optimistic-concurrency / 409 conflict tests (#1135) for the place + packing
* update services. A matching If-Match token (or none) updates as before; a
* stale token returns the conflict sentinel carrying the server's current row.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
function getPlaceWithTags(placeId: number | string) {
const p = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
if (!p) return null;
return { ...(p as object), category: null, tags: [] };
}
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags,
canAccessTrip: async () => null,
isOwner: async () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { isUpdateConflict } from '../../../src/nest/common/conflictResult';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { notificationsStub } from '../../helpers/notifications';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestTagsRepo,
createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo,
createTestGooglePlacePhotoMetaRepo,
createTestPlacesRepo,
createTestTripMembersRepo,
createTestDayAssignmentsRepo,
createTestCategoriesRepo,
createTestTripsRepo,
sharedTestOrm,
} from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
const realtime = new RealtimeService();
const runtimeEnv = new RuntimeEnvService();
let packing: PackingService;
let places: PlacesService;
let photoCache: PlacePhotoCacheService;
beforeAll(async () => {
// One cache instance shared by maps and places, the way the container
// wires it: the service's stampede guard only works if there is exactly
// one of them. Plan 3c Task 1: PlacePhotoCacheService now takes the two
// repositories too.
photoCache = new PlacePhotoCacheService(
dbs,
makeStorageFixture('photos/google/').storage,
await createTestGooglePlacePhotoMetaRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection),
);
// Plan 3c Task 0b: `dbs` is constructed at module load, before any
// `beforeAll` can resolve a real `EntityManager` — the four
// repository-backed methods are spied directly on this instance instead,
// routed to a real `DatabaseService` built with one.
const real = new DatabaseService(testDb, (await sharedTestOrm(testDb)).em);
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
vi.spyOn(dbs, 'isOwner').mockImplementation((...a) => real.isOwner(...a));
vi.spyOn(dbs, 'rosterUserIds').mockImplementation((...a) => real.rosterUserIds(...a));
vi.spyOn(dbs, 'getPlaceWithTags').mockImplementation((...a) => real.getPlaceWithTags(...a));
packing = new PackingService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
places = new PlacesService(
dbs,
new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)),
realtime,
new MapsService(dbs, photoCache, await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection)),
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), runtimeEnv, makeStorageFixture('').storage),
photoCache,
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
await createTestPlacesRepo(dbs.connection),
await createTestTagsRepo(dbs.connection),
await createTestPlaceRatingsRepo(dbs.connection),
await createTestTripMembersRepo(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestCategoriesRepo(dbs.connection),
await createTestTripsRepo(dbs.connection),
);
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
async function freshPlace(tripId: number) {
const place = await places.create(String(tripId), { name: 'Original' }) as unknown as { id: number; updated_at: string };
return place;
}
describe('PlacesService.update — optimistic concurrency', () => {
it('updates normally when no If-Match token is sent (back-compat)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = await freshPlace(trip.id);
const result = await places.update(String(trip.id), String(place.id), { name: 'Edited' });
expect(isUpdateConflict(result)).toBe(false);
expect((result as { name: string }).name).toBe('Edited');
});
it('updates when the If-Match token matches the current updated_at', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = await freshPlace(trip.id);
const result = await places.update(String(trip.id), String(place.id), { name: 'Edited' }, place.updated_at);
expect(isUpdateConflict(result)).toBe(false);
expect((result as { name: string }).name).toBe('Edited');
});
it('returns a conflict (with the server row) when the token is stale', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = await freshPlace(trip.id);
const result = await places.update(String(trip.id), String(place.id), { name: 'Mine' }, '1999-01-01 00:00:00');
expect(isUpdateConflict(result)).toBe(true);
if (isUpdateConflict(result)) {
expect((result.server as { name: string }).name).toBe('Original');
}
// The row must NOT have been overwritten.
const row = testDb.prepare('SELECT name FROM places WHERE id = ?').get(place.id) as { name: string };
expect(row.name).toBe('Original');
});
it('returns null for a place that does not exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await places.update(String(trip.id), '999999', { name: 'x' }, 'whatever')).toBeNull();
});
});
describe('updateItem (packing) — optimistic concurrency', () => {
it('migration added updated_at and createItem stamps it', async () => {
const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[];
expect(cols.map(c => c.name)).toContain('updated_at');
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null };
expect(item.updated_at).toBeTruthy();
});
it('returns a conflict when the packing token is stale', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = await packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string };
const stale = await packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id);
expect(isUpdateConflict(stale)).toBe(true);
const fresh = await packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id);
expect(isUpdateConflict(fresh)).toBe(false);
expect((fresh as { name: string }).name).toBe('Edited');
});
})