mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports, idempotency, invites, tokens, OAuth, push subscriptions, the version check, budget settlement maths, addons, place shadow picks, tour GPX import, the place region repair, journey stats, day notes and storage jobs seeded and inspected their rows with raw statements. They now go through the ORM factories and readers over the suite's shared test ORM. The database mocks of day notes and OAuth also lose their dead canAccessTrip and isOwner stubs: db/database exports neither since the repositories took them over. Raw statements stay, marked, where they are the subject: the backup file the boot restore reads before any ORM exists, the full raw row a day note is checked against, sqlite_master, the synchronous probe that counts tours while the GPX import may still hold its transaction, and the edits the region repair case lands mid-pass, past the ORM's connection queue.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Unit tests for AddonsService — rebuilt on real rows (Plan 3a Task 4).
|
||||
*
|
||||
* The legacy version of this file fully stubbed DatabaseService at the
|
||||
* `db.prepare(sql).{get,all,run}` level: a single shared fake `stmt` fed
|
||||
* `db.prepare` level (its `get`, `all` and `run`): a single shared fake `stmt` fed
|
||||
* canned rows *in call order* (three `.all()` reads for addons/providers/
|
||||
* fields, a `.get()` for the journey gate, and so on). That coupling cannot
|
||||
* survive AddonsService now calling four different repositories instead of
|
||||
@@ -26,6 +26,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { deleteRows, findRow, insertRow, updateRows, upsertRow } from '../../helpers/factories/rows';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
@@ -77,10 +78,11 @@ function providerFields(addon: ListAddon): PhotoProviderField[] {
|
||||
return (addon as ListAddon & { fields: PhotoProviderField[] }).fields;
|
||||
}
|
||||
|
||||
function insertAddon(row: { id: string; name: string; type?: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.type ?? 'global', row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
async function insertAddon(row: { id: string; name: string; type?: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): Promise<void> {
|
||||
await insertRow(t, Addons, {
|
||||
id: row.id, name: row.name, description: null, type: row.type ?? 'global', icon: row.icon ?? null,
|
||||
enabled: row.enabled === 1, sort_order: row.sort_order ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,13 +102,13 @@ function stubJourneyEnabled(): ReturnType<typeof vi.spyOn> {
|
||||
return vi.spyOn(addonsRepo, 'isEnabled').mockImplementation(async (id: string) => id === 'journey');
|
||||
}
|
||||
|
||||
function insertProvider(row: { id: string; name: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
async function insertProvider(row: { id: string; name: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): Promise<void> {
|
||||
await insertRow(t, PhotoProviders, {
|
||||
id: row.id, name: row.name, description: null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function insertField(row: {
|
||||
async function insertField(row: {
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
@@ -118,36 +120,29 @@ function insertField(row: {
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
row.provider_id,
|
||||
row.field_key,
|
||||
row.label,
|
||||
row.input_type ?? 'text',
|
||||
row.placeholder ?? null,
|
||||
row.hint ?? null,
|
||||
row.required ?? 0,
|
||||
row.secret ?? 0,
|
||||
row.settings_key ?? null,
|
||||
row.payload_key ?? null,
|
||||
row.sort_order ?? 0,
|
||||
);
|
||||
}): Promise<void> {
|
||||
await insertRow(t, PhotoProviderFields, {
|
||||
provider: row.provider_id,
|
||||
field_key: row.field_key,
|
||||
label: row.label,
|
||||
input_type: row.input_type ?? 'text',
|
||||
placeholder: row.placeholder ?? null,
|
||||
hint: row.hint ?? null,
|
||||
required: row.required ?? 0,
|
||||
secret: row.secret ?? 0,
|
||||
settings_key: row.settings_key ?? null,
|
||||
payload_key: row.payload_key ?? null,
|
||||
sort_order: row.sort_order ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function setAppSetting(key: string, value: string | null): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
async function setAppSetting(key: string, value: string | null): Promise<void> {
|
||||
await upsertRow(t, AppSettings, { key, value });
|
||||
}
|
||||
|
||||
function rawAppSetting(key: string): { key: string; value: string | null } | undefined {
|
||||
return testDb.prepare('SELECT key, value FROM app_settings WHERE key = ?').get(key) as
|
||||
| { key: string; value: string | null }
|
||||
| undefined;
|
||||
async function storedAppSetting(key: string): Promise<{ key: string; value: string | null } | undefined> {
|
||||
const row = await findRow(t, AppSettings, { key });
|
||||
return row ? { key: row.key!, value: row.value ?? null } : undefined;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -187,8 +182,8 @@ afterAll(async () => {
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from app_settings', async () => {
|
||||
setAppSetting('collab_chat_enabled', 'false');
|
||||
setAppSetting('bag_tracking_enabled', 'true');
|
||||
await setAppSetting('collab_chat_enabled', 'false');
|
||||
await setAppSetting('bag_tracking_enabled', 'true');
|
||||
|
||||
const res = await svc.list();
|
||||
expect(res.collabFeatures).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
@@ -202,8 +197,8 @@ describe('AddonsService.list', () => {
|
||||
// enabled row that does (the entity's `enabled` is p.boolean(), so the
|
||||
// stored int comes back as a real JS boolean, not just truthy).
|
||||
it('only returns enabled addons, with the stored int coerced to a JS boolean', async () => {
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 });
|
||||
await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
await insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 });
|
||||
|
||||
const res = await svc.list();
|
||||
expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]);
|
||||
@@ -211,7 +206,7 @@ describe('AddonsService.list', () => {
|
||||
|
||||
it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' });
|
||||
|
||||
const res = await svc.list();
|
||||
@@ -236,8 +231,8 @@ describe('AddonsService.list', () => {
|
||||
// AddonsService does the `!!` itself, unlike the Addons entity above).
|
||||
it('only returns enabled photo providers, with the stored integer coerced to a JS boolean', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'synology', name: 'Synology', icon: 'image', enabled: 1, sort_order: 1 });
|
||||
insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0, sort_order: 2 });
|
||||
await insertProvider({ id: 'synology', name: 'Synology', icon: 'image', enabled: 1, sort_order: 1 });
|
||||
await insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0, sort_order: 2 });
|
||||
|
||||
const res = await svc.list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['synology']);
|
||||
@@ -246,8 +241,8 @@ describe('AddonsService.list', () => {
|
||||
|
||||
it('groups multiple fields under their provider and keeps insertion order', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
insertField({
|
||||
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
await insertField({
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
@@ -261,7 +256,7 @@ describe('AddonsService.list', () => {
|
||||
sort_order: 0,
|
||||
});
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
insertField({
|
||||
await insertField({
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
@@ -302,8 +297,8 @@ describe('AddonsService.list', () => {
|
||||
|
||||
it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'p', name: 'P', icon: 'i', enabled: 1 });
|
||||
insertField({ provider_id: 'p', field_key: 'k', label: 'L' }); // placeholder/hint/settings_key/payload_key all null
|
||||
await insertProvider({ id: 'p', name: 'P', icon: 'i', enabled: 1 });
|
||||
await insertField({ provider_id: 'p', field_key: 'k', label: 'L' }); // placeholder/hint/settings_key/payload_key all null
|
||||
|
||||
const res = await svc.list();
|
||||
const field = providerFields(res.addons[0])[0];
|
||||
@@ -319,9 +314,9 @@ describe('AddonsService.list', () => {
|
||||
stubJourneyEnabled();
|
||||
// A field exists, but for a DIFFERENT provider than the one returned — exercises
|
||||
// the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty.
|
||||
insertProvider({ id: 'has-none', name: 'X', icon: 'i', enabled: 1 });
|
||||
insertProvider({ id: 'other', name: 'Other', icon: 'i', enabled: 0 }); // FK target for the field below
|
||||
insertField({ provider_id: 'other', field_key: 'k', label: 'L' });
|
||||
await insertProvider({ id: 'has-none', name: 'X', icon: 'i', enabled: 1 });
|
||||
await insertProvider({ id: 'other', name: 'Other', icon: 'i', enabled: 0 }); // FK target for the field below
|
||||
await insertField({ provider_id: 'other', field_key: 'k', label: 'L' });
|
||||
|
||||
const res = await svc.list();
|
||||
expect(providerFields(res.addons[0])).toEqual([]);
|
||||
@@ -329,8 +324,8 @@ describe('AddonsService.list', () => {
|
||||
|
||||
it('concatenates regular addons before the photo providers', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
|
||||
const res = await svc.list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']);
|
||||
@@ -340,8 +335,8 @@ describe('AddonsService.list', () => {
|
||||
it('drops the photo providers while the journey addon is off, whatever their rows say', async () => {
|
||||
// No 'journey' row at all — isAddonEnabled('journey') reads false, same as
|
||||
// the legacy `!!undefined`.
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
const listSpy = vi.spyOn(photoProvidersRepo, 'listEnabled');
|
||||
|
||||
const res = await svc.list();
|
||||
@@ -356,37 +351,37 @@ describe('AddonsService.list', () => {
|
||||
// purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false').
|
||||
describe('AddonsService addon/feature flags', () => {
|
||||
it('isAddonEnabled coerces the enabled column (1/0/missing row)', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 1 });
|
||||
await insertAddon({ id: 'budget', name: 'Budget', enabled: 1 });
|
||||
expect(await svc.isAddonEnabled('budget')).toBe(true);
|
||||
|
||||
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run('budget');
|
||||
await updateRows(t, Addons, { id: 'budget' }, { enabled: false });
|
||||
expect(await svc.isAddonEnabled('budget')).toBe(false);
|
||||
|
||||
expect(await svc.isAddonEnabled('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('getBagTracking is opt-in: only the literal string true enables it', async () => {
|
||||
setAppSetting('bag_tracking_enabled', 'true');
|
||||
await setAppSetting('bag_tracking_enabled', 'true');
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: true });
|
||||
|
||||
setAppSetting('bag_tracking_enabled', 'false');
|
||||
await setAppSetting('bag_tracking_enabled', 'false');
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: false });
|
||||
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'bag_tracking_enabled'").run();
|
||||
await deleteRows(t, AppSettings, { key: 'bag_tracking_enabled' });
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: false }); // absent row → OFF
|
||||
});
|
||||
|
||||
it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', async () => {
|
||||
expect(await svc.updateBagTracking(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'true' });
|
||||
expect(await storedAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'true' });
|
||||
|
||||
expect(await svc.updateBagTracking(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' });
|
||||
expect(await storedAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' });
|
||||
});
|
||||
|
||||
it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => {
|
||||
setAppSetting('collab_chat_enabled', 'false');
|
||||
setAppSetting('collab_polls_enabled', 'true');
|
||||
await setAppSetting('collab_chat_enabled', 'false');
|
||||
await setAppSetting('collab_polls_enabled', 'true');
|
||||
|
||||
expect(await svc.getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
});
|
||||
@@ -400,7 +395,7 @@ describe('AddonsService addon/feature flags', () => {
|
||||
expect(first.features.chat).toBe(false);
|
||||
expect(setValueSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setValueSpy).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
expect(rawAppSetting('collab_chat_enabled')).toEqual({ key: 'collab_chat_enabled', value: 'false' });
|
||||
expect(await storedAppSetting('collab_chat_enabled')).toEqual({ key: 'collab_chat_enabled', value: 'false' });
|
||||
|
||||
// identical save → before and after read the same → no change, MCP sessions must survive
|
||||
setValueSpy.mockClear();
|
||||
@@ -447,17 +442,17 @@ describe('AddonsService places flags', () => {
|
||||
|
||||
it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', async () => {
|
||||
for (const [getter, , key] of cases) {
|
||||
testDb.prepare('DELETE FROM app_settings WHERE key = ?').run(key);
|
||||
await deleteRows(t, AppSettings, { key });
|
||||
expect(await svc[getter]()).toEqual({ enabled: false });
|
||||
|
||||
setAppSetting(key, 'garbage');
|
||||
await setAppSetting(key, 'garbage');
|
||||
expect(await svc[getter]()).toEqual({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-081 a stored "true" reads as ON', async () => {
|
||||
for (const [getter, , key] of cases) {
|
||||
setAppSetting(key, 'true');
|
||||
await setAppSetting(key, 'true');
|
||||
expect(await svc[getter]()).toEqual({ enabled: true });
|
||||
}
|
||||
});
|
||||
@@ -465,10 +460,10 @@ describe('AddonsService places flags', () => {
|
||||
it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', async () => {
|
||||
for (const [, setter, key] of cases) {
|
||||
expect(await svc[setter](true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting(key)).toEqual({ key, value: 'true' });
|
||||
expect(await storedAppSetting(key)).toEqual({ key, value: 'true' });
|
||||
|
||||
expect(await svc[setter](false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting(key)).toEqual({ key, value: 'false' });
|
||||
expect(await storedAppSetting(key)).toEqual({ key, value: 'false' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -489,21 +484,21 @@ describe('AddonsService places enrichment flag', () => {
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-084 only the literal "false" switches it off', async () => {
|
||||
setAppSetting('places_enrich_enabled', 'false');
|
||||
await setAppSetting('places_enrich_enabled', 'false');
|
||||
expect(await svc.getPlacesEnrich()).toEqual({ enabled: false });
|
||||
|
||||
for (const value of ['true', 'garbage', '']) {
|
||||
setAppSetting('places_enrich_enabled', value);
|
||||
await setAppSetting('places_enrich_enabled', value);
|
||||
expect(await svc.getPlacesEnrich()).toEqual({ enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', async () => {
|
||||
expect(await svc.updatePlacesEnrich(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'false' });
|
||||
expect(await storedAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'false' });
|
||||
|
||||
expect(await svc.updatePlacesEnrich(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'true' });
|
||||
expect(await storedAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'true' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,21 +519,21 @@ describe('AddonsService transit provider', () => {
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-087 only a known provider name is honoured', async () => {
|
||||
setAppSetting('transit_provider', 'google');
|
||||
await setAppSetting('transit_provider', 'google');
|
||||
expect((await svc.getTransitProvider()).provider).toBe('google');
|
||||
|
||||
for (const value of ['someday-maps', '', 'GOOGLE']) {
|
||||
setAppSetting('transit_provider', value);
|
||||
await setAppSetting('transit_provider', value);
|
||||
expect((await svc.getTransitProvider()).provider).toBe('transitous');
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-088 the setter persists the name and echoes it back', async () => {
|
||||
expect((await svc.updateTransitProvider('google')).provider).toBe('google');
|
||||
expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'google' });
|
||||
expect(await storedAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'google' });
|
||||
|
||||
expect((await svc.updateTransitProvider('transitous')).provider).toBe('transitous');
|
||||
expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'transitous' });
|
||||
expect(await storedAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'transitous' });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -547,14 +542,14 @@ describe('AddonsService transit provider', () => {
|
||||
* means "works for this admin, Transitous for everybody else".
|
||||
*/
|
||||
it('ADDONS-SVC-089 reports where the Google key resolved from', async () => {
|
||||
setAppSetting('transit_provider', 'google');
|
||||
setAppSetting('maps_api_key', 'instance-key');
|
||||
await setAppSetting('transit_provider', 'google');
|
||||
await setAppSetting('maps_api_key', 'instance-key');
|
||||
expect((await svc.getTransitProvider(7)).googleKeySource).toBe('instance');
|
||||
|
||||
// No instance row, but the caller's own users column has one.
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run();
|
||||
await deleteRows(t, AppSettings, { key: 'maps_api_key' });
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
|
||||
await updateRows(t, Users, { id: user.id }, { maps_api_key: 'personal-key' });
|
||||
expect((await svc.getTransitProvider(user.id)).googleKeySource).toBe('user-row');
|
||||
|
||||
// Nothing anywhere.
|
||||
@@ -570,21 +565,21 @@ describe('AddonsService transit provider', () => {
|
||||
*/
|
||||
describe('AddonsService places Google-only flag', () => {
|
||||
it('ADDONS-SVC-095 an unset flag reads as OFF, only the literal "true" switches it on', async () => {
|
||||
expect(rawAppSetting('places_google_only')).toBeUndefined();
|
||||
expect(await storedAppSetting('places_google_only')).toBeUndefined();
|
||||
expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: false });
|
||||
for (const value of ['false', 'TRUE', '1', '']) {
|
||||
setAppSetting('places_google_only', value);
|
||||
await setAppSetting('places_google_only', value);
|
||||
expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: false });
|
||||
}
|
||||
setAppSetting('places_google_only', 'true');
|
||||
await setAppSetting('places_google_only', 'true');
|
||||
expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-096 the setter persists the literal string under its own key', async () => {
|
||||
expect(await svc.updatePlacesGoogleOnly(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'true' });
|
||||
expect(await storedAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'true' });
|
||||
expect(await svc.updatePlacesGoogleOnly(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'false' });
|
||||
expect(await storedAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'false' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -601,28 +596,28 @@ describe('AddonsService place shadow flag', () => {
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
|
||||
for (const value of ['false', 'TRUE', '1', '']) {
|
||||
setAppSetting('place_shadow_enabled', value);
|
||||
await setAppSetting('place_shadow_enabled', value);
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-091 a stored "true" reads as ON', async () => {
|
||||
setAppSetting('place_shadow_enabled', 'true');
|
||||
await setAppSetting('place_shadow_enabled', 'true');
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-092 the setter round-trips through the getter under its own key', async () => {
|
||||
expect(await svc.updatePlaceShadow(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'true' });
|
||||
expect(await storedAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'true' });
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: true });
|
||||
// a sibling switch must not ride along
|
||||
expect(await svc.getPlacesDetails()).toEqual({ enabled: false });
|
||||
|
||||
expect(await svc.updatePlaceShadow(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'false' });
|
||||
expect(await storedAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'false' });
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
// no sibling key was ever written
|
||||
expect(rawAppSetting('places_details_enabled')).toBeUndefined();
|
||||
expect(await storedAppSetting('places_details_enabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', async () => {
|
||||
@@ -635,8 +630,8 @@ describe('AddonsService place shadow flag', () => {
|
||||
];
|
||||
|
||||
for (const [value, expected] of cases) {
|
||||
if (value === undefined) testDb.prepare("DELETE FROM app_settings WHERE key = 'place_shadow_enabled'").run();
|
||||
else setAppSetting('place_shadow_enabled', value);
|
||||
if (value === undefined) await deleteRows(t, AppSettings, { key: 'place_shadow_enabled' });
|
||||
else await setAppSetting('place_shadow_enabled', value);
|
||||
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: expected });
|
||||
expect(await shadow.enabled()).toBe(expected);
|
||||
|
||||
@@ -74,6 +74,9 @@ import {
|
||||
createTestPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { countRows, findRow, findRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
|
||||
import { Notifications } from '../../../src/db/entities/Notifications.entity';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
@@ -165,16 +168,12 @@ function mockGitHubFetchFailure(): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
}
|
||||
|
||||
function getLastNotifiedVersion(): string | undefined {
|
||||
return (
|
||||
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value;
|
||||
async function getLastNotifiedVersion(): Promise<string | undefined> {
|
||||
return (await readAppSetting(await sharedTestOrm(testDb), 'last_notified_version')) ?? undefined;
|
||||
}
|
||||
|
||||
function getNotificationCount(): number {
|
||||
return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c;
|
||||
async function getNotificationCount(): Promise<number> {
|
||||
return countRows(await sharedTestOrm(testDb), Notifications);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -201,8 +200,8 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
expect(await getNotificationCount()).toBe(0);
|
||||
expect(await getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => {
|
||||
@@ -212,11 +211,7 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{
|
||||
recipient_id: number;
|
||||
type: string;
|
||||
scope: string;
|
||||
}>;
|
||||
const notifications = await findRows(await sharedTestOrm(testDb), Notifications, {}, { id: 'asc' });
|
||||
expect(notifications.length).toBe(2);
|
||||
const recipientIds = notifications.map((n) => n.recipient_id);
|
||||
expect(recipientIds).toContain(admin1.id);
|
||||
@@ -231,7 +226,7 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getLastNotifiedVersion()).toBe('99.1.0');
|
||||
expect(await getLastNotifiedVersion()).toBe('99.1.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => {
|
||||
@@ -240,26 +235,24 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
// First call notifies
|
||||
await checkAndNotifyVersion();
|
||||
const countAfterFirst = getNotificationCount();
|
||||
const countAfterFirst = await getNotificationCount();
|
||||
expect(countAfterFirst).toBe(1);
|
||||
|
||||
// Second call with same version — should not create another
|
||||
await checkAndNotifyVersion();
|
||||
expect(getNotificationCount()).toBe(countAfterFirst);
|
||||
expect(await getNotificationCount()).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||
createAdmin(testDb);
|
||||
// Simulate having been notified about an older version
|
||||
testDb
|
||||
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('last_notified_version', '98.0.0');
|
||||
await setAppSetting(await sharedTestOrm(testDb), 'last_notified_version', '98.0.0');
|
||||
mockGitHubLatest('v99.3.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
expect(getNotificationCount()).toBe(1);
|
||||
expect(getLastNotifiedVersion()).toBe('99.3.0');
|
||||
expect(await getNotificationCount()).toBe(1);
|
||||
expect(await getLastNotifiedVersion()).toBe('99.3.0');
|
||||
});
|
||||
|
||||
it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => {
|
||||
@@ -268,14 +261,7 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as {
|
||||
type: string;
|
||||
scope: string;
|
||||
navigate_target: string;
|
||||
title_key: string;
|
||||
text_key: string;
|
||||
navigate_text_key: string;
|
||||
};
|
||||
const notif = (await findRow(await sharedTestOrm(testDb), Notifications, {}))!;
|
||||
expect(notif.type).toBe('navigate');
|
||||
expect(notif.scope).toBe('admin');
|
||||
expect(notif.navigate_target).toBe('/admin');
|
||||
@@ -290,7 +276,7 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
// Should not throw
|
||||
await expect(checkAndNotifyVersion()).resolves.toBeUndefined();
|
||||
expect(getNotificationCount()).toBe(0);
|
||||
expect(getLastNotifiedVersion()).toBeUndefined();
|
||||
expect(await getNotificationCount()).toBe(0);
|
||||
expect(await getLastNotifiedVersion()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ afterEach(() => {
|
||||
function trekDb(file: string, tables = ['users', 'trips', 'trip_members', 'places', 'days']) {
|
||||
const db = new Database(file);
|
||||
for (const t of tables) db.exec(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, name TEXT)`);
|
||||
// test-sql-allow: a hand-made backup database, before any boot; no ORM is bound to it.
|
||||
if (tables.includes('users')) db.prepare('INSERT INTO users (name) VALUES (?)').run('from-backup');
|
||||
db.close();
|
||||
}
|
||||
@@ -80,6 +81,7 @@ describe('restoreOnFirstBoot', () => {
|
||||
|
||||
expect(out.restored).toBe(true);
|
||||
const db = new Database(dbFile, { readonly: true });
|
||||
// test-sql-allow: the restored file is read before any boot; no ORM is bound to it.
|
||||
expect(db.prepare('SELECT name FROM users').get()).toEqual({ name: 'from-backup' });
|
||||
db.close();
|
||||
expect(fs.readFileSync(path.join(dataDir, '.encryption_key'), 'utf8')).toBe('a'.repeat(64));
|
||||
|
||||
@@ -38,6 +38,14 @@ import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { deleteRows, findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
|
||||
import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity';
|
||||
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
||||
import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
const mockRates = { getRates: vi.fn() };
|
||||
|
||||
@@ -98,22 +106,19 @@ const centSum = (values: number[]) => values.reduce((a, v) => a + Math.round(v *
|
||||
/** Every trip in this suite is id 1, owned by a user no test ever names or asserts on. */
|
||||
const GHOST_OWNER_ID = 999999;
|
||||
|
||||
function seedBaseline() {
|
||||
testDb.prepare(
|
||||
'INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(GHOST_OWNER_ID, 'ghost_owner', 'ghost_owner@test.example.com', 'x', 'user');
|
||||
testDb.prepare(
|
||||
"INSERT INTO trips (id, user_id, title, currency) VALUES (1, ?, 'Trip', 'EUR')",
|
||||
).run(GHOST_OWNER_ID);
|
||||
async function seedBaseline() {
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
await insertRow(orm, Users, { id: GHOST_OWNER_ID, username: 'ghost_owner', email: 'ghost_owner@test.example.com', password_hash: 'x', role: 'user' });
|
||||
await insertRow(orm, Trips, { id: 1, user: GHOST_OWNER_ID, title: 'Trip', currency: 'EUR' });
|
||||
}
|
||||
|
||||
function seedUser(id: number, username: string) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(id, username, `${username}.${id}@test.example.com`, 'x', 'user');
|
||||
async function seedUser(id: number, username: string) {
|
||||
await insertRow(await sharedTestOrm(testDb), Users, {
|
||||
id, username, email: `${username}.${id}@test.example.com`, password_hash: 'x', role: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
function setupDb(
|
||||
async function setupDb(
|
||||
items: BudgetItem[],
|
||||
members: (BudgetItemMember & { budget_item_id: number })[],
|
||||
payers: (BudgetItemPayer & { budget_item_id: number })[] = [],
|
||||
@@ -123,11 +128,12 @@ function setupDb(
|
||||
// after booking its own offered flows), so a second call must not collide with
|
||||
// the first's rows. Children before parents; users last since settlements and
|
||||
// items/members/payers all reference them.
|
||||
testDb.exec('DELETE FROM budget_settlements');
|
||||
testDb.exec('DELETE FROM budget_item_payers');
|
||||
testDb.exec('DELETE FROM budget_item_members');
|
||||
testDb.exec('DELETE FROM budget_items');
|
||||
testDb.prepare('DELETE FROM users WHERE id != ?').run(GHOST_OWNER_ID);
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
await deleteRows(orm, BudgetSettlements);
|
||||
await deleteRows(orm, BudgetItemPayers);
|
||||
await deleteRows(orm, BudgetItemMembers);
|
||||
await deleteRows(orm, BudgetItems);
|
||||
await deleteRows(orm, Users, { id: { $ne: GHOST_OWNER_ID } });
|
||||
|
||||
// Every user id these rows reference, with the username the fixture gave it —
|
||||
// members/payers carry one explicitly; a settlement-only party (no expense
|
||||
@@ -139,55 +145,52 @@ function setupDb(
|
||||
if (!usernames.has(s.from_user_id)) usernames.set(s.from_user_id, s.from_username);
|
||||
if (!usernames.has(s.to_user_id)) usernames.set(s.to_user_id, s.to_username);
|
||||
}
|
||||
for (const [id, username] of usernames) seedUser(id, username);
|
||||
for (const [id, username] of usernames) await seedUser(id, username);
|
||||
|
||||
for (const item of items) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_items (id, trip_id, name, total_price, currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(item.id, item.trip_id, item.name, item.total_price, item.currency ?? null, item.exchange_rate ?? 1);
|
||||
await insertRow(orm, BudgetItems, {
|
||||
id: item.id, trip: item.trip_id, name: item.name, total_price: item.total_price,
|
||||
currency: item.currency ?? null, exchange_rate: item.exchange_rate ?? 1,
|
||||
});
|
||||
}
|
||||
for (const m of members) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, ?, ?)',
|
||||
).run(m.budget_item_id, m.user_id, m.paid ?? 0, m.amount ?? null);
|
||||
await insertRow(orm, BudgetItemMembers, { budgetItem: m.budget_item_id, user: m.user_id, paid: m.paid ?? 0, amount: m.amount ?? null });
|
||||
}
|
||||
for (const p of payers) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)',
|
||||
).run(p.budget_item_id, p.user_id, p.amount);
|
||||
await insertRow(orm, BudgetItemPayers, { budgetItem: p.budget_item_id, user: p.user_id, amount: p.amount });
|
||||
}
|
||||
for (const s of settlements) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_settlements (id, trip_id, from_user_id, to_user_id, amount, currency, exchange_rate) VALUES (?, 1, ?, ?, ?, ?, ?)',
|
||||
).run(s.id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate);
|
||||
await insertRow(orm, BudgetSettlements, {
|
||||
id: s.id, trip: 1, fromUser: s.from_user_id, toUser: s.to_user_id, amount: s.amount, currency: s.currency, exchange_rate: s.exchange_rate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetTestDb(testDb);
|
||||
seedBaseline();
|
||||
setupDb([], [], []);
|
||||
await seedBaseline();
|
||||
await setupDb([], [], []);
|
||||
});
|
||||
|
||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateSettlement', () => {
|
||||
it('returns empty balances and flows when trip has no items', async () => {
|
||||
setupDb([], [], []);
|
||||
await setupDb([], [], []);
|
||||
const result = await budget.calculateSettlement(1);
|
||||
expect(result.balances).toEqual([]);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when there are items but no members', async () => {
|
||||
setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
||||
await setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
||||
const result = await budget.calculateSettlement(1);
|
||||
expect(result.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no flows when no one has paid', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[],
|
||||
@@ -201,7 +204,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('2 members, 1 payer: payer is owed half, non-payer owes half', async () => {
|
||||
// Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -219,7 +222,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('3 members, 1 payer: correct 3-way split', async () => {
|
||||
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 90, 'alice')],
|
||||
@@ -236,7 +239,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('all paid equally: all balances are zero, no flows', async () => {
|
||||
// Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 60)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
|
||||
@@ -252,7 +255,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('flow direction: from is debtor (owes), to is creditor (is owed)', async () => {
|
||||
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -265,7 +268,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('amounts are rounded to 2 decimal places', async () => {
|
||||
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 10)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 10, 'alice')],
|
||||
@@ -287,7 +290,7 @@ describe('calculateSettlement', () => {
|
||||
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
|
||||
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
|
||||
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100), makeItem(2, 60)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'),
|
||||
@@ -306,7 +309,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('counts a settlement with no matching expense as an amount still to square up', async () => {
|
||||
// bob paid alice 30 but every expense behind it was deleted: alice now owes bob.
|
||||
setupDb([], [], [], [makeSettlementRow(1, 2, 1, 30)]);
|
||||
await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 30)]);
|
||||
const result = await budget.calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
@@ -321,7 +324,7 @@ describe('calculateSettlement', () => {
|
||||
// $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since
|
||||
// drifted to 1.2, but the converted amount must stay on the frozen rate so an already
|
||||
// settled position isn't re-opened with a residual.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 110, 'alice')],
|
||||
@@ -333,7 +336,7 @@ describe('calculateSettlement', () => {
|
||||
});
|
||||
|
||||
it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 120, 'alice')],
|
||||
@@ -349,7 +352,7 @@ describe('calculateSettlement', () => {
|
||||
// at the settle-time rate of 1.25 USD/EUR. He records that 62.50 USD transfer, which
|
||||
// freezes currency=USD, exchange_rate=1.25. Live rates have since drifted (now the
|
||||
// recompute passes EUR=0.5 per 1 USD), but the frozen transfer still nets to -50+50=0.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)], // trip-currency expense (currency NULL)
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -364,7 +367,7 @@ describe('calculateSettlement', () => {
|
||||
// Same shape, but the transfer predates the fix (currency NULL). It is re-converted
|
||||
// to trip currency with live rates, so a drift re-opens the position — unchanged
|
||||
// legacy behaviour that normalises once the row is re-edited.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -383,7 +386,7 @@ describe('calculateSettlement', () => {
|
||||
it('2 payers, 3 members: each payer is credited what they actually paid', async () => {
|
||||
// $90 bill split 3 ways ($30 each). Alice and Bob each fronted $45.
|
||||
// Alice: +45 - 30 = +15. Bob: +45 - 30 = +15. Carol: -30.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')],
|
||||
@@ -399,7 +402,7 @@ describe('calculateSettlement', () => {
|
||||
it('2 payers with unequal amounts: credits follow the actual amounts paid', async () => {
|
||||
// $100 bill split 2 ways ($50 each). Alice fronted $70, Bob fronted $30.
|
||||
// Alice: +70 - 50 = +20. Bob: +30 - 50 = -20. Bob owes Alice $20.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')],
|
||||
@@ -417,7 +420,7 @@ describe('calculateSettlement', () => {
|
||||
|
||||
it('multi-payer balances sum to zero (no money invented or destroyed)', async () => {
|
||||
// The invariant that makes settle-up trustworthy: credits == debits.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90), makeItem(2, 55)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'),
|
||||
@@ -436,7 +439,7 @@ describe('calculateSettlement', () => {
|
||||
it('3 payers on one bill: an odd total still splits to the cent', async () => {
|
||||
// $100.01 split 3 ways. splitEqualShares distributes the remainder cent,
|
||||
// so debits must still exactly cancel the $100.01 of credits.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100.01)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')],
|
||||
@@ -453,7 +456,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => {
|
||||
it('a refund credits the members and debits its recipient', async () => {
|
||||
// A 30 € refund lands with Alice; all three had shared the original cost.
|
||||
// Alice received 30 but only 10 of it was hers, so she owes 20.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, -30)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, -30, 'alice')],
|
||||
@@ -470,7 +473,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => {
|
||||
it('a refund reduces the debt its expense created', async () => {
|
||||
// 90 € dinner fronted by Alice, then a 30 € partial reimbursement she keeps —
|
||||
// both split three ways. Bob's debt drops from 30 to 20.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90), makeItem(2, -30)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'),
|
||||
@@ -490,7 +493,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => {
|
||||
});
|
||||
|
||||
it('an odd negative total still nets to exactly zero', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, -100.01)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, -100.01, 'alice')],
|
||||
@@ -502,7 +505,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => {
|
||||
|
||||
it('a negative custom split settles by the custom amounts', async () => {
|
||||
// 100 € refund to Alice; by agreement Bob is owed 70 of it, Carol 30.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, -100)],
|
||||
[
|
||||
{ ...makeMember(1, 2, 'bob'), amount: -70 },
|
||||
@@ -523,7 +526,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => {
|
||||
// Nobody is recorded as having received the refund, so there is no credit to
|
||||
// hand back: the row is outstanding, not a debt the trip owes its members.
|
||||
// It used to credit all three 30 € out of thin air.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, -90)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[],
|
||||
@@ -572,7 +575,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
// Alice fronts 100 for the two of them. Nothing has been paid back yet, so the
|
||||
// trip costs each of them 50: Alice is out 100 with 50 still coming, Bob is out
|
||||
// nothing with 50 still to pay.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -589,7 +592,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', async () => {
|
||||
// Bob pays his 50 back. What the trip costs either of them cannot change — only
|
||||
// which of the two lines under it the 50 now sits on.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -607,7 +610,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
it('sums the finals to what the trip actually spent', async () => {
|
||||
// 90 + 60 across three people, fronted by two of them. However the debts are
|
||||
// arranged, the trip cost the group exactly what it spent.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90), makeItem(2, 60)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'),
|
||||
@@ -626,7 +629,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
|
||||
it('follows a custom split rather than an equal one', async () => {
|
||||
// Alice fronts 100 but only owes 20 of it — the split says so.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[
|
||||
{ ...makeMember(1, 1, 'alice'), amount: 20 },
|
||||
@@ -643,7 +646,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
|
||||
it('leaves an expense nobody paid out of the final budget (#2225)', async () => {
|
||||
// The unpaid row stays out of the ledger, so it cannot charge anybody either.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[],
|
||||
@@ -656,7 +659,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
it('gives a refund back to whoever was charged for it (#2176)', async () => {
|
||||
// A 30 refund Alice received, split between the two of them: each is 15 better
|
||||
// off, so the trip costs them -15 on this row alone.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, -30)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, -30, 'alice')],
|
||||
@@ -672,7 +675,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
// The three lines are converted as their own sets, like the balances are, and
|
||||
// the final is subtracted afterwards — so what the breakdown prints adds up in
|
||||
// whatever currency the viewer picked, at whatever the live rate happens to be.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -693,7 +696,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
// Bob handed Alice 40 with no expense on the trip to justify it. Alice has the
|
||||
// 40 but owes it straight back, so the trip has cost neither of them anything —
|
||||
// the transfer shows up as reimbursed on one line and outstanding on the next.
|
||||
setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]);
|
||||
await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]);
|
||||
const result = await budget.calculateSettlement(1);
|
||||
|
||||
expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: 0, reimbursed: 40, pending: -40, final: 0 });
|
||||
@@ -705,7 +708,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
// The first case read row by row, with 20 of Bob's 50 already sent: Alice
|
||||
// fronted the one expense, received the 20, and the open flow is the 30 coming
|
||||
// to her; on Bob's side the same transfer and flow are going out.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -728,7 +731,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
it('keeps an expense with no split members out of the rows, as it is out of the ledger', async () => {
|
||||
// Item 1 has a payer but nobody to split it with: a planning-only entry that
|
||||
// charges nobody, so it cannot be listed as something Alice fronted either.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100), makeItem(2, 40)],
|
||||
[makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 40, 'alice')],
|
||||
@@ -745,7 +748,7 @@ describe('calculateSettlement — finalBudgets', () => {
|
||||
// viewed in pounds: every row goes through the conversion its figure went
|
||||
// through, and the cents lost to rounding land on rows instead of between them.
|
||||
const sum = (rows: { cents: number }[]) => rows.reduce((a, r) => a + r.cents, 0);
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[
|
||||
{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.08 },
|
||||
{ ...makeItem(2, 33.33), currency: 'USD', exchange_rate: 1.1 },
|
||||
@@ -809,7 +812,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
||||
// 10.00 and Carol pays exactly, so one cent is still open. The reporter's
|
||||
// symptom was that the balances showed that cent while "Settle up" offered
|
||||
// nothing to clear it: below 0.01 the old filter dropped the person entirely.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 30)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 30, 'alice')],
|
||||
@@ -833,13 +836,13 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
||||
const items = [makeItem(1, 10)];
|
||||
const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')];
|
||||
const payers = [makePayer(1, 1, 10, 'alice')];
|
||||
setupDb(items, members, payers);
|
||||
await setupDb(items, members, payers);
|
||||
|
||||
const first = await budget.calculateSettlement(1);
|
||||
expect(first.flows.length).toBeGreaterThan(0);
|
||||
|
||||
const booked = first.flows.map((f, i) => makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount));
|
||||
setupDb(items, members, payers, booked);
|
||||
await setupDb(items, members, payers, booked);
|
||||
|
||||
const after = await budget.calculateSettlement(1);
|
||||
expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]);
|
||||
@@ -850,7 +853,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
||||
// 100 USD frozen at 1.1 is 90.909090… EUR: no split of it lands on whole cents.
|
||||
// The equal split divides the credited cents rather than the foreign total, so
|
||||
// the debits cancel the credits exactly instead of leaving dust behind.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -867,7 +870,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
||||
// is USD. Rounding each balance into that currency on its own used to leave the
|
||||
// set adding up to a cent that no flow could ever clear, and the drift moved
|
||||
// with the live rate — money appearing without a single expense being touched.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -889,7 +892,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => {
|
||||
// Nobody is down as a payer, so nobody is out of pocket and there is nothing
|
||||
// to pay back. It used to debit all three 30 € against no credit at all,
|
||||
// leaving Σ(balances) at -90 with no flow able to clear it.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||
[],
|
||||
@@ -910,7 +913,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => {
|
||||
// be debited with no credit behind it, and the simplifier (which knows
|
||||
// nothing about which expense made which debt) handed the extra 60 € to
|
||||
// Alice, who was never owed it.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 300), makeItem(2, 60)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), makeMember(1, 4, 'dave'),
|
||||
@@ -933,7 +936,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => {
|
||||
it('an unpaid expense with a custom split is skipped too', async () => {
|
||||
// The custom branch had the same hole and no coverage: 100 € split 70/30 by
|
||||
// agreement, with nobody recorded as having paid it.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 90), makeItem(2, 100)],
|
||||
[
|
||||
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'),
|
||||
@@ -956,7 +959,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => {
|
||||
// way, and both panels synthesise a missing member's 0.00 row from the trip
|
||||
// roster (CostsPanel.tsx:853, MCostsTab.tsx:273), so dropping the row costs
|
||||
// the UI nothing.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 0)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[],
|
||||
@@ -973,7 +976,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => {
|
||||
// row survives as custom per-member amounts with no credit behind them. A
|
||||
// guard that also required a non-zero total would let exactly this shape
|
||||
// through and rebuild the #2225 phantom debt from a first-party endpoint.
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 0)],
|
||||
[
|
||||
{ ...makeMember(1, 2, 'bob'), amount: 40 },
|
||||
@@ -998,7 +1001,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
const none = { item_ids: [], settlement_ids: [], currencies: [] };
|
||||
|
||||
it('VND/AUD regression: an unfrozen 8,920,000 VND bill on an AUD trip with no rates is left out whole, Σ balances 0, no VND-scale balance', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[vndBill(1), makeItem(2, 40)],
|
||||
[...four(), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol')],
|
||||
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 2, 40, 'bob')],
|
||||
@@ -1014,7 +1017,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('VND/AUD regression: frozen at 18241.3 it books 489.00 AUD, split 4 ways +366.75 / -122.25 x3', async () => {
|
||||
setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]);
|
||||
await setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]);
|
||||
const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null });
|
||||
expect(result.unconverted).toEqual(none);
|
||||
expect(result.balances.map(b => b.balance)).toEqual([366.75, -122.25, -122.25, -122.25]);
|
||||
@@ -1022,7 +1025,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('a foreign row the rates do not quote is left out and listed while others convert live', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[vndBill(1), { ...makeItem(2, 65), currency: 'USD', exchange_rate: 1 } as BudgetItem],
|
||||
[...four(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
||||
[makePayer(1, 1, 8920000, 'alice'), makePayer(2, 1, 65, 'alice')],
|
||||
@@ -1034,7 +1037,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('a frozen row converts without any rates (unchanged)', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 110, 'alice')],
|
||||
@@ -1045,7 +1048,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('trip-currency and NULL-currency rows with rate 1 count as they are', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 100), currency: 'aud', exchange_rate: 1 } as BudgetItem, { ...makeItem(2, 60), currency: null, exchange_rate: 1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 60, 'alice')],
|
||||
@@ -1056,7 +1059,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('planning-only and unpaid unconvertible rows are listed but never touch balances', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[
|
||||
{ ...makeItem(1, 500000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // planning-only
|
||||
{ ...makeItem(2, 700000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // nobody paid yet
|
||||
@@ -1071,7 +1074,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('an unfrozen foreign transfer without a quote is left out and listed in settlement_ids', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -1087,7 +1090,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('a NULL-currency transfer still reads in the display currency', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[makeItem(1, 100)],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -1100,7 +1103,7 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
});
|
||||
|
||||
it('base EUR on an AUD trip without quote or baseRate answers in AUD with currency \'AUD\'', async () => {
|
||||
setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]);
|
||||
await setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]);
|
||||
for (const rates of [null, { AUD: 1, USD: 0.65 }]) {
|
||||
const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates });
|
||||
// Trip cents, labelled as what they are rather than printed as euros.
|
||||
@@ -1113,28 +1116,28 @@ describe('calculateSettlement: unconverted rows', () => {
|
||||
const bill = () => [makeItem(1, 123.45)];
|
||||
const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')];
|
||||
const paid = () => [makePayer(1, 1, 123.45, 'alice')];
|
||||
setupDb(bill(), pair(), paid());
|
||||
await setupDb(bill(), pair(), paid());
|
||||
const before = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 });
|
||||
expect(before.currency).toBe('USD');
|
||||
// Bob's 61.73 EUR at the browser's 1.1429 dollars per euro.
|
||||
expect(before.flows.map(f => f.amount)).toEqual([70.55]);
|
||||
|
||||
// Bob pays what settle-up offers, frozen at the same browser quote (fallback_fx).
|
||||
setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]);
|
||||
await setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]);
|
||||
const after = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 });
|
||||
expect(after.balances.map(b => b.balance)).toEqual([0, 0]);
|
||||
expect(after.flows).toEqual([]);
|
||||
});
|
||||
|
||||
it('a server quote wins over baseRate', async () => {
|
||||
setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]);
|
||||
await setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]);
|
||||
const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1429 }, baseRate: 2 });
|
||||
expect(result.currency).toBe('USD');
|
||||
expect(result.flows.map(f => f.amount)).toEqual([70.55]);
|
||||
});
|
||||
|
||||
it('baseRate never converts a row', async () => {
|
||||
setupDb(
|
||||
await setupDb(
|
||||
[{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem],
|
||||
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||
[makePayer(1, 1, 100, 'alice')],
|
||||
@@ -1182,9 +1185,7 @@ describe('freezeForeignRate', () => {
|
||||
});
|
||||
|
||||
it('does not re-freeze on update when the currency is unchanged', async () => {
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_items (id, trip_id, name, total_price, currency) VALUES (9, 1, ?, ?, ?)',
|
||||
).run('Existing', 1, 'USD');
|
||||
await insertRow(await sharedTestOrm(testDb), BudgetItems, { id: 9, trip: 1, name: 'Existing', total_price: 1, currency: 'USD' });
|
||||
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
|
||||
await budget.freezeForeignRate(1, data, 9);
|
||||
expect(mockRates.getRates).not.toHaveBeenCalled();
|
||||
@@ -1215,20 +1216,22 @@ describe('applySettlementUpdate', () => {
|
||||
});
|
||||
|
||||
it('updates the row (rounded to cents) and returns the refreshed settlement', async () => {
|
||||
seedUser(1, 'alice');
|
||||
seedUser(2, 'bob');
|
||||
testDb.prepare(
|
||||
'INSERT INTO budget_settlements (id, trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at) VALUES (7, 1, 1, 2, 5, NULL, 1, NULL)',
|
||||
).run();
|
||||
await seedUser(1, 'alice');
|
||||
await seedUser(2, 'bob');
|
||||
await insertRow(await sharedTestOrm(testDb), BudgetSettlements, {
|
||||
id: 7, trip: 1, fromUser: 1, toUser: 2, amount: 5, currency: null, exchange_rate: 1, settled_at: null,
|
||||
});
|
||||
|
||||
const res = await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
// Quirk fix: currency/exchange_rate/settled_at/note are presence-gated CASE guards —
|
||||
// omitted from this update, so the row keeps what it already had (NULL/1/NULL/NULL,
|
||||
// the note since #2340) while from/to/amount (rounded) take the new values. Checked
|
||||
// against the persisted row rather than a mocked `run` call, now that there is a real one.
|
||||
const row = testDb.prepare(
|
||||
'SELECT from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, note FROM budget_settlements WHERE id = ?',
|
||||
).get(7);
|
||||
const stored = (await findRow(await sharedTestOrm(testDb), BudgetSettlements, { id: 7 }))!;
|
||||
const row = {
|
||||
from_user_id: stored.from_user_id, to_user_id: stored.to_user_id, amount: stored.amount, currency: stored.currency,
|
||||
exchange_rate: stored.exchange_rate, settled_at: stored.settled_at, note: stored.note,
|
||||
};
|
||||
expect(row).toEqual({ from_user_id: 2, to_user_id: 1, amount: 10.13, currency: null, exchange_rate: 1, settled_at: null, note: null });
|
||||
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
|
||||
});
|
||||
|
||||
@@ -67,7 +67,8 @@ import { WebhookService } from '../../../src/nest/notifications/transports/webho
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { makePlugin } from '../../helpers/factories/plugins';
|
||||
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
|
||||
|
||||
import {
|
||||
@@ -284,7 +285,7 @@ describe('the plugin channel source reaches the outside-container instance', ()
|
||||
delivered.push({ userId: user.id, title: msg.title });
|
||||
return true;
|
||||
};
|
||||
testDb.prepare("INSERT INTO plugins (id, name, version, status, enabled) VALUES ('gotify', 'Gotify', '1.0.0', 'active', 1)").run();
|
||||
await makePlugin(await sharedTestOrm(testDb), 'gotify', { name: 'Gotify', version: '1.0.0', status: 'active', enabled: 1 });
|
||||
|
||||
// The exact line plugin-runtime.service.ts runs in onModuleInit.
|
||||
setPluginChannelSource(() => runtime.notificationChannels());
|
||||
|
||||
@@ -19,14 +19,6 @@ vi.mock('../../../src/db/database', async () => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: number | string, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: number | string, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/config', () => ({
|
||||
@@ -44,6 +36,9 @@ import { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
|
||||
import type { DayNote } from '../../../src/types';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDayNotesRepo, createTestDaysRepo, createTestTripsRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { findRow } from '../../helpers/factories/rows';
|
||||
import { DayNotes } from '../../../src/db/entities/DayNotes.entity';
|
||||
|
||||
let svc: DayNotesService;
|
||||
beforeAll(async () => {
|
||||
@@ -121,6 +116,7 @@ describe('create', () => {
|
||||
const note = await svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote;
|
||||
expect(note).toMatchObject({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: '12:00', icon: '🍜', sort_order: 2 });
|
||||
expect(note.id).toBeGreaterThan(0);
|
||||
// test-sql-allow: the raw full row is the oracle the service's return value is compared against.
|
||||
const row = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id);
|
||||
expect(row).toEqual(note);
|
||||
});
|
||||
@@ -269,7 +265,7 @@ describe('remove', () => {
|
||||
const { trip, day } = seedTripAndDay();
|
||||
const note = await svc.create(day.id, trip.id, 'Lunch') as DayNote;
|
||||
await svc.remove(note.id);
|
||||
expect(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
||||
expect(await findRow(await sharedTestOrm(testDb), DayNotes, { id: note.id })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createUser } from '../../helpers/factories';
|
||||
import { IdempotencyKeys } from '../../../src/db/entities/IdempotencyKeys.entity';
|
||||
import type { IdempotencyKeysRepository } from '../../../src/db/repositories/IdempotencyKeys.repository';
|
||||
import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor';
|
||||
import { findRow as findStoredRow, insertRow } from '../../helpers/factories/rows';
|
||||
|
||||
type ReqShape = {
|
||||
method: string;
|
||||
@@ -56,8 +57,10 @@ beforeEach(() => {
|
||||
});
|
||||
afterAll(async () => { await t.close(); testDb.close(); });
|
||||
|
||||
const findRow = (key: string, user: number, method: string, path: string) =>
|
||||
testDb.prepare('SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?').get(key, user, method, path);
|
||||
const findRow = async (key: string, user: number, method: string, path: string) => {
|
||||
const row = await findStoredRow(t, IdempotencyKeys, { key, user, method, path });
|
||||
return row ? { status_code: row.status_code, response_body: row.response_body } : undefined;
|
||||
};
|
||||
|
||||
// A few `await Promise.resolve()` hops give the repository's async write
|
||||
// chain (and its `.finally(release)`) the same room it gets under a real
|
||||
@@ -116,8 +119,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
|
||||
});
|
||||
|
||||
it('replays a cached response and skips the handler', async () => {
|
||||
testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run('k', userId, 'POST', '/api/categories', 201, '{"id":5}');
|
||||
await insertRow(t, IdempotencyKeys, { key: 'k', user: userId, method: 'POST', path: '/api/categories', status_code: 201, response_body: '{"id":5}' });
|
||||
const res = makeRes();
|
||||
const h = handler('should-not-run');
|
||||
const out = await lastValueFrom(
|
||||
@@ -145,7 +147,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
|
||||
res.json({ created: true });
|
||||
await settle();
|
||||
|
||||
expect(findRow('k', userId, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"created":true}' });
|
||||
expect(await findRow('k', userId, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"created":true}' });
|
||||
});
|
||||
|
||||
it('does not cache a non-2xx response', async () => {
|
||||
@@ -161,7 +163,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
|
||||
res.json({ error: 'bad' });
|
||||
await settle();
|
||||
|
||||
expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
|
||||
expect(await findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not cache a body that exceeds the 256 KiB cap', async () => {
|
||||
@@ -178,7 +180,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle
|
||||
res.json(big);
|
||||
await settle();
|
||||
|
||||
expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
|
||||
expect(await findRow('k', userId, 'POST', '/api/categories')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('swallows a storage failure so the response still succeeds', async () => {
|
||||
|
||||
@@ -62,6 +62,11 @@ import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
|
||||
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity';
|
||||
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
||||
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
||||
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
|
||||
import {
|
||||
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
||||
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
|
||||
@@ -70,7 +75,7 @@ import {
|
||||
let svc: JourneyDomainService;
|
||||
|
||||
/** The factory has no coordinate fields, and a route is made of coordinates. */
|
||||
function placeEntry(
|
||||
async function placeEntry(
|
||||
journeyId: number,
|
||||
authorId: number,
|
||||
lat: number,
|
||||
@@ -78,9 +83,7 @@ function placeEntry(
|
||||
overrides: { title?: string; entry_date?: string; stats_excluded?: number } = {},
|
||||
) {
|
||||
const entry = createJourneyEntry(testDb, journeyId, authorId, overrides);
|
||||
testDb
|
||||
.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?')
|
||||
.run(lat, lng, entry.id);
|
||||
await updateRows(await sharedTestOrm(testDb), JourneyEntries, { id: entry.id }, { location_lat: lat, location_lng: lng });
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -147,8 +150,8 @@ describe('journeyStats route', () => {
|
||||
it('builds the route from the entries when they carry coordinates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -161,8 +164,8 @@ describe('journeyStats route', () => {
|
||||
it('orders the route by entry date, not by insertion', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -172,7 +175,7 @@ describe('journeyStats route', () => {
|
||||
it('skips entries without coordinates but still counts them as steps', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
@@ -210,7 +213,7 @@ describe('journeyStats route', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
linkTripToJourney(testDb, journey.id, trip.id);
|
||||
createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -275,8 +278,8 @@ describe('journeyStats countries', () => {
|
||||
it('resolves each stop to a country and names it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -296,9 +299,7 @@ describe('journeyStats countries', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
linkTripToJourney(testDb, journey.id, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'somewhere', lat: 64.14, lng: -21.94 });
|
||||
testDb
|
||||
.prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
|
||||
.run(place.id, 'fr', 'FR-75', 'Paris');
|
||||
await insertRow(await sharedTestOrm(testDb), PlaceRegions, { place: place.id, country_code: 'fr', region_code: 'FR-75', region_name: 'Paris' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -311,7 +312,7 @@ describe('journeyStats countries', () => {
|
||||
it('leaves a stop with no country out of the list rather than inventing one', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -325,12 +326,8 @@ describe('journeyStats totals', () => {
|
||||
it('counts the gallery photographs', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const photo = testDb
|
||||
.prepare("INSERT INTO trek_photos (provider, file_path, owner_id) VALUES ('local', 'x.jpg', ?)")
|
||||
.run(user.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)')
|
||||
.run(journey.id, photo.lastInsertRowid, Date.now());
|
||||
const photoId = await insertRow(await sharedTestOrm(testDb), TrekPhotos, { provider: 'local', file_path: 'x.jpg', owner: user.id });
|
||||
await insertRow(await sharedTestOrm(testDb), JourneyPhotos, { journey: journey.id, photo: photoId, sort_order: 0, created_at: Date.now() });
|
||||
|
||||
expect((await svc.journeyStats(journey.id, user.id))!.photos).toBe(1);
|
||||
});
|
||||
@@ -368,9 +365,7 @@ describe('journeyStats totals', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: helper } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id);
|
||||
testDb
|
||||
.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
|
||||
.run(journey.id, helper.id, 'editor', Date.now());
|
||||
await insertRow(await sharedTestOrm(testDb), JourneyContributors, { journey: journey.id, user: helper.id, role: 'editor', added_at: Date.now() });
|
||||
|
||||
expect(await svc.journeyStats(journey.id, helper.id)).not.toBeNull();
|
||||
});
|
||||
@@ -386,9 +381,9 @@ describe('journeyStats excluded stops', () => {
|
||||
it('leaves a switched-off entry out of the route, the distance and the countries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
|
||||
await placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -403,8 +398,8 @@ describe('journeyStats excluded stops', () => {
|
||||
it('names the switched-off stop under excluded so it can be switched back on', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
const airport = await placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -414,8 +409,8 @@ describe('journeyStats excluded stops', () => {
|
||||
it('does not count a switched-off entry as a step', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
|
||||
expect((await svc.journeyStats(journey.id, user.id))!.steps).toBe(1);
|
||||
});
|
||||
@@ -425,7 +420,7 @@ describe('journeyStats excluded stops', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 });
|
||||
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -436,8 +431,8 @@ describe('journeyStats excluded stops', () => {
|
||||
it('carries the entry on every stop read from the entries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' });
|
||||
const a = await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
|
||||
const b = await placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' });
|
||||
|
||||
expect((await svc.journeyStats(journey.id, user.id))!.points.map(p => p.entryId)).toEqual([a.id, b.id]);
|
||||
});
|
||||
@@ -452,9 +447,7 @@ describe('journeyStats excluded stops', () => {
|
||||
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id);
|
||||
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id);
|
||||
await svc.syncTripPlaces(journey.id, trip.id, user.id);
|
||||
testDb
|
||||
.prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?')
|
||||
.run(journey.id, airport.id);
|
||||
await updateRows(await sharedTestOrm(testDb), JourneyEntries, { journey: journey.id, sourcePlace: airport.id }, { stats_excluded: 1 });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -483,13 +476,9 @@ describe('journeyStats excluded stops', () => {
|
||||
// Switched off, and its point cleared by hand afterwards, which is what
|
||||
// sends the journey down the fallback while the skeleton still names the
|
||||
// place it came from.
|
||||
testDb
|
||||
.prepare(`
|
||||
UPDATE journey_entries
|
||||
SET stats_excluded = 1, location_lat = NULL, location_lng = NULL
|
||||
WHERE journey_id = ? AND source_place_id = ?
|
||||
`)
|
||||
.run(journey.id, airport.id);
|
||||
await updateRows(await sharedTestOrm(testDb), JourneyEntries, { journey: journey.id, sourcePlace: airport.id }, {
|
||||
stats_excluded: 1, location_lat: null, location_lng: null,
|
||||
});
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
@@ -509,7 +498,7 @@ describe('journeyStats excluded stops', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
linkTripToJourney(testDb, journey.id, trip.id);
|
||||
createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 });
|
||||
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
await placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
|
||||
|
||||
const stats = (await svc.journeyStats(journey.id, user.id))!;
|
||||
|
||||
|
||||
@@ -50,16 +50,17 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import { setAppSetting as storeAppSetting } from '../../helpers/factories/settings';
|
||||
|
||||
function setAppSetting(key: string, value: string): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
function setAppSetting(key: string, value: string): Promise<void> {
|
||||
return storeAppSetting(testOrm, key, value);
|
||||
}
|
||||
|
||||
/** The minimum that makes getSmtpConfig() return a config instead of null. */
|
||||
function configureSmtp(): void {
|
||||
setAppSetting('smtp_host', 'mail.internal.example');
|
||||
setAppSetting('smtp_port', '587');
|
||||
setAppSetting('smtp_from', 'trek@example.com');
|
||||
async function configureSmtp(): Promise<void> {
|
||||
await setAppSetting('smtp_host', 'mail.internal.example');
|
||||
await setAppSetting('smtp_port', '587');
|
||||
await setAppSetting('smtp_from', 'trek@example.com');
|
||||
}
|
||||
|
||||
let testOrm: TestOrm;
|
||||
@@ -89,7 +90,7 @@ afterAll(() => {
|
||||
|
||||
describe('MailerService TLS options', () => {
|
||||
it('MAILER-001: leaves certificate verification on by default', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true);
|
||||
|
||||
@@ -98,8 +99,8 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-002: the smtp_skip_tls_verify setting turns verification off', async () => {
|
||||
configureSmtp();
|
||||
setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
await configureSmtp();
|
||||
await setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
|
||||
await newMailer().sendEmail('someone@example.com', 'Subject', 'Body');
|
||||
|
||||
@@ -107,8 +108,8 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-003: anything other than "true" leaves verification on', async () => {
|
||||
configureSmtp();
|
||||
setAppSetting('smtp_skip_tls_verify', 'false');
|
||||
await configureSmtp();
|
||||
await setAppSetting('smtp_skip_tls_verify', 'false');
|
||||
|
||||
await newMailer().sendEmail('someone@example.com', 'Subject', 'Body');
|
||||
|
||||
@@ -117,8 +118,8 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-004: skipping verification is announced, and names the host it applies to', async () => {
|
||||
configureSmtp();
|
||||
setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
await configureSmtp();
|
||||
await setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
|
||||
await newMailer().sendEmail('someone@example.com', 'Subject', 'Body');
|
||||
|
||||
@@ -129,8 +130,8 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-005: the warning is logged once per process, not once per mail', async () => {
|
||||
configureSmtp();
|
||||
setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
await configureSmtp();
|
||||
await setAppSetting('smtp_skip_tls_verify', 'true');
|
||||
const mailer = newMailer();
|
||||
|
||||
await mailer.sendEmail('first@example.com', 'One', 'Body');
|
||||
@@ -149,7 +150,7 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-007: connecting and greeting are bounded hard, the transfer is not', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
await newMailer().sendEmail('someone@example.com', 'Subject', 'Body');
|
||||
|
||||
@@ -166,7 +167,7 @@ describe('MailerService TLS options', () => {
|
||||
});
|
||||
|
||||
it('MAILER-007b: the admin test send bounds the transfer too, because somebody is waiting', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
await newMailer().testSmtp('someone@example.com');
|
||||
|
||||
@@ -187,8 +188,8 @@ describe('MailerService test send', () => {
|
||||
}
|
||||
|
||||
it('MAILER-008: an incomplete configuration names the fields that are missing', async () => {
|
||||
setAppSetting('smtp_host', 'mail.internal.example');
|
||||
setAppSetting('smtp_port', '587');
|
||||
await setAppSetting('smtp_host', 'mail.internal.example');
|
||||
await setAppSetting('smtp_port', '587');
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
|
||||
@@ -198,9 +199,9 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-009: an unusable port is refused before anything reaches a socket', async () => {
|
||||
setAppSetting('smtp_host', 'mail.internal.example');
|
||||
setAppSetting('smtp_port', 'smtp.example.com');
|
||||
setAppSetting('smtp_from', 'trek@example.com');
|
||||
await setAppSetting('smtp_host', 'mail.internal.example');
|
||||
await setAppSetting('smtp_port', 'smtp.example.com');
|
||||
await setAppSetting('smtp_from', 'trek@example.com');
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
|
||||
@@ -211,7 +212,7 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-010: a rejected login comes back as a reason, not as a bare failure', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
sendMail.mockRejectedValueOnce(smtpError('Invalid login: 535 5.7.8 Username and Password not accepted', 'EAUTH'));
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
@@ -222,7 +223,7 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-011: a refused connection is classified and reaches the log with its code', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
sendMail.mockRejectedValueOnce(smtpError('connect ECONNREFUSED 10.0.0.5:587', 'ESOCKET'));
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
@@ -236,9 +237,9 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-012: a stalled relay is named as a timeout and points at the 465/587 split', async () => {
|
||||
setAppSetting('smtp_host', 'mail.internal.example');
|
||||
setAppSetting('smtp_port', '465');
|
||||
setAppSetting('smtp_from', 'trek@example.com');
|
||||
await setAppSetting('smtp_host', 'mail.internal.example');
|
||||
await setAppSetting('smtp_port', '465');
|
||||
await setAppSetting('smtp_from', 'trek@example.com');
|
||||
sendMail.mockRejectedValueOnce(smtpError('Greeting never received', 'ETIMEDOUT'));
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
@@ -248,9 +249,9 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-013: the password appears in neither the response nor the log', async () => {
|
||||
configureSmtp();
|
||||
setAppSetting('smtp_user', 'trek@example.com');
|
||||
setAppSetting('smtp_pass', 'correct-horse-battery');
|
||||
await configureSmtp();
|
||||
await setAppSetting('smtp_user', 'trek@example.com');
|
||||
await setAppSetting('smtp_pass', 'correct-horse-battery');
|
||||
sendMail.mockRejectedValueOnce(smtpError('Invalid login for correct-horse-battery', 'EAUTH'));
|
||||
|
||||
const result = await newMailer().testSmtp('admin@example.com');
|
||||
@@ -260,7 +261,7 @@ describe('MailerService test send', () => {
|
||||
});
|
||||
|
||||
it('MAILER-014: a successful test leaves a line in the log naming the relay', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
expect(await newMailer().testSmtp('admin@example.com')).toEqual({ success: true });
|
||||
|
||||
@@ -302,7 +303,7 @@ describe('MailerService header logo (#2507)', () => {
|
||||
}
|
||||
|
||||
it('MAILER-015: a notification mail carries the logo its HTML points to', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true);
|
||||
|
||||
@@ -310,7 +311,7 @@ describe('MailerService header logo (#2507)', () => {
|
||||
});
|
||||
|
||||
it('MAILER-016: the password-reset mail carries it too', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
expect((await newMailer().sendPasswordResetEmail('someone@example.com', 'https://trek.example/reset', null)).delivered).toBe('email');
|
||||
|
||||
@@ -318,7 +319,7 @@ describe('MailerService header logo (#2507)', () => {
|
||||
});
|
||||
|
||||
it('MAILER-017: the plain-text test mail has no HTML, so it carries no logo either', async () => {
|
||||
configureSmtp();
|
||||
await configureSmtp();
|
||||
|
||||
await newMailer().testSmtp('admin@example.com');
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ import { buildBuiltinChannels } from '../../../src/nest/notifications/channels/b
|
||||
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||
import { logError } from '../../../src/nest/audit/audit-log.logger';
|
||||
import { createTestSettingsRepo, createTestAppSettingsRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { makeAdmin, makeUser } from '../../helpers/factories/users';
|
||||
import { setAppSetting, setUserSetting } from '../../helpers/factories/settings';
|
||||
|
||||
// The transports are providers now, taking SettingsRepository/
|
||||
// AppSettingsRepository instead of DatabaseService — a real, throwaway
|
||||
@@ -476,19 +479,15 @@ describe('GHSA-7pqc-fj3c-9346: ntfy token is only attached when the target is th
|
||||
describe('GHSA-7pqc-fj3c-9346 (live path): buildBuiltinChannels sendToUser resolves configs through the repositories', () => {
|
||||
it('operator-server user gets the admin token; foreign-server user does not', async () => {
|
||||
const liveDb = createSnapshotTestDb();
|
||||
liveDb
|
||||
.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1,'op','op@x','x','admin'),(2,'u2','u2@x','x','user')")
|
||||
.run();
|
||||
liveDb
|
||||
.prepare(
|
||||
"INSERT INTO app_settings (key, value) VALUES ('admin_ntfy_server','https://ntfy.operator.example'),('admin_ntfy_topic','ops'),('admin_ntfy_token','operator-secret')",
|
||||
)
|
||||
.run();
|
||||
liveDb
|
||||
.prepare(
|
||||
"INSERT INTO settings (user_id, key, value) VALUES (1,'ntfy_topic','t1'),(2,'ntfy_topic','t2'),(2,'ntfy_server','https://ntfy.attacker.example')",
|
||||
)
|
||||
.run();
|
||||
const liveOrm = await sharedTestOrm(liveDb);
|
||||
await makeAdmin(liveOrm, { id: 1, username: 'op', email: 'op@x' });
|
||||
await makeUser(liveOrm, { id: 2, username: 'u2', email: 'u2@x' });
|
||||
await setAppSetting(liveOrm, 'admin_ntfy_server', 'https://ntfy.operator.example');
|
||||
await setAppSetting(liveOrm, 'admin_ntfy_topic', 'ops');
|
||||
await setAppSetting(liveOrm, 'admin_ntfy_token', 'operator-secret');
|
||||
await setUserSetting(liveOrm, 1, 'ntfy_topic', 't1');
|
||||
await setUserSetting(liveOrm, 2, 'ntfy_topic', 't2');
|
||||
await setUserSetting(liveOrm, 2, 'ntfy_server', 'https://ntfy.attacker.example');
|
||||
const liveNtfy = new NtfyService(await createTestSettingsRepo(liveDb), await createTestAppSettingsRepo(liveDb));
|
||||
const ntfyChannel = buildBuiltinChannels({ mailer: {} as never, webhook: {} as never, ntfy: liveNtfy, push: {} as never }).find((c) => c.id === 'ntfy')!;
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ import { db as testDb } from '../../../../src/db/database';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createUser } from '../../../helpers/factories';
|
||||
import { makePushSubscriptionsService } from '../../../helpers/notifications';
|
||||
import { sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows';
|
||||
import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity';
|
||||
import { Users } from '../../../../src/db/entities/Users.entity';
|
||||
import {
|
||||
MAX_PUSH_DEVICES_PER_USER,
|
||||
PushSubscriptionsService,
|
||||
@@ -215,7 +219,7 @@ describe('PushSubscriptionsService', () => {
|
||||
}
|
||||
// Same second for every row; the id breaks the tie, so d0 is the oldest.
|
||||
// d0 subscribing again renews it, which leaves d1 as the oldest.
|
||||
testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run();
|
||||
await updateRows(await sharedTestOrm(testDb), PushSubscriptions, {}, { created_at: '2026-01-01 00:00:00' });
|
||||
await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
|
||||
expect(await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
|
||||
MAX_PUSH_DEVICES_PER_USER,
|
||||
@@ -258,12 +262,13 @@ describe('PushSubscriptionsService', () => {
|
||||
const { user } = createUser(testDb);
|
||||
await subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
|
||||
expect((await subs.listForUser(user.id))[0].user_agent).toHaveLength(256);
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 });
|
||||
await deleteRows(await sharedTestOrm(testDb), Users, { id: user.id });
|
||||
expect(await countRows(await sharedTestOrm(testDb), PushSubscriptions)).toBe(0);
|
||||
});
|
||||
|
||||
it('PUSHSUB-008: migration 245 created the table with its user index', async () => {
|
||||
const index = testDb
|
||||
// test-sql-allow: the index list comes from sqlite_master, which no entity or repository maps.
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'")
|
||||
.all() as { name: string }[];
|
||||
expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user');
|
||||
|
||||
@@ -20,10 +20,6 @@ vi.mock('../../../src/db/database', async () => {
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return mock;
|
||||
});
|
||||
@@ -68,6 +64,8 @@ import {
|
||||
sweepPendingCodes,
|
||||
} from '../../../src/nest/oauth/oauth.pending-codes';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { dbNow } from '../../../src/db/types';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
@@ -920,10 +918,10 @@ describe('getUserByAccessToken — includes clientId (C2)', () => {
|
||||
* replay cases below still describe theft — a token used minutes later — rather
|
||||
* than two clients racing.
|
||||
*/
|
||||
function agePastGrace(rawRefreshToken: string) {
|
||||
async function agePastGrace(rawRefreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex');
|
||||
const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash);
|
||||
await updateRows(t, OauthTokens, { refresh_token_hash: hash }, { revoked_at: old });
|
||||
}
|
||||
|
||||
describe('refreshTokens — replay detection (C3)', () => {
|
||||
@@ -941,7 +939,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
|
||||
// Replay the FIRST (now revoked) refresh token, long enough after the
|
||||
// rotation that it cannot be a concurrent refresh.
|
||||
agePastGrace(firstRefresh);
|
||||
await agePastGrace(firstRefresh);
|
||||
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
||||
const replayResult = await refreshTokens(firstRefresh, clientId, rawSecret);
|
||||
expect(replayResult.error).toBe('invalid_grant');
|
||||
@@ -963,7 +961,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
const { access_token: access2, refresh_token: second } = r1.tokens!;
|
||||
|
||||
// Replay first (revoked) refresh token → chain revoke
|
||||
agePastGrace(first);
|
||||
await agePastGrace(first);
|
||||
await refreshTokens(first, clientId, rawSecret);
|
||||
|
||||
// The rotated access token should also be dead now
|
||||
@@ -990,7 +988,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
const { refresh_token: third } = r2.tokens!;
|
||||
|
||||
// Replay the first revoked token → revokes chain containing first+second+third
|
||||
agePastGrace(first);
|
||||
await agePastGrace(first);
|
||||
await refreshTokens(first, clientId, rawSecret);
|
||||
|
||||
// third should now be revoked too (it's in the same chain)
|
||||
@@ -1046,7 +1044,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
||||
|
||||
agePastGrace(shared);
|
||||
await agePastGrace(shared);
|
||||
const replay = await refreshTokens(shared, clientId, rawSecret);
|
||||
|
||||
expect(replay.error).toBe('invalid_grant');
|
||||
@@ -1071,7 +1069,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
const second = (await refreshTokens(first, clientId, rawSecret)).tokens!;
|
||||
|
||||
// Real theft: the first token turns up again once the window has passed.
|
||||
agePastGrace(first);
|
||||
await agePastGrace(first);
|
||||
expect((await refreshTokens(first, clientId, rawSecret)).error).toBe('invalid_grant');
|
||||
|
||||
// The successor was revoked with the chain, so presenting it now must not
|
||||
@@ -1235,7 +1233,7 @@ describe('branches the legacy suite could not reach', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run();
|
||||
await updateRows(t, OauthTokens, {}, { access_token_expires_at: '2000-01-01T00:00:00.000Z' });
|
||||
|
||||
expect(await getUserByAccessToken(tokens.access_token)).toBeNull();
|
||||
});
|
||||
@@ -1363,12 +1361,11 @@ describe('OauthModule', () => {
|
||||
describe('admin OAuth sessions', () => {
|
||||
it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO oauth_clients (client_id, client_secret_hash, name) VALUES ('c1', 'hash', 'Client')").run();
|
||||
testDb.prepare(`
|
||||
INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, scopes,
|
||||
access_token_expires_at, refresh_token_expires_at)
|
||||
VALUES ('c1', ?, 'ahash', 'rhash', 'not-json{', datetime('now', '+1 hour'), datetime('now', '+1 day'))
|
||||
`).run(user.id);
|
||||
await insertRow(t, OauthClients, { client_id: 'c1', client_secret_hash: 'hash', name: 'Client' });
|
||||
await insertRow(t, OauthTokens, {
|
||||
client: 'c1', user: user.id, access_token_hash: 'ahash', refresh_token_hash: 'rhash', scopes: 'not-json{',
|
||||
access_token_expires_at: dbNow(new Date(Date.now() + 3600_000)), refresh_token_expires_at: dbNow(new Date(Date.now() + 86_400_000)),
|
||||
});
|
||||
|
||||
const sessions = (await svc.listAllOAuthSessions()) as any[];
|
||||
expect(sessions).toHaveLength(1);
|
||||
|
||||
@@ -14,6 +14,10 @@ import { PLACE_REGIONS_REPAIR_DONE_KEY, PlaceRegionsRepairJob } from '../../../s
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import {
|
||||
createTestAppSettingsRepo, createTestPlacesRepo, createTestReservationEndpointsRepo, createTestTripsRepo, createTestUnitOfWork, sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
@@ -60,22 +64,20 @@ describe('the one time repair of place_regions rows cached before #2527', () =>
|
||||
let tripId: number;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const addPlace = (name: string, at: Location, cached?: [string, string, string]) => {
|
||||
const id = db
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(tripId, name, at.lat, at.lng, at.address).lastInsertRowid as number;
|
||||
const orm = () => sharedTestOrm(db);
|
||||
const addPlace = async (name: string, at: Location, cached?: [string, string, string]) => {
|
||||
const id = await insertRow(await orm(), Places, { trip: tripId, name, lat: at.lat, lng: at.lng, address: at.address });
|
||||
if (cached) {
|
||||
db.prepare(
|
||||
'INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)',
|
||||
).run(id, ...cached);
|
||||
const [country_code, region_code, region_name] = cached;
|
||||
await insertRow(await orm(), PlaceRegions, { place: id, country_code, region_code, region_name });
|
||||
}
|
||||
return id;
|
||||
};
|
||||
const cachedRow = (id: number) =>
|
||||
db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
const markedDone = () => db.prepare('SELECT 1 FROM app_settings WHERE key = ?').get(PLACE_REGIONS_REPAIR_DONE_KEY);
|
||||
const cachedRow = async (id: number) => {
|
||||
const row = await findRow(await orm(), PlaceRegions, { place: id });
|
||||
return row ? { country_code: row.country_code, region_code: row.region_code } : undefined;
|
||||
};
|
||||
const markedDone = async () => (await findRow(await orm(), AppSettings, { key: PLACE_REGIONS_REPAIR_DONE_KEY })) ?? undefined;
|
||||
// runOnBoot is where the real registrar opens the request context; here it just runs the pass.
|
||||
const registrar = (enabled: boolean) =>
|
||||
({ isEnabled: () => enabled, runOnBoot: async (_name: string, fn: () => Promise<void>) => fn() }) as unknown as CronRegistrarService;
|
||||
@@ -101,57 +103,57 @@ describe('the one time repair of place_regions rows cached before #2527', () =>
|
||||
|
||||
it('shows an upgraded install the country its corrected places are in now, without editing them again', async () => {
|
||||
// The shape from the report: moved to Germany on 4.3.2, still cached in France.
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const rhine = addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']);
|
||||
const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const rhine = await addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']);
|
||||
expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['FR']);
|
||||
|
||||
await job.runOnce();
|
||||
|
||||
expect(cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' });
|
||||
expect(cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' });
|
||||
expect(await cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' });
|
||||
expect(await cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' });
|
||||
expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['DE']);
|
||||
expect(Object.keys((await atlas.visitedRegions(userId)).regions)).toEqual(['DE']);
|
||||
expect((await atlas.getTravelStats(userId)).countries).toEqual(['DE']);
|
||||
expect((await atlas.lastTrip(userId))?.countries).toEqual(['DE']);
|
||||
expect(markedDone()).toBeTruthy();
|
||||
expect(await markedDone()).toBeTruthy();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-derives a region that moved inside the same country', async () => {
|
||||
const museum = addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const museum = await addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 1, dropped: 0 });
|
||||
expect(cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' });
|
||||
expect(await cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' });
|
||||
});
|
||||
|
||||
it('leaves good rows alone, the address fallback and rows only Nominatim could judge included', async () => {
|
||||
const paris = addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const airbnb = addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']);
|
||||
const paris = await addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const airbnb = await addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']);
|
||||
// The bundle calls this point Italian and has no region for it; the address backs the row.
|
||||
const harbour = addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]);
|
||||
const ship = addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']);
|
||||
const harbour = await addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]);
|
||||
const ship = await addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 });
|
||||
expect(cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' });
|
||||
expect(cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' });
|
||||
expect(cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' });
|
||||
expect(await cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(await cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' });
|
||||
expect(await cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' });
|
||||
expect(await cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' });
|
||||
});
|
||||
|
||||
it('drops a row the bundle cannot re-derive when coordinates and address both name another country', async () => {
|
||||
const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
const cleared = addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const beach = await addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
const cleared = await addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 2 });
|
||||
expect(cachedRow(beach)).toBeUndefined();
|
||||
expect(cachedRow(cleared)).toBeUndefined();
|
||||
expect(await cachedRow(beach)).toBeUndefined();
|
||||
expect(await cachedRow(cleared)).toBeUndefined();
|
||||
// The dashboard, which reads only the cache, no longer counts France.
|
||||
expect((await atlas.getTravelStats(userId)).countries).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not write over a place that is edited while the pass runs', async () => {
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const beach = await addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']);
|
||||
|
||||
// Both places are edited once the pass has read their rows, before it writes.
|
||||
const read = PlaceRegionsRepository.prototype.listWithPlaceLocation;
|
||||
@@ -159,13 +161,16 @@ describe('the one time repair of place_regions rows cached before #2527', () =>
|
||||
this: PlaceRegionsRepository,
|
||||
) {
|
||||
const rows = await read.call(this);
|
||||
// test-sql-allow: an edit landing mid-pass has to reach the connection directly, past the pass's own ORM work.
|
||||
db.prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?').run(
|
||||
MUNICH.lat,
|
||||
MUNICH.lng,
|
||||
MUNICH.address,
|
||||
hotel,
|
||||
);
|
||||
// test-sql-allow: an edit landing mid-pass has to reach the connection directly, past the pass's own ORM work.
|
||||
db.prepare("UPDATE places SET address = 'Westerland, Germany' WHERE id = ?").run(beach);
|
||||
// test-sql-allow: a fresh lookup landing mid-pass has to reach the connection directly, past the pass's own ORM work.
|
||||
db.prepare(
|
||||
"INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'DE', 'DE-SH', 'Schleswig-Holstein')",
|
||||
).run(beach);
|
||||
@@ -181,39 +186,38 @@ describe('the one time repair of place_regions rows cached before #2527', () =>
|
||||
expect(drop).toHaveBeenCalledTimes(1);
|
||||
await expect(drop.mock.results[0].value).resolves.toBe(false);
|
||||
// The trigger dropped the moved place's row and nothing wrote Berlin back.
|
||||
expect(cachedRow(hotel)).toBeUndefined();
|
||||
expect(await cachedRow(hotel)).toBeUndefined();
|
||||
// The row a fresh lookup wrote for the edited place stays.
|
||||
expect(cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' });
|
||||
expect(await cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' });
|
||||
});
|
||||
|
||||
it('runs once, and a second pass over the same rows changes nothing', async () => {
|
||||
// Enough rows for the pass to give way to other work along the way.
|
||||
const moved = Array.from({ length: 250 }, (_, i) =>
|
||||
addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']),
|
||||
);
|
||||
const moved: number[] = [];
|
||||
for (let i = 0; i < 250; i++) moved.push(await addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']));
|
||||
|
||||
await job.runOnce();
|
||||
expect(moved.every((id) => cachedRow(id)?.region_code === 'DE-BE')).toBe(true);
|
||||
expect((await Promise.all(moved.map(cachedRow))).every((row) => row?.region_code === 'DE-BE')).toBe(true);
|
||||
expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 });
|
||||
|
||||
// Marked as done, the next start does not read the cache again.
|
||||
const later = addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const later = await addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
await job.runOnce();
|
||||
expect(cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
expect(await cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' });
|
||||
});
|
||||
|
||||
it('is not marked as done when the pass fails, so the next start tries again', async () => {
|
||||
const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']);
|
||||
vi.spyOn(atlas, 'repairStaleRegionCache').mockRejectedValueOnce(new Error('disk I/O error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await job.runOnce();
|
||||
expect(markedDone()).toBeUndefined();
|
||||
expect(cachedRow(hotel)?.country_code).toBe('FR');
|
||||
expect(await markedDone()).toBeUndefined();
|
||||
expect((await cachedRow(hotel))?.country_code).toBe('FR');
|
||||
|
||||
await job.runOnce();
|
||||
expect(markedDone()).toBeTruthy();
|
||||
expect(cachedRow(hotel)?.country_code).toBe('DE');
|
||||
expect(await markedDone()).toBeTruthy();
|
||||
expect((await cachedRow(hotel))?.country_code).toBe('DE');
|
||||
});
|
||||
|
||||
it('starts from the boot hook, but not where the scheduler is off', async () => {
|
||||
|
||||
@@ -12,6 +12,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { setAppSetting } from '../../helpers/factories/settings';
|
||||
import { dbNow } from '../../../src/db/types';
|
||||
import { PlaceShadowService, RETENTION_DAYS } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import { PlaceShadowPicks } from '../../../src/db/entities/PlaceShadowPicks.entity';
|
||||
import type { PlaceShadowPicksRepository } from '../../../src/db/repositories/PlaceShadowPicks.repository';
|
||||
@@ -38,8 +41,12 @@ const PICK: PlaceShadowPickRequest = {
|
||||
};
|
||||
|
||||
function enable(on = true) {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', on ? 'true' : 'false');
|
||||
return setAppSetting(t, 'place_shadow_enabled', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/** A timestamp in the stored datetime('now') format, `days` days back. */
|
||||
function daysAgo(days: number): string {
|
||||
return dbNow(new Date(Date.now() - days * 86_400_000));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -56,19 +63,18 @@ describe('PlaceShadowService', () => {
|
||||
it('is off when the setting row is absent', async () => {
|
||||
expect(await svc.enabled()).toBe(false);
|
||||
expect(await svc.record(PICK)).toBe(false);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
expect(await countRows(t, PlaceShadowPicks)).toBe(0);
|
||||
});
|
||||
|
||||
it('is off for any value that is not exactly "true"', async () => {
|
||||
for (const value of ['false', '1', 'yes', 'TRUE', '']) {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', value);
|
||||
await setAppSetting(t, 'place_shadow_enabled', value);
|
||||
expect(await svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes once switched on', async () => {
|
||||
enable();
|
||||
await enable();
|
||||
expect(await svc.record(PICK)).toBe(true);
|
||||
expect((await svc.summary()).total).toBe(1);
|
||||
});
|
||||
@@ -79,7 +85,7 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
it('rounds every coordinate to three decimals', async () => {
|
||||
await svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
|
||||
const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
|
||||
const row = (await findRow(t, PlaceShadowPicks, {}))!;
|
||||
expect(row.picked_lat).toBe(54.089);
|
||||
expect(row.picked_lng).toBe(12.137);
|
||||
expect(row.bias_lat).toBe(54.089);
|
||||
@@ -88,7 +94,7 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
it('stores an absent bias and place id as NULL rather than inventing zeroes', async () => {
|
||||
await svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
|
||||
const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
|
||||
const row = (await findRow(t, PlaceShadowPicks, {}))!;
|
||||
expect(row.bias_lat).toBeNull();
|
||||
expect(row.bias_lng).toBeNull();
|
||||
expect(row.picked_place_id).toBeNull();
|
||||
@@ -105,7 +111,7 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
describe('summary', () => {
|
||||
beforeEach(async () => {
|
||||
enable();
|
||||
await enable();
|
||||
const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42];
|
||||
// Sequential, not Promise.all: the rows are inserted in rank order and the
|
||||
// export/paging assertions below read that order back.
|
||||
@@ -146,14 +152,14 @@ describe('PlaceShadowService', () => {
|
||||
|
||||
it('reports the switch state it was asked about', async () => {
|
||||
expect((await svc.summary()).enabled).toBe(true);
|
||||
enable(false);
|
||||
await enable(false);
|
||||
expect((await svc.summary()).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
beforeEach(async () => {
|
||||
enable();
|
||||
await enable();
|
||||
for (let i = 0; i < 7; i++) await svc.record({ ...PICK, query: `q${i}` });
|
||||
});
|
||||
|
||||
@@ -199,23 +205,22 @@ describe('PlaceShadowService', () => {
|
||||
// `resetTestDb` never resets `sqlite_sequence` (ids keep growing across
|
||||
// tests in this file, by design — see its own docstring), so the
|
||||
// "first" row's id is read back rather than hardcoded as `1`.
|
||||
const [first] = testDb.prepare('SELECT id FROM place_shadow_picks ORDER BY id ASC').all() as { id: number }[];
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = ?").run(first.id);
|
||||
const [first] = await findRows(t, PlaceShadowPicks, {}, { id: 'asc' });
|
||||
await updateRows(t, PlaceShadowPicks, { id: first.id }, { created_at: daysAgo(200) });
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
expect((await svc.summary()).total).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a row that is one day short of the window', async () => {
|
||||
await svc.record(PICK);
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
|
||||
.run(`-${RETENTION_DAYS - 1} days`);
|
||||
await updateRows(t, PlaceShadowPicks, {}, { created_at: daysAgo(RETENTION_DAYS - 1) });
|
||||
expect(await svc.purgeExpired()).toBe(0);
|
||||
});
|
||||
|
||||
it('runs even while the log is switched off, so old rows still age out', async () => {
|
||||
await svc.record(PICK);
|
||||
testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
|
||||
enable(false);
|
||||
await updateRows(t, PlaceShadowPicks, {}, { created_at: daysAgo(200) });
|
||||
await enable(false);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createInviteToken } from '../../helpers/factories';
|
||||
import { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
|
||||
import { createTestInviteTokensRepo, createTestTripsRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity';
|
||||
|
||||
let svc: RegistrationInvitesService;
|
||||
|
||||
@@ -57,8 +60,8 @@ describe('Invites', () => {
|
||||
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
|
||||
const result = await svc.deleteInvite(String(invite.id)) as any;
|
||||
expect(result.error).toBeUndefined();
|
||||
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
|
||||
expect(check).toBeUndefined();
|
||||
const check = await findRow(await sharedTestOrm(testDb), InviteTokens, { id: invite.id });
|
||||
expect(check).toBeNull();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => {
|
||||
@@ -68,12 +71,12 @@ describe('Invites', () => {
|
||||
|
||||
it('ADMIN-SVC-029 — deleteInvite(\'0x10\') is the legacy 404, not a hex-literal delete of invite id 16 (Plan 3b Task 3 review, F2)', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
testDb.prepare('INSERT INTO invite_tokens (id, token, max_uses, used_count, expires_at, created_by) VALUES (16, ?, 1, 0, NULL, ?)').run('hex-survivor', admin.id);
|
||||
await insertRow(await sharedTestOrm(testDb), InviteTokens, { id: 16, token: 'hex-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id });
|
||||
|
||||
const result = await svc.deleteInvite('0x10');
|
||||
|
||||
expect(result).toEqual({ error: 'Invite not found', status: 404 });
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE id = 16').get()).toBeDefined();
|
||||
expect(await findRow(await sharedTestOrm(testDb), InviteTokens, { id: 16 })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +85,7 @@ describe('Invites — trip binding', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
expect(await svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' });
|
||||
expect(await svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 });
|
||||
expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 });
|
||||
expect(await countRows(await sharedTestOrm(testDb), InviteTokens)).toBe(0);
|
||||
// An absent/blank binding is still a plain registration invite.
|
||||
expect((await svc.createInvite(admin.id, {}) as any).tripId).toBeNull();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
StorageJobsService,
|
||||
} from '../../../../src/nest/storage/storage-jobs.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow';
|
||||
import { deleteRows } from '../../../helpers/factories/rows';
|
||||
import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings';
|
||||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
function makeTmpDir(): string {
|
||||
@@ -34,11 +37,11 @@ function makeTmpDir(): string {
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
function setSetting(key: string, value: string): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
async function setSetting(key: string, value: string): Promise<void> {
|
||||
await setAppSetting(await sharedTestOrm(testDb), key, value);
|
||||
}
|
||||
beforeEach(() => {
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
|
||||
beforeEach(async () => {
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -50,7 +53,7 @@ async function makeWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -59,7 +62,7 @@ async function makeWorld() {
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ backups: 'm' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ backups: 'm' }));
|
||||
const env = { placePhotoDir: undefined };
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -87,7 +90,7 @@ async function makeMigrationWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const destRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -95,7 +98,7 @@ async function makeMigrationWorld() {
|
||||
{ name: 'dest-local', type: 'local', options: { root: destRoot } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
const env = { placePhotoDir: undefined };
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -121,7 +124,7 @@ async function makePhotosGoogleMigrationWorld() {
|
||||
const backupsRoot = makeTmpDir();
|
||||
const placePhotoRoot = makeTmpDir();
|
||||
const destRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -149,7 +152,7 @@ async function makeMigrationBackfillWorld() {
|
||||
const backupsRoot = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
const destRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -159,7 +162,7 @@ async function makeMigrationBackfillWorld() {
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' }));
|
||||
const env = { placePhotoDir: undefined };
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -174,12 +177,10 @@ async function makeMigrationBackfillWorld() {
|
||||
return { registry, storage, jobs, uploadsRoot, backupsRoot, nasRoot, destRoot };
|
||||
}
|
||||
|
||||
/** Reads the raw 'storage.categories' app_settings row (undefined key ⇒ {}). */
|
||||
function registryCategoriesRow(): Record<string, string> {
|
||||
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(CATEGORIES_KEY) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row ? (JSON.parse(row.value) as Record<string, string>) : {};
|
||||
/** Reads the stored 'storage.categories' app_settings row (undefined key ⇒ {}). */
|
||||
async function registryCategoriesRow(): Promise<Record<string, string>> {
|
||||
const value = await readAppSetting(await sharedTestOrm(testDb), CATEGORIES_KEY);
|
||||
return value !== null ? (JSON.parse(value) as Record<string, string>) : {};
|
||||
}
|
||||
|
||||
/** Polls migrationStatuses() for the named category to leave 'running', bounded ~5s. */
|
||||
@@ -307,7 +308,7 @@ describe('StorageJobsService migrations', () => {
|
||||
// copy phase's own (re-run) list(), so assert combined coverage, not
|
||||
// which phase caught it.
|
||||
expect(final.copied + final.skipped).toBeGreaterThanOrEqual(3);
|
||||
expect(registryCategoriesRow().files).toBe('dest-local'); // flip persisted
|
||||
expect((await registryCategoriesRow()).files).toBe('dest-local'); // flip persisted
|
||||
expect(fs.existsSync(path.join(destRoot, 'files', 'a.txt'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(destRoot, 'files', 'b.txt'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(destRoot, 'files', 'c.txt'))).toBe(true);
|
||||
@@ -325,7 +326,7 @@ describe('StorageJobsService migrations', () => {
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('failed');
|
||||
expect(final.failed).toBeGreaterThan(0);
|
||||
expect(registryCategoriesRow().files).toBe('uploads-local'); // NOT flipped
|
||||
expect((await registryCategoriesRow()).files).toBe('uploads-local'); // NOT flipped
|
||||
});
|
||||
|
||||
it('MIG-003 cancel before the flip leaves everything untouched', async () => {
|
||||
@@ -340,7 +341,7 @@ describe('StorageJobsService migrations', () => {
|
||||
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('cancelled');
|
||||
expect(registryCategoriesRow().files).toBe('uploads-local');
|
||||
expect((await registryCategoriesRow()).files).toBe('uploads-local');
|
||||
});
|
||||
|
||||
it('MIG-007 a cancel landing after the copy loop but before the flip ends cancelled, categories row untouched', async () => {
|
||||
@@ -361,7 +362,7 @@ describe('StorageJobsService migrations', () => {
|
||||
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('cancelled');
|
||||
expect(registryCategoriesRow().files).toBe('uploads-local'); // never flipped
|
||||
expect((await registryCategoriesRow()).files).toBe('uploads-local'); // never flipped
|
||||
});
|
||||
|
||||
it('MIG-004 validations: 400s, 404, and 409 against a running backfill (and vice versa)', async () => {
|
||||
@@ -396,7 +397,7 @@ describe('StorageJobsService migrations', () => {
|
||||
expect(final.status).toBe('done');
|
||||
expect(final.skipped).toBeGreaterThanOrEqual(1);
|
||||
expect(final.copied).toBe(0);
|
||||
expect(registryCategoriesRow().files).toBe('dest-local');
|
||||
expect((await registryCategoriesRow()).files).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('MIG-006 a sweep-phase copy failure is reported without undoing the already-flipped category', async () => {
|
||||
@@ -426,7 +427,7 @@ describe('StorageJobsService migrations', () => {
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('done'); // sweep failures don't undo a completed flip
|
||||
expect(final.failed).toBeGreaterThanOrEqual(1);
|
||||
expect(registryCategoriesRow().files).toBe('dest-local');
|
||||
expect((await registryCategoriesRow()).files).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('MIG-008 photos-google mode-A (bare "" prefix) migrating to a prefixed backend rewrites destination keys (audit #8)', async () => {
|
||||
@@ -448,7 +449,7 @@ describe('StorageJobsService migrations', () => {
|
||||
expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'abc.jpg'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'sub', 'def.jpg'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(destRoot, 'abc.jpg'))).toBe(false); // not left at the bare source key
|
||||
expect(registryCategoriesRow()['photos-google']).toBe('dest-local');
|
||||
expect((await registryCategoriesRow())['photos-google']).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('MIG-009 the skip-check on a rewritten-prefix migration stats the DESTINATION key, not the source key', async () => {
|
||||
@@ -466,7 +467,7 @@ describe('StorageJobsService migrations', () => {
|
||||
expect(final.status).toBe('done');
|
||||
expect(final.skipped).toBeGreaterThanOrEqual(1);
|
||||
expect(final.copied).toBe(0);
|
||||
expect(registryCategoriesRow()['photos-google']).toBe('dest-local');
|
||||
expect((await registryCategoriesRow())['photos-google']).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('MIG-010 the delta sweep rewrites a raced object\'s destination key too', async () => {
|
||||
@@ -488,7 +489,7 @@ describe('StorageJobsService migrations', () => {
|
||||
expect(final.status).toBe('done');
|
||||
expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'a.jpg'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'raced.jpg'))).toBe(true);
|
||||
expect(registryCategoriesRow()['photos-google']).toBe('dest-local');
|
||||
expect((await registryCategoriesRow())['photos-google']).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('MIG-011 equal-prefix migrations (files -> dest-local) stay byte-identical: destination key equals the source key', async () => {
|
||||
@@ -503,7 +504,7 @@ describe('StorageJobsService migrations', () => {
|
||||
// Both source and destination use the same 'files/' prefix — the key is
|
||||
// unchanged, exactly today's pre-fix behavior.
|
||||
expect(fs.existsSync(path.join(destRoot, 'files', 'a.txt'))).toBe(true);
|
||||
expect(registryCategoriesRow().files).toBe('dest-local');
|
||||
expect((await registryCategoriesRow()).files).toBe('dest-local');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -516,8 +517,8 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
|
||||
// Simulate a config save that drops mirror 'm' entirely — 'backups' reverts
|
||||
// to the built-in default ('backups-local'), a self-consistent config.
|
||||
setSetting('storage.backends', JSON.stringify([]));
|
||||
setSetting('storage.categories', JSON.stringify({}));
|
||||
await setSetting('storage.backends', JSON.stringify([]));
|
||||
await setSetting('storage.categories', JSON.stringify({}));
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
@@ -533,7 +534,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
const destRoot = makeTmpDir();
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
@@ -541,7 +542,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
{ name: 'dest-local', type: 'local', options: { root: destRoot } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'nas' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ files: 'nas' }));
|
||||
const env = { placePhotoDir: undefined };
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -562,14 +563,14 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
// migration's in-flight FROM backend ('nas') is dropped from the config
|
||||
// entirely — self-consistent (validateConfig only checks the new
|
||||
// category map), but the running migration's `from` no longer resolves.
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } },
|
||||
{ name: 'dest-local', type: 'local', options: { root: destRoot } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
@@ -588,7 +589,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
// still exist (the migration's already-resolved source driver instance
|
||||
// is unaffected by the roots below — the in-flight guarantee), so the
|
||||
// existing missing-backend check wouldn't catch this.
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: makeTmpDir() } },
|
||||
@@ -597,13 +598,13 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
{ name: 'third-local', type: 'local', options: { root: makeTmpDir() } },
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'third-local' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ files: 'third-local' }));
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('cancelled');
|
||||
expect(registryCategoriesRow().files).toBe('third-local'); // the save's reroute stands — no flip
|
||||
expect((await registryCategoriesRow()).files).toBe('third-local'); // the save's reroute stands — no flip
|
||||
});
|
||||
|
||||
it('JOBS-023 a save that does not touch the migrating category leaves it running', async () => {
|
||||
@@ -613,7 +614,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1);
|
||||
|
||||
// A save that touches an unrelated category only — 'files' route is untouched.
|
||||
setSetting(
|
||||
await setSetting(
|
||||
'storage.backends',
|
||||
JSON.stringify([
|
||||
{ name: 'uploads-local', type: 'local', options: { root: makeTmpDir() } },
|
||||
@@ -622,7 +623,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
]),
|
||||
);
|
||||
// Re-declare the original mapping unchanged (files still on uploads-local).
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
expect(jobs.migrationStatuses().find((m) => m.category === 'files')!.status).toBe('running');
|
||||
|
||||
@@ -44,6 +44,14 @@ import { revokeUserSessions } from '../../../src/mcp/sessionManager';
|
||||
import { expectRegisteredProvider } from '../../helpers/module-providers';
|
||||
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
|
||||
import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
/** The token row, or null once it is gone. */
|
||||
async function tokenRow(id: number | string) {
|
||||
return findRow(await sharedTestOrm(testDb), McpTokens, { id: Number(id) });
|
||||
}
|
||||
|
||||
// Schema first, then the ORM-backed repositories `TokenService` now takes
|
||||
// (Plan 3b Task 2) — `sharedTestOrm` (behind `createTestMcpTokensRepo`/
|
||||
@@ -91,9 +99,7 @@ describe('MCP token service', () => {
|
||||
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
|
||||
await insertRow(await sharedTestOrm(testDb), McpTokens, { user: user.id, name: `Token ${i}`, token_hash: `hash${i}`, token_prefix: `trek_prefix${i}` });
|
||||
}
|
||||
const result = await svc.createMcpToken(user.id, 'One More');
|
||||
expect(result.status).toBe(400);
|
||||
@@ -113,8 +119,8 @@ describe('MCP token service', () => {
|
||||
const result = await svc.deleteMcpToken(user.id, tokenId);
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
|
||||
expect(row).toBeUndefined();
|
||||
const row = await tokenRow(tokenId);
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => {
|
||||
@@ -124,7 +130,7 @@ describe('MCP token service', () => {
|
||||
vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); });
|
||||
|
||||
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
|
||||
expect(await tokenRow(tokenId)).toBeNull();
|
||||
});
|
||||
|
||||
it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => {
|
||||
@@ -146,7 +152,7 @@ describe('MCP token service', () => {
|
||||
const tokenId = String((created.token as { id: number }).id);
|
||||
|
||||
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 });
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeDefined();
|
||||
expect(await tokenRow(tokenId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('TOKEN-017: deleteMcpToken 404s (not 500) on a non-numeric id — Plan 3b Task 2 review, F1', async () => {
|
||||
@@ -207,8 +213,8 @@ describe('API key service', () => {
|
||||
expect(await svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 });
|
||||
// Neither row was touched: a wrong-kind delete must not be a way to revoke
|
||||
// someone's assistant access from the API-key screen.
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(mcpId)).toBeDefined();
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(apiId)).toBeDefined();
|
||||
expect(await tokenRow(mcpId)).not.toBeNull();
|
||||
expect(await tokenRow(apiId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => {
|
||||
@@ -225,9 +231,7 @@ describe('API key service', () => {
|
||||
const created = await svc.createApiToken(user.id, 'dawarich');
|
||||
const raw = (created.token as { raw_token: string }).raw_token;
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT token_hash, token_prefix, kind FROM mcp_tokens WHERE user_id = ?')
|
||||
.get(user.id) as { token_hash: string; token_prefix: string; kind: string };
|
||||
const row = (await findRow(await sharedTestOrm(testDb), McpTokens, { user: user.id }))!;
|
||||
expect(row.kind).toBe('api');
|
||||
expect(row.token_hash).not.toBe(raw);
|
||||
expect(raw.startsWith(row.token_prefix)).toBe(true);
|
||||
@@ -289,7 +293,7 @@ describe('MCP token service (admin view)', () => {
|
||||
const tokenId = String((created.token as { id: number }).id);
|
||||
|
||||
expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({});
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
|
||||
expect(await tokenRow(tokenId)).toBeNull();
|
||||
expect(revokeUserSessions).toHaveBeenCalledWith(user.id);
|
||||
});
|
||||
|
||||
@@ -330,7 +334,7 @@ describe('ephemeral tokens', () => {
|
||||
|
||||
it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id);
|
||||
await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { password_version: 7 });
|
||||
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
||||
|
||||
await svc.createWsToken(user.id);
|
||||
@@ -370,13 +374,13 @@ describe('verifyMcpToken', () => {
|
||||
const created = await svc.createMcpToken(user.id, 'stamped');
|
||||
const raw = (created.token as { raw_token: string }).raw_token;
|
||||
const id = (created.token as { id: number }).id;
|
||||
expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).toBeNull();
|
||||
expect((await tokenRow(id))!.last_used_at).toBeNull();
|
||||
|
||||
await svc.verifyMcpToken(raw);
|
||||
expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).not.toBeNull();
|
||||
expect((await tokenRow(id))!.last_used_at).not.toBeNull();
|
||||
|
||||
await svc.verifyMcpToken('trek_wrong');
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens').get()).toEqual({ c: 1 });
|
||||
expect(await countRows(await sharedTestOrm(testDb), McpTokens)).toBe(1);
|
||||
});
|
||||
|
||||
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => {
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo,
|
||||
createTestCategoriesRepo, createTestTripsRepo, createTestCollectionPlacesRepo, sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import type { EntityClass } from '@mikro-orm/core';
|
||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import {
|
||||
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
||||
@@ -105,7 +110,18 @@ let tours: ToursService;
|
||||
let tripId: string;
|
||||
let broadcast: MockInstance<PlacesService['broadcast']>;
|
||||
|
||||
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
const countedTables = { places: Places, tours: Tours, tour_waypoints: TourWaypoints } as const;
|
||||
const count = async (table: keyof typeof countedTables) =>
|
||||
countRows(await sharedTestOrm(testDb), countedTables[table] as EntityClass<object>);
|
||||
/**
|
||||
* The broadcast probe reads mid-request, while the import may still hold the
|
||||
* connection inside its transaction: it has to see the handle's own state
|
||||
* synchronously, past the ORM's connection queue, which a raw read on the
|
||||
* shared handle does.
|
||||
*/
|
||||
const countNow = (table: keyof typeof countedTables) =>
|
||||
// test-sql-allow: a synchronous read on the shared handle mid-transaction is what this probe measures.
|
||||
(testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
||||
|
||||
beforeAll(async () => {
|
||||
uow = await createTestUnitOfWork(testDb);
|
||||
@@ -131,15 +147,15 @@ afterEach(() => {
|
||||
afterAll(() => { testDb.close(); });
|
||||
|
||||
describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
function expectEmpty() {
|
||||
for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0);
|
||||
async function expectEmpty() {
|
||||
for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
const seen: { inTransaction: boolean; tours: number }[] = [];
|
||||
broadcast.mockImplementation(() => { seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') }); });
|
||||
broadcast.mockImplementation(() => { seen.push({ inTransaction: testDb.inTransaction, tours: countNow('tours') }); });
|
||||
|
||||
const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!;
|
||||
|
||||
@@ -151,15 +167,15 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
expect(result.tours[0]).toMatchObject({ name: 'walk', tour_type: 'hike', max_hiking_difficulty: 2, planned: false, has_waypoints: false, caution: true, match_confidence: 0.3 });
|
||||
expect(result.tours[1]).toMatchObject({ name: 'Ridge', elevation_gain: 50, elevation_loss: 30, caution: false, match_confidence: 1 });
|
||||
|
||||
const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as { id: number; trip_id: number; description: string; route_geometry: string; route_color: string }[];
|
||||
const rows = (await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })) as Array<{ id: number; trip_id: number; description: string; route_geometry: string; route_color: string }>;
|
||||
expect(rows.map(row => row.trip_id)).toEqual([Number(tripId), Number(tripId)]);
|
||||
expect(rows.map(row => row.description)).toEqual(['Route description', 'Track description']);
|
||||
expect(JSON.parse(rows[0].route_geometry)).toEqual([[48, 11], [48.01, 11.01]]);
|
||||
expect(JSON.parse(rows[1].route_geometry)).toEqual([[49, 12, 100], [49.01, 12.01, 150], [49.02, 12.02, 120]]);
|
||||
expect(new Set(rows.map(row => row.route_color)).size).toBe(2);
|
||||
expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all())
|
||||
expect((await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' })).map(tour => ({ place_id: tour.place_id, tour_type: tour.tour_type })))
|
||||
.toEqual(rows.map(row => ({ place_id: row.id, tour_type: 'hike' })));
|
||||
expect(count('tour_waypoints')).toBe(0);
|
||||
expect(await count('tour_waypoints')).toBe(0);
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 },
|
||||
@@ -179,28 +195,28 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1}
|
||||
BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure');
|
||||
expectEmpty();
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back earlier carriers when the later Place insert fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure');
|
||||
expectEmpty();
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure');
|
||||
expectEmpty();
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it.each(['<not-gpx/>', '<gpx/>', '<gpx><wpt lat="1" lon="2"/></gpx>', '<gpx><trk><trkseg><trkpt/></trkseg></trk></gpx>'])('does not write or publish unusable input %s', async (xml) => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull();
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it('propagates parser failures before entering the transaction', async () => {
|
||||
@@ -208,14 +224,14 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => { throw new Error('parser failure'); });
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure');
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expectEmpty();
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it('keeps a committed response and attempts remaining events if publication throws', async () => {
|
||||
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
broadcast.mockImplementationOnce(() => { throw new Error('transport unavailable'); });
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(await count('tours')).toBe(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(3);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -227,7 +243,7 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
});
|
||||
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(await count('places')).toBe(2);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -240,21 +256,25 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
const result = await tours.importGpxAsTour(tripId, mixedGpx);
|
||||
|
||||
expect(result?.tours).toHaveLength(2);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(await count('places')).toBe(2);
|
||||
expect(await count('tours')).toBe(2);
|
||||
expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns a successful skipped result for duplicate-only imports without publishing events', async () => {
|
||||
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
|
||||
const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all();
|
||||
const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all();
|
||||
const placeRows = async () =>
|
||||
(await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })).map(row => ({ id: row.id, trip_id: row.trip_id, name: row.name }));
|
||||
const tourFacets = async () =>
|
||||
(await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' })).map(tour => ({ place_id: tour.place_id }));
|
||||
const rowsBefore = await placeRows();
|
||||
const facetsBefore = await tourFacets();
|
||||
broadcast.mockClear();
|
||||
|
||||
expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 });
|
||||
expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore);
|
||||
expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore);
|
||||
expect(await placeRows()).toEqual(rowsBefore);
|
||||
expect(await tourFacets()).toEqual(facetsBefore);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -270,8 +290,8 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
expect(result?.tours).toHaveLength(1);
|
||||
expect(result?.tours[0].name).toBe('walk');
|
||||
expect(result?.skipped).toBe(1);
|
||||
expect(count('places')).toBe(2);
|
||||
expect(count('tours')).toBe(2);
|
||||
expect(await count('places')).toBe(2);
|
||||
expect(await count('tours')).toBe(2);
|
||||
const walkId = result!.tours[0].place_id;
|
||||
expect(broadcast.mock.calls.map(call => [call[1], call[2]])).toEqual([
|
||||
['tours:changed', { placeIds: [walkId] }],
|
||||
@@ -289,7 +309,7 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
expect(result.tours).toHaveLength(1);
|
||||
expect(result.tours[0].name).toBe('single');
|
||||
expect(result.caution).toBe(true);
|
||||
expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]);
|
||||
expect((await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })).map(row => ({ trip_id: row.trip_id }))).toEqual([{ trip_id: Number(otherTripId) }]);
|
||||
expect((await tours.listTours(otherTripId)).map(t => t.place_id)).toEqual([result.tours[0].place_id]);
|
||||
expect(await tours.listTours(tripId)).toEqual([]);
|
||||
expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created']);
|
||||
|
||||
@@ -33,6 +33,9 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTripInviteTokensRepo } from '../../helpers/test-uow';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { countRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { TripInviteTokens } from '../../../src/db/entities/TripInviteTokens.entity';
|
||||
|
||||
let svc: TripInviteService;
|
||||
beforeAll(async () => {
|
||||
@@ -75,8 +78,8 @@ describe('TripInviteService', () => {
|
||||
const first = await svc.createOrRotate(trip.id, owner.id);
|
||||
const second = await svc.createOrRotate(trip.id, owner.id);
|
||||
expect(second.token).not.toBe(first.token);
|
||||
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number };
|
||||
expect(count.n).toBe(1);
|
||||
const count = await countRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id });
|
||||
expect(count).toBe(1);
|
||||
// The old token no longer resolves.
|
||||
expect(await svc.resolve(first.token)).toBeNull();
|
||||
});
|
||||
@@ -93,16 +96,14 @@ describe('TripInviteService', () => {
|
||||
// Use the exact ISO-8601 format the service writes, one hour in the past —
|
||||
// this catches the lexicographic-SQL-comparison bug where a same-UTC-day
|
||||
// expiry would otherwise still resolve.
|
||||
testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?')
|
||||
.run(new Date(Date.now() - 3600_000).toISOString(), trip.id);
|
||||
await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() - 3600_000).toISOString() });
|
||||
expect(await svc.resolve(info.token)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-005b: a not-yet-expired token still resolves', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?')
|
||||
.run(new Date(Date.now() + 3600_000).toISOString(), trip.id);
|
||||
await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() + 3600_000).toISOString() });
|
||||
expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user