Files
TREK/server/tests/unit/nest/common/conflictUpdate.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

264 lines
11 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 { db as testDb } from '../../../../src/db/database';
import { Places } from '../../../../src/db/entities/Places.entity';
import { TrekPhotos } from '../../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../../src/db/entities/TripPhotos.entity';
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { isUpdateConflict } from '../../../../src/nest/common/conflictResult';
import { JourneyDomainService } from '../../../../src/nest/journey/journey-domain.service';
import { PackingService } from '../../../../src/nest/packing/packing.service';
import { PermissionsService } from '../../../../src/nest/permissions/permissions.service';
import { TrekPhotoRegistrationService } from '../../../../src/nest/photos/trek-photo-registration.service';
import { PlacePhotoCacheService } from '../../../../src/nest/place-photos/place-photo-cache.service';
import { PlacesService } from '../../../../src/nest/places/places.service';
import { QueryHelpersService } from '../../../../src/nest/query-helpers/query-helpers.service';
import { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
import { UnsplashService } from '../../../../src/nest/unsplash/unsplash.service';
import { accommodationsOver } from '../../../helpers/accommodations-service';
import { createUser, createTrip } from '../../../helpers/factories';
import { findRow } from '../../../helpers/factories/rows';
import { createTestBudgetItemsRepo } from '../../../helpers/files-repos';
import { noGoogleQuota } from '../../../helpers/google-quota';
import {
createTestJourneysRepo,
createTestJourneyContributorsRepo,
createTestJourneyTripsRepo,
createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo,
createTestJourneyEntryPhotosRepo,
} from '../../../helpers/journey-repos';
import { buildMapsService } from '../../../helpers/maps-service';
import { notificationsStub } from '../../../helpers/notifications';
import {
createTestPackingItemsRepo,
createTestPackingItemContributorsRepo,
createTestPackingBagsRepo,
createTestPackingCategoryAssigneesRepo,
createTestPackingTemplatesRepo,
createTestPackingTemplateCategoriesRepo,
createTestPackingTemplateItemsRepo,
} from '../../../helpers/packing-repos';
import { buildPlaceImportService } from '../../../helpers/place-import';
import { makeStorageFixture } from '../../../helpers/storage-fixture';
import { resetTestDb } from '../../../helpers/test-db';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestTagsRepo,
createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo,
createTestGooglePlacePhotoMetaRepo,
createTestPlacesRepo,
createTestPlaceDetailsCacheRepo,
createTestTripMembersRepo,
createTestDayAssignmentsRepo,
createTestCategoriesRepo,
createTestTripsRepo,
sharedTestOrm,
} from '../../../helpers/test-uow';
import { createTestCollectionPlacesRepo } from '../../../helpers/test-uow';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../../src/db/database', async () => {
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
const db = createSnapshotTestDb();
return {
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip: async () => null,
isOwner: async () => false,
};
});
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. Plan 4 Task 4: `DatabaseService` is gone — every
// service below is fully repository-backed, so the `dbs.canAccessTrip`/
// `isOwner`/`rosterUserIds`/`getPlaceWithTags` spies this block used to
// route to a real `DatabaseService` are dead; removed with it.
photoCache = new PlacePhotoCacheService(
makeStorageFixture('photos/google/').storage,
await createTestGooglePlacePhotoMetaRepo(testDb),
await createTestPlacesRepo(testDb),
await createTestCollectionPlacesRepo(testDb),
);
const t = await sharedTestOrm(testDb);
packing = new PackingService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
realtime,
notificationsStub(),
await createTestUnitOfWork(testDb),
await createTestPackingItemsRepo(testDb),
await createTestPackingItemContributorsRepo(testDb),
await createTestPackingBagsRepo(testDb),
await createTestPackingCategoryAssigneesRepo(testDb),
await createTestPackingTemplatesRepo(testDb),
await createTestPackingTemplateCategoriesRepo(testDb),
await createTestPackingTemplateItemsRepo(testDb),
await createTestTripsRepo(testDb),
await createTestTripMembersRepo(testDb),
);
places = new PlacesService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
realtime,
buildMapsService(
photoCache,
await createTestAppSettingsRepo(testDb),
await createTestUsersRepo(testDb),
await createTestPlaceDetailsCacheRepo(testDb),
await createTestPlacesRepo(testDb),
noGoogleQuota,
),
new QueryHelpersService(
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestAssignmentParticipantsRepo(testDb),
),
new UnsplashService(
await createTestAppSettingsRepo(testDb),
await createTestUsersRepo(testDb),
runtimeEnv,
makeStorageFixture('').storage,
),
photoCache,
new JourneyDomainService(
realtime,
new TrekPhotoRegistrationService(
t.repo(TrekPhotos),
t.repo(TripPhotos),
await createTestJourneyPhotosRepo(testDb),
),
await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb),
await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb),
await createTestJourneyEntriesRepo(testDb),
await createTestTripsRepo(testDb),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
),
makeStorageFixture('').storage,
await accommodationsOver(testDb),
await createTestUnitOfWork(testDb),
await createTestPlacesRepo(testDb),
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestCategoriesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestBudgetItemsRepo(testDb),
await createTestCollectionPlacesRepo(testDb),
buildPlaceImportService(),
);
});
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 = await findRow(await sharedTestOrm(testDb), Places, { id: place.id });
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 () => {
// test-sql-allow: schema introspection, which no entity or repository maps.
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');
});
});