mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
6cf5760383
Nine fire-and-forget sends reached notifications.bridge through
import('...').then(({ send }) => ...). The laziness was working around a
module cycle that no longer exists, and it cost twice: it hid the edge from
the module graph, and it handed each send a NotificationsService built
outside the container rather than the container's own.
The sweep needed a method rather than a grep. tests/ sits outside
tsconfig's include, so a service that gains a constructor parameter does not
break its hand-wired call sites at compile time — the argument arrives as
undefined and throws inside the send. Adding tests to a throwaway tsconfig
and diffing the errors before and after named all 46 sites exactly. The same
config reports 288 pre-existing errors in the suites, almost all deliberate
partial constructions in the harnesses, which is why tests/ is not in the
build's include; that cleanup is its own piece of work and is written down
in the nest README.
notifications.instance.ts holds the out-of-container instance, apart from
notifications.bridge.ts on purpose: several suites replace the bridge
wholesale with vi.mock(..., () => ({ send })), and a sibling bridge taking
its instance from there would break under those mocks.
The suites that assert on a send now hand their spy to the injected service.
The reservations e2e overrides the provider, because its app resolves the
real one out of its own container and never saw the module mock.
139 lines
5.8 KiB
TypeScript
139 lines
5.8 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: () => null,
|
|
isOwner: () => 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 { 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 { notificationsStub } from '../../helpers/notifications';
|
|
|
|
const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub());
|
|
const places = new PlacesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new MapsService(new DatabaseService(testDb)), new QueryHelpersService(new DatabaseService(testDb)));
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
function freshPlace(tripId: number) {
|
|
const place = 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)', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = freshPlace(trip.id);
|
|
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = freshPlace(trip.id);
|
|
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = freshPlace(trip.id);
|
|
|
|
const result = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
expect(places.update(String(trip.id), '999999', { name: 'x' }, 'whatever')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateItem (packing) — optimistic concurrency', () => {
|
|
it('migration added updated_at and createItem stamps it', () => {
|
|
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 = 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', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const item = packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string };
|
|
|
|
const stale = packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00');
|
|
expect(isUpdateConflict(stale)).toBe(true);
|
|
|
|
const fresh = packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at);
|
|
expect(isUpdateConflict(fresh)).toBe(false);
|
|
expect((fresh as { name: string }).name).toBe('Edited');
|
|
});
|
|
})
|