mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed the reminder, roster, Dawarich and plugin specs through the factories
Reminder jobs, user profile, trip members, Dawarich suggestions, the doc sync controller, the plugin registry and the plugin repository parity suite now write and read their rows through the ORM factories. The doc sync controller's itemsForTrip stand-in answers through the two repository reads DocSyncService makes instead of a copy of their SQL, and one more database mock drops its dead canAccessTrip/isOwner stand-ins. What stays raw is marked: the legacy statements the parity cases are held to, and the places stub in the Dawarich suggestions suite, which runs inside the service's open transaction and has to write on the connection that transaction holds.
This commit is contained in:
@@ -20,13 +20,15 @@ vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({
|
||||
// `discoverPlugins`/`setUpdateBlock`/`clearUpdateBlock` calls are all
|
||||
// repository-backed now — no more raw `DatabaseService`/`db/database` mock.
|
||||
// A real MikroORM over the full migrated schema (`createSnapshotTestDb` +
|
||||
// `createTestOrm`) replaces the old hand-rolled `:memory:` table set; every
|
||||
// `testDb.prepare(...)` fixture/assertion below still works unchanged since
|
||||
// the real schema is a strict superset of the old one's columns.
|
||||
// `createTestOrm`) replaces the old hand-rolled `:memory:` table set; the
|
||||
// fixtures and read-backs below go through the same ORM (the factories in
|
||||
// tests/helpers/factories), since the real schema is a strict superset of
|
||||
// the old one's columns.
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
@@ -80,6 +82,13 @@ const REGISTRY = {
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
|
||||
/** The plugin's stored row; fails the case when there is none. */
|
||||
async function pluginRow(id: string) {
|
||||
const row = await findRow(t, Plugins, { id });
|
||||
if (!row) throw new Error(`no plugins row for ${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
let dataRoot: string;
|
||||
let codeRoot: string;
|
||||
let svc: PluginRegistryService;
|
||||
@@ -287,9 +296,8 @@ describe('PluginRegistryService', () => {
|
||||
],
|
||||
};
|
||||
const seedRow = (hold = 0) =>
|
||||
testDb.prepare("INSERT INTO plugins (id, name, status, enabled, update_hold) VALUES ('flight-tracker','Flight','inactive',0,?)").run(hold);
|
||||
const holdInDb = () =>
|
||||
(testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold;
|
||||
insertRow(t, Plugins, { id: 'flight-tracker', name: 'Flight', status: 'inactive', enabled: 0, update_hold: hold });
|
||||
const holdInDb = async () => (await pluginRow('flight-tracker')).update_hold;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response));
|
||||
@@ -297,15 +305,15 @@ describe('PluginRegistryService', () => {
|
||||
});
|
||||
|
||||
it('holds when the admin explicitly picked an older version', async () => {
|
||||
seedRow();
|
||||
await seedRow();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true);
|
||||
expect(holdInDb()).toBe(1);
|
||||
expect(await holdInDb()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not hold when the explicit pick IS the newest compatible version', async () => {
|
||||
seedRow(1);
|
||||
await seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
expect(await holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
// The comparison is against the newest version THIS TREK can run, not the newest
|
||||
@@ -330,26 +338,26 @@ describe('PluginRegistryService', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response));
|
||||
__clearRegistryCacheForTests();
|
||||
try {
|
||||
seedRow(1);
|
||||
await seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
expect(await holdInDb()).toBe(0);
|
||||
} finally {
|
||||
delete process.env.APP_VERSION;
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-deliberate update clears a stale hold', async () => {
|
||||
seedRow(1);
|
||||
await seedRow(1);
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
expect(await holdInDb()).toBe(0);
|
||||
});
|
||||
|
||||
it('an unresolvable registry never sets a hold', async () => {
|
||||
seedRow();
|
||||
await seedRow();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
__clearRegistryCacheForTests();
|
||||
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false);
|
||||
expect(holdInDb()).toBe(0);
|
||||
expect(await holdInDb()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,7 +411,7 @@ describe('PluginRegistryService', () => {
|
||||
|
||||
// moved into place + registered inactive with provenance
|
||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true);
|
||||
const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string };
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) });
|
||||
// no staging left behind
|
||||
const staging = path.join(dataRoot, '.staging');
|
||||
@@ -451,8 +459,7 @@ describe('PluginRegistryService', () => {
|
||||
|
||||
await svc.commitUpload(staged);
|
||||
|
||||
const row = testDb.prepare('SELECT status, source_repo, reviewed_at, version FROM plugins WHERE id = ?').get('my-upload') as
|
||||
{ status: string; source_repo: string | null; reviewed_at: string | null; version: string } | undefined;
|
||||
const row = await findRow(t, Plugins, { id: 'my-upload' });
|
||||
expect(row?.status).toBe('inactive'); // never auto-activates
|
||||
expect(row?.source_repo).toBe('local:upload');
|
||||
expect(row?.reviewed_at).toBeNull(); // unsigned + unreviewed → flagged in the UI
|
||||
@@ -473,9 +480,9 @@ describe('PluginRegistryService', () => {
|
||||
it('sideload: forces INACTIVE even when replacing a plugin that was active', async () => {
|
||||
const zip = () => makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
await svc.commitUpload(svc.stageUpload(zip())); // first install
|
||||
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'my-upload'").run(); // admin activated it
|
||||
await updateRows(t, Plugins, { id: 'my-upload' }, { status: 'active', enabled: 1 }); // admin activated it
|
||||
await svc.commitUpload(svc.stageUpload(zip())); // re-upload replaces the code
|
||||
const row = testDb.prepare('SELECT status, enabled FROM plugins WHERE id = ?').get('my-upload') as { status: string; enabled: number };
|
||||
const row = await pluginRow('my-upload');
|
||||
expect(row.status).toBe('inactive'); // discoverPlugins keeps the old status; commitUpload floors it back to inactive
|
||||
expect(row.enabled).toBe(0);
|
||||
});
|
||||
@@ -509,7 +516,7 @@ describe('PluginRegistryService', () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0', trekRangeBypassed: null });
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row.author_pubkey).toBe(k.pubB64);
|
||||
});
|
||||
|
||||
@@ -637,7 +644,7 @@ describe('signature failure codes', () => {
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow();
|
||||
|
||||
const blocked = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
const blocked = await pluginRow('flight-tracker');
|
||||
expect(blocked.update_block_code).toBe('SIGNATURE_KEY_CHANGED');
|
||||
expect(blocked.update_block_version).toBe('1.0.0');
|
||||
// The plugin still RUNS fine on its old code — a blocked update is not a broken
|
||||
@@ -649,7 +656,7 @@ describe('signature failure codes', () => {
|
||||
// The author reverts to the original key: the install succeeds and the block goes.
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
const after = testDb.prepare("SELECT * FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
const after = await pluginRow('flight-tracker');
|
||||
expect(after.update_block_code).toBeNull();
|
||||
expect(after.update_block_version).toBeNull();
|
||||
});
|
||||
@@ -688,7 +695,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
async function rotateTo(b: { pubB64: string; sign: (x: Buffer) => string }, a = signingKey()) {
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
||||
await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'acme/trek-flight' });
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
return a;
|
||||
}
|
||||
@@ -716,7 +723,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
testDb.prepare("UPDATE plugins SET source_repo='acme/trek-flight' WHERE id='flight-tracker'").run();
|
||||
await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'acme/trek-flight' });
|
||||
|
||||
await expect(svc.assertRetrustable('flight-tracker', a.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
||||
});
|
||||
@@ -724,7 +731,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
it('refuses a sideloaded plugin (it sits outside the registry trust model entirely)', async () => {
|
||||
const b = signingKey();
|
||||
await rotateTo(b);
|
||||
testDb.prepare("UPDATE plugins SET source_repo='local:upload' WHERE id='flight-tracker'").run();
|
||||
await updateRows(t, Plugins, { id: 'flight-tracker' }, { source_repo: 'local:upload' });
|
||||
|
||||
await expect(svc.assertRetrustable('flight-tracker', b.pubB64)).rejects.toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
|
||||
});
|
||||
@@ -742,7 +749,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
code: 'SIGNATURE_INVALID',
|
||||
});
|
||||
// The old key is STILL pinned — a refused re-trust changes nothing.
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row.author_pubkey).toBe(a.pubB64);
|
||||
});
|
||||
|
||||
@@ -752,10 +759,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
__clearRegistryCacheForTests();
|
||||
|
||||
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: b.pubB64 })).resolves.toMatchObject({ version: '1.0.0' });
|
||||
const row = testDb.prepare("SELECT author_pubkey, update_block_code FROM plugins WHERE id='flight-tracker'").get() as {
|
||||
author_pubkey: string | null;
|
||||
update_block_code: string | null;
|
||||
};
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row.author_pubkey).toBe(b.pubB64);
|
||||
expect(row.author_pubkey).not.toBeNull();
|
||||
expect(row.update_block_code).toBeNull();
|
||||
@@ -778,7 +782,7 @@ describe('re-trust (assertRetrustable)', () => {
|
||||
await expect(svc.install('flight-tracker', { version: '1.0.0', retrustKey: 'anything' })).rejects.toMatchObject({
|
||||
code: 'SIGNATURE_MISSING',
|
||||
});
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string | null };
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row.author_pubkey).toBe(a.pubB64); // still pinned, never NULL
|
||||
});
|
||||
});
|
||||
@@ -1081,16 +1085,13 @@ describe('an update block does not outlive the registry relationship', () => {
|
||||
const b = signingKey();
|
||||
stageSignedArtifact(b.pubB64, b.sign);
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(); // rotated key → blocked
|
||||
expect(
|
||||
(testDb.prepare("SELECT update_block_code FROM plugins WHERE id='flight-tracker'").get() as { update_block_code: string })
|
||||
.update_block_code,
|
||||
).toBe('SIGNATURE_KEY_CHANGED');
|
||||
expect((await pluginRow('flight-tracker')).update_block_code).toBe('SIGNATURE_KEY_CHANGED');
|
||||
|
||||
// The admin now uploads the plugin by hand.
|
||||
const upload = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '9.9.9', type: 'widget', permissions: ['db:own'] });
|
||||
await svc.commitUpload(svc.stageUpload(upload));
|
||||
|
||||
const row = testDb.prepare("SELECT source_repo, author_pubkey, update_block_code, update_block_version FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
|
||||
const row = await pluginRow('flight-tracker');
|
||||
expect(row.source_repo).toBe('local:upload');
|
||||
expect(row.author_pubkey).toBeNull(); // out of the trust model — deliberate, and badged
|
||||
expect(row.update_block_code).toBeNull();
|
||||
|
||||
@@ -42,6 +42,13 @@ import type { JourneyDomainService } from '../../../src/nest/journey/journey-dom
|
||||
import type { DawarichVisitSuggestionsRepository } from '../../../src/db/repositories/DawarichVisitSuggestions.repository';
|
||||
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
||||
import { BucketList } from '../../../src/db/entities/BucketList.entity';
|
||||
import { Days } from '../../../src/db/entities/Days.entity';
|
||||
import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity';
|
||||
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
||||
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
|
||||
import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
|
||||
import type { BucketListRepository } from '../../../src/db/repositories/BucketList.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
@@ -137,8 +144,10 @@ const CREATED_ENTRY_ID = 7702;
|
||||
* the acceptance fail on the constraint instead of on anything a case is about.
|
||||
*/
|
||||
function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng?: number }): { id: number } {
|
||||
// test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds.
|
||||
const category = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||
const result = testDb
|
||||
// test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds.
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, category_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(Number(tripId), body.name ?? 'Imported stay', body.lat ?? null, body.lng ?? null, category?.id ?? null);
|
||||
return { id: Number(result.lastInsertRowid) };
|
||||
@@ -167,8 +176,8 @@ function armStubs(): void {
|
||||
assignmentsStub.dayExists
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
(dayId: unknown, tripId: unknown) =>
|
||||
!!testDb.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId),
|
||||
async (dayId: unknown, tripId: unknown) =>
|
||||
!!(await findRow(t, Days, { id: Number(dayId), trip: Number(tripId) })),
|
||||
);
|
||||
placesStub.broadcast.mockReset();
|
||||
placesStub.onCreated.mockReset();
|
||||
@@ -198,56 +207,33 @@ interface SuggestionSeed {
|
||||
|
||||
let _visitSeq = 0;
|
||||
|
||||
function seedSuggestion(seed: SuggestionSeed): number {
|
||||
async function seedSuggestion(seed: SuggestionSeed): Promise<number> {
|
||||
_visitSeq++;
|
||||
const result = testDb
|
||||
.prepare(
|
||||
`INSERT INTO dawarich_visit_suggestions
|
||||
(user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at,
|
||||
duration_minutes, local_date, state, matched_bucket_list_item_id, source_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
seed.userId,
|
||||
`visit-${_visitSeq}`,
|
||||
seed.tripId ?? null,
|
||||
seed.name ?? 'Cafe Central',
|
||||
seed.lat === undefined ? 48.2092 : seed.lat,
|
||||
seed.lng === undefined ? 16.3658 : seed.lng,
|
||||
seed.startedAt ?? '2026-09-01T09:30:00Z',
|
||||
seed.endedAt ?? '2026-09-01T11:45:00Z',
|
||||
seed.durationMinutes ?? 135,
|
||||
seed.localDate ?? '2026-09-01',
|
||||
seed.state ?? 'new',
|
||||
seed.matchedBucketListItemId ?? null,
|
||||
seed.sourceHash ?? `hash-${_visitSeq}`,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
return insertRow(t, DawarichVisitSuggestions, {
|
||||
user: seed.userId,
|
||||
source_visit_id: `visit-${_visitSeq}`,
|
||||
trip: seed.tripId ?? null,
|
||||
name: seed.name ?? 'Cafe Central',
|
||||
lat: seed.lat === undefined ? 48.2092 : seed.lat,
|
||||
lng: seed.lng === undefined ? 16.3658 : seed.lng,
|
||||
started_at: seed.startedAt ?? '2026-09-01T09:30:00Z',
|
||||
ended_at: seed.endedAt ?? '2026-09-01T11:45:00Z',
|
||||
duration_minutes: seed.durationMinutes ?? 135,
|
||||
local_date: seed.localDate ?? '2026-09-01',
|
||||
state: seed.state ?? 'new',
|
||||
matchedBucketListItem: seed.matchedBucketListItemId ?? null,
|
||||
source_hash: seed.sourceHash ?? `hash-${_visitSeq}`,
|
||||
});
|
||||
}
|
||||
|
||||
interface SuggestionRow {
|
||||
id: number;
|
||||
state: string;
|
||||
target: string | null;
|
||||
accepted_place_id: number | null;
|
||||
accepted_journal_entry_id: number | null;
|
||||
accepted_bucket_list_item_id: number | null;
|
||||
source_hash: string;
|
||||
accepted_hash: string | null;
|
||||
/** The stored suggestion; fails the case when it is gone. */
|
||||
async function rowOf(id: number) {
|
||||
const row = await findRow(t, DawarichVisitSuggestions, { id });
|
||||
if (!row) throw new Error(`no suggestion ${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowOf(id: number): SuggestionRow {
|
||||
return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as SuggestionRow;
|
||||
}
|
||||
|
||||
interface BucketRow {
|
||||
id: number;
|
||||
name: string;
|
||||
visited_at: string | null;
|
||||
visited_source: string | null;
|
||||
}
|
||||
|
||||
function seedBucketItem(
|
||||
async function seedBucketItem(
|
||||
userId: number,
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
@@ -256,24 +242,22 @@ function seedBucketItem(
|
||||
visitedAt: string | null;
|
||||
visitedSource: string | null;
|
||||
}> = {},
|
||||
): number {
|
||||
const result = testDb
|
||||
.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng, visited_at, visited_source) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
overrides.name ?? 'Zell am See',
|
||||
overrides.lat === undefined ? 47.3232 : overrides.lat,
|
||||
overrides.lng === undefined ? 12.7981 : overrides.lng,
|
||||
overrides.visitedAt ?? null,
|
||||
overrides.visitedSource ?? null,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
): Promise<number> {
|
||||
return insertRow(t, BucketList, {
|
||||
user: userId,
|
||||
name: overrides.name ?? 'Zell am See',
|
||||
lat: overrides.lat === undefined ? 47.3232 : overrides.lat,
|
||||
lng: overrides.lng === undefined ? 12.7981 : overrides.lng,
|
||||
visited_at: overrides.visitedAt ?? null,
|
||||
visited_source: overrides.visitedSource ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function bucketOf(id: number): BucketRow {
|
||||
return testDb.prepare('SELECT * FROM bucket_list WHERE id = ?').get(id) as BucketRow;
|
||||
/** The stored wish; fails the case when it is gone. */
|
||||
async function bucketOf(id: number) {
|
||||
const row = await findRow(t, BucketList, { id });
|
||||
if (!row) throw new Error(`no bucket list item ${id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,8 +295,8 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", async () => {
|
||||
const { user: mine } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' });
|
||||
const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
const mineId = await seedSuggestion({ userId: mine.id, name: 'Volksgarten' });
|
||||
const strangerId = await seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
|
||||
const list = await svc.list(mine.id, {});
|
||||
|
||||
@@ -329,10 +313,10 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const strangerTrip = createTrip(testDb, stranger.id);
|
||||
|
||||
const onTrip = seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' });
|
||||
const dismissed = seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' });
|
||||
seedSuggestion({ userId: user.id, tripId: otherTrip.id });
|
||||
seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id });
|
||||
const onTrip = await seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' });
|
||||
const dismissed = await seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' });
|
||||
await seedSuggestion({ userId: user.id, tripId: otherTrip.id });
|
||||
await seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id });
|
||||
|
||||
const byTrip = (await svc.list(user.id, { tripId: trip.id }))
|
||||
.suggestions.map((s) => s.id)
|
||||
@@ -348,7 +332,7 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
|
||||
it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedSuggestion({ userId: user.id });
|
||||
await seedSuggestion({ userId: user.id });
|
||||
|
||||
const list = await svc.list(user.id, {});
|
||||
|
||||
@@ -362,7 +346,7 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
const theirs = await seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
|
||||
|
||||
expect(await svc.getOne(user.id, theirs)).toBeNull();
|
||||
expect((await svc.getOne(stranger.id, theirs))?.name).toBe('Their Kitchen');
|
||||
@@ -370,22 +354,19 @@ describe('DawarichSuggestionsService — the review list', () => {
|
||||
|
||||
it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const wish = await seedBucketItem(user.id);
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
|
||||
|
||||
// Everything a later sync is allowed to do to a row somebody already
|
||||
// accepted: the detector re-ran and the hash moved, it upgraded the stay
|
||||
// from suggested to confirmed, and then the visit disappeared from
|
||||
// Dawarich altogether. The tick the user made stands through all of it.
|
||||
testDb
|
||||
.prepare(
|
||||
`UPDATE dawarich_visit_suggestions
|
||||
SET source_hash = 'hash-after-redetection', source_status = 'confirmed',
|
||||
source_missing_at = '2026-09-07T00:00:00Z'
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(id);
|
||||
await updateRows(t, DawarichVisitSuggestions, { id }, {
|
||||
source_hash: 'hash-after-redetection',
|
||||
source_status: 'confirmed',
|
||||
source_missing_at: '2026-09-07T00:00:00Z',
|
||||
});
|
||||
|
||||
const wire = (await svc.getOne(user.id, id))!;
|
||||
expect(wire.sourceStatus).toBe('confirmed');
|
||||
@@ -404,14 +385,14 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const strangerTrip = createTrip(testDb, stranger.id);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id }));
|
||||
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe('not_found');
|
||||
expect(placesStub.create).not.toHaveBeenCalled();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', async () => {
|
||||
@@ -419,7 +400,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-09-01' });
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
|
||||
const err = await asyncRefusalFrom(() =>
|
||||
svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: foreignDay.id }),
|
||||
@@ -431,7 +412,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
expect(assignmentsStub.dayExists).toHaveBeenCalledWith(foreignDay.id, trip.id);
|
||||
expect(placesStub.create).not.toHaveBeenCalled();
|
||||
expect(assignmentsStub.createAssignment).not.toHaveBeenCalled();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', async () => {
|
||||
@@ -440,7 +421,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const id = seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' });
|
||||
const id = await seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' });
|
||||
|
||||
const result = await svc.accept(member.id, id, {
|
||||
target: 'place',
|
||||
@@ -467,7 +448,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
expect(result.createdJournalEntryId).toBeNull();
|
||||
expect(result.suggestion.state).toBe('accepted');
|
||||
|
||||
const row = rowOf(id);
|
||||
const row = await rowOf(id);
|
||||
expect(row.target).toBe('place');
|
||||
expect(row.accepted_place_id).toBe(placeId);
|
||||
// The hash is frozen at acceptance, which is what makes "changed since" answerable.
|
||||
@@ -488,16 +469,17 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
// the write", not merely "changed before either ran".
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id, sourceHash: 'hash-at-accept-start' });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id, sourceHash: 'hash-at-accept-start' });
|
||||
|
||||
placesStub.create.mockImplementationOnce((tripId: string, body: { name?: string; lat?: number; lng?: number }) => {
|
||||
// test-sql-allow: runs inside the service's open transaction, on the connection that transaction holds.
|
||||
testDb.prepare('UPDATE dawarich_visit_suggestions SET source_hash = ? WHERE id = ?').run('hash-changed-concurrently', id);
|
||||
return insertPlaceRow(tripId, body);
|
||||
});
|
||||
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
const row = rowOf(id);
|
||||
const row = await rowOf(id);
|
||||
expect(row.source_hash).toBe('hash-changed-concurrently');
|
||||
// A bound-at-read-time literal would have frozen 'hash-at-accept-start'
|
||||
// here instead — the whole point of the column-ref form.
|
||||
@@ -507,7 +489,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
|
||||
it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: null });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: null });
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'place' }));
|
||||
|
||||
@@ -522,7 +504,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
// the whole form decorative.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' });
|
||||
|
||||
await svc.accept(user.id, id, {
|
||||
target: 'place',
|
||||
@@ -556,7 +538,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
// coast of Africa with a duration of "0 min" is worse than one with none.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 });
|
||||
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
@@ -569,7 +551,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
it('DAWARICH-SUG-045: with no trip in the body, the one the sync filed the stay under is used', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'place' });
|
||||
|
||||
@@ -584,7 +566,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
// time field.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({
|
||||
const id = await seedSuggestion({
|
||||
userId: user.id,
|
||||
tripId: trip.id,
|
||||
startedAt: '2026-09-01',
|
||||
@@ -603,7 +585,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const theirs = seedSuggestion({ userId: stranger.id });
|
||||
const theirs = await seedSuggestion({ userId: stranger.id });
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id }));
|
||||
|
||||
@@ -618,7 +600,7 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
|
||||
describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
journeyStub.canEdit.mockReturnValue(false);
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 }));
|
||||
@@ -627,12 +609,12 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe('journal_forbidden');
|
||||
expect(journeyStub.createEntry).not.toHaveBeenCalled();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' });
|
||||
const id = await seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7');
|
||||
|
||||
@@ -653,7 +635,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
expect(result.createdJournalEntryId).toBe(CREATED_ENTRY_ID);
|
||||
expect(result.createdPlaceId).toBeNull();
|
||||
|
||||
const row = rowOf(id);
|
||||
const row = await rowOf(id);
|
||||
expect(row.state).toBe('accepted');
|
||||
expect(row.target).toBe('journal');
|
||||
expect(row.accepted_journal_entry_id).toBe(CREATED_ENTRY_ID);
|
||||
@@ -661,7 +643,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
|
||||
it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal' }));
|
||||
|
||||
@@ -672,7 +654,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
|
||||
it('DAWARICH-SUG-047: a corrected entry carries the corrections, not the recording', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' });
|
||||
const id = await seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' });
|
||||
|
||||
await svc.accept(user.id, id, {
|
||||
target: 'journal',
|
||||
@@ -706,7 +688,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
// (a location_lat of null would put a pin on the map at the equator), so
|
||||
// everything unknown has to arrive as undefined rather than as null.
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({
|
||||
const id = await seedSuggestion({
|
||||
userId: user.id,
|
||||
lat: null,
|
||||
lng: null,
|
||||
@@ -726,13 +708,13 @@ describe('DawarichSuggestionsService — accepting into a journey', () => {
|
||||
|
||||
it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
journeyStub.createEntry.mockReturnValue(null);
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 }));
|
||||
|
||||
expect(err.status).toBe(404);
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -742,8 +724,8 @@ describe('DawarichSuggestionsService — ticking off a wish', () => {
|
||||
it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const theirWish = seedBucketItem(stranger.id, { name: 'Their Hallstatt' });
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const theirWish = await seedBucketItem(stranger.id, { name: 'Their Hallstatt' });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
|
||||
const err = await asyncRefusalFrom(() =>
|
||||
svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: theirWish }),
|
||||
@@ -751,39 +733,39 @@ describe('DawarichSuggestionsService — ticking off a wish', () => {
|
||||
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe('not_found');
|
||||
expect(bucketOf(theirWish).visited_at).toBeNull();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await bucketOf(theirWish)).visited_at).toBeNull();
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const id = seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' });
|
||||
const wish = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const id = await seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
|
||||
|
||||
const item = bucketOf(wish);
|
||||
const item = await bucketOf(wish);
|
||||
expect(item.visited_at).toBe('2026-09-01T09:30:00Z');
|
||||
expect(item.visited_source).toBe('dawarich');
|
||||
expect(result.bucketListItemId).toBe(wish);
|
||||
expect(result.createdPlaceId).toBeNull();
|
||||
expect(rowOf(id).accepted_bucket_list_item_id).toBe(wish);
|
||||
expect((await rowOf(id)).accepted_bucket_list_item_id).toBe(wish);
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: wish });
|
||||
const wish = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const id = await seedSuggestion({ userId: user.id, matchedBucketListItemId: wish });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'bucket_list' });
|
||||
|
||||
expect(result.bucketListItemId).toBe(wish);
|
||||
expect(bucketOf(wish).visited_source).toBe('dawarich');
|
||||
expect((await bucketOf(wish)).visited_source).toBe('dawarich');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: null });
|
||||
const id = await seedSuggestion({ userId: user.id, matchedBucketListItemId: null });
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' }));
|
||||
|
||||
@@ -798,7 +780,7 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
|
||||
const first = await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
expect(placesStub.create).toHaveBeenCalledTimes(1);
|
||||
@@ -809,28 +791,28 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
expect(err.code).toBe('already_accepted');
|
||||
expect(placesStub.create).toHaveBeenCalledTimes(1);
|
||||
// Still pointing at the first place, so the refusal cost nothing and created nothing.
|
||||
expect(rowOf(id).accepted_place_id).toBe(first.createdPlaceId);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 });
|
||||
expect((await rowOf(id)).accepted_place_id).toBe(first.createdPlaceId);
|
||||
expect(await countRows(t, Places, { trip: trip.id })).toBe(1);
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const wish = await seedBucketItem(user.id);
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
await svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
|
||||
|
||||
const back = await svc.setState(user.id, id, 'new');
|
||||
|
||||
expect(back?.state).toBe('accepted');
|
||||
expect(rowOf(id).state).toBe('accepted');
|
||||
expect((await rowOf(id)).state).toBe('accepted');
|
||||
// Dismissing it is refused the same way — the tick it produced is its own thing now.
|
||||
expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('accepted');
|
||||
expect(rowOf(id).target).toBe('bucket_list');
|
||||
expect((await rowOf(id)).target).toBe('bucket_list');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id });
|
||||
const id = await seedSuggestion({ userId: user.id });
|
||||
|
||||
expect((await svc.setState(user.id, id, 'dismissed'))?.state).toBe('dismissed');
|
||||
expect((await svc.setState(user.id, id, 'new'))?.state).toBe('new');
|
||||
@@ -839,10 +821,10 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const theirs = seedSuggestion({ userId: stranger.id });
|
||||
const theirs = await seedSuggestion({ userId: stranger.id });
|
||||
|
||||
expect(await svc.setState(user.id, theirs, 'dismissed')).toBeNull();
|
||||
expect(rowOf(theirs).state).toBe('new');
|
||||
expect((await rowOf(theirs)).state).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -851,8 +833,8 @@ describe('DawarichSuggestionsService — what an acceptance closes off', () => {
|
||||
describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const open = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const alreadyTicked = seedBucketItem(user.id, {
|
||||
const open = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const alreadyTicked = await seedBucketItem(user.id, {
|
||||
name: 'Kyoto',
|
||||
visitedAt: '2026-01-04T12:00:00Z',
|
||||
visitedSource: 'manual',
|
||||
@@ -861,24 +843,24 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
const updated = await svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z');
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
expect(bucketOf(open).visited_source).toBe('dawarich');
|
||||
expect((await bucketOf(open)).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
expect((await bucketOf(open)).visited_source).toBe('dawarich');
|
||||
// The hand-ticked one keeps both its date and who decided it.
|
||||
expect(bucketOf(alreadyTicked).visited_at).toBe('2026-01-04T12:00:00Z');
|
||||
expect(bucketOf(alreadyTicked).visited_source).toBe('manual');
|
||||
expect((await bucketOf(alreadyTicked)).visited_at).toBe('2026-01-04T12:00:00Z');
|
||||
expect((await bucketOf(alreadyTicked)).visited_source).toBe('manual');
|
||||
});
|
||||
|
||||
it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mine = seedBucketItem(user.id);
|
||||
const theirs = seedBucketItem(stranger.id);
|
||||
const mine = await seedBucketItem(user.id);
|
||||
const theirs = await seedBucketItem(stranger.id);
|
||||
|
||||
const updated = await svc.confirmBucketVisits(user.id, [mine, theirs]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(theirs).visited_at).toBeNull();
|
||||
expect(bucketOf(mine).visited_at).not.toBeNull();
|
||||
expect((await bucketOf(theirs)).visited_at).toBeNull();
|
||||
expect((await bucketOf(mine)).visited_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', async () => {
|
||||
@@ -886,28 +868,28 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
// entry in a list people keep for years. The clock is only the answer when
|
||||
// nothing else knows better.
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' });
|
||||
const wish = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' });
|
||||
|
||||
const updated = await svc.confirmBucketVisits(user.id, [wish]);
|
||||
|
||||
expect(updated).toBe(1);
|
||||
expect(bucketOf(wish).visited_at).toBe('2023-07-14T08:20:00Z');
|
||||
expect(bucketOf(wish).visited_source).toBe('dawarich');
|
||||
expect((await bucketOf(wish)).visited_at).toBe('2023-07-14T08:20:00Z');
|
||||
expect((await bucketOf(wish)).visited_source).toBe('dawarich');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
const mine = await seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
const theirs = await seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
|
||||
|
||||
expect(await svc.clearBucketVisit(user.id, mine)).toBe(true);
|
||||
expect(bucketOf(mine).visited_at).toBeNull();
|
||||
expect(bucketOf(mine).visited_source).toBeNull();
|
||||
expect((await bucketOf(mine)).visited_at).toBeNull();
|
||||
expect((await bucketOf(mine)).visited_source).toBeNull();
|
||||
|
||||
expect(await svc.clearBucketVisit(user.id, theirs)).toBe(false);
|
||||
expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
expect((await bucketOf(theirs)).visited_at).toBe('2026-09-05T10:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -916,7 +898,7 @@ describe('DawarichSuggestionsService — confirming a scan', () => {
|
||||
describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
it('DAWARICH-SUG-025: a scan without a connection refuses before a single request', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id);
|
||||
await seedBucketItem(user.id);
|
||||
dawarichStub.getCredentials.mockReturnValue(null);
|
||||
|
||||
const err = await asyncRefusalFrom(() => svc.scanBucketList(user.id));
|
||||
@@ -928,8 +910,8 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
|
||||
it('DAWARICH-SUG-026: the longest stay wins over the nearest, and a wish without coordinates reports as skipped', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null });
|
||||
const wish = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
{
|
||||
timestamp: 1757000000,
|
||||
@@ -965,7 +947,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
// mode this flag exists to prevent.
|
||||
const { user } = createUser(testDb);
|
||||
for (let i = 0; i < DAWARICH_BUCKET_SCAN_LIMIT + 1; i++) {
|
||||
seedBucketItem(user.id, { name: `Wish ${i}` });
|
||||
await seedBucketItem(user.id, { name: `Wish ${i}` });
|
||||
}
|
||||
clientStub.findVisitsNear.mockResolvedValue([]);
|
||||
|
||||
@@ -983,8 +965,8 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
// tick did, but flagged, so the list reads as "these are new" rather than
|
||||
// as a wall of things the user has known for years.
|
||||
const { user } = createUser(testDb);
|
||||
const open = seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const ticked = seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2023-04-02T09:00:00Z' });
|
||||
const open = await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
const ticked = await seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2023-04-02T09:00:00Z' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([]);
|
||||
|
||||
const scan = await svc.scanBucketList(user.id);
|
||||
@@ -999,7 +981,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
// it answers with stays from its own default 500 m, and a four-hour stay
|
||||
// 600 m away is somewhere else entirely.
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
{
|
||||
timestamp: 1757000000,
|
||||
@@ -1020,7 +1002,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
|
||||
it('DAWARICH-SUG-052: with no duration reported the two timestamps decide, and a stay with no details is skipped', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
// Nothing to measure at all: no duration, no times. It cannot clear the
|
||||
// dwell threshold, so it is not a visit.
|
||||
@@ -1048,7 +1030,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
// for a 250 m radius: treating the answer as "at most the radius" keeps the
|
||||
// stay rather than dropping a real match over a missing field.
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
{
|
||||
timestamp: 1_757_000_000,
|
||||
@@ -1069,7 +1051,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
|
||||
it('DAWARICH-SUG-054: a stay that cannot be dated at all is skipped, not ticked off on 1970-01-01', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
{ timestamp: null, distance_meters: 10, points_count: 8, visit_details: { duration_minutes: 60 } },
|
||||
]);
|
||||
@@ -1081,7 +1063,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
|
||||
it('DAWARICH-SUG-055: the longest qualifying stay wins, and a later shorter one does not displace it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockResolvedValue([
|
||||
{
|
||||
timestamp: 1757000000,
|
||||
@@ -1112,7 +1094,7 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
|
||||
it('DAWARICH-SUG-027: a wish whose lookup fails reads as "no match", not as a failed scan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
await seedBucketItem(user.id, { name: 'Hallstatt' });
|
||||
clientStub.findVisitsNear.mockRejectedValue(new Error('upstream is down'));
|
||||
|
||||
const scan = await svc.scanBucketList(user.id);
|
||||
@@ -1204,7 +1186,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
['FR', false],
|
||||
['AT', false],
|
||||
]);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM visited_countries WHERE user_id = ?').get(user.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(t, VisitedCountries, { user: user.id })).toBe(0);
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-064: the shape the Atlas answers for a user without trips carries no status and reads as visited', async () => {
|
||||
@@ -1278,7 +1260,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7');
|
||||
|
||||
@@ -1299,7 +1281,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
// next full load, which is the state this re-read exists to avoid.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
@@ -1314,7 +1296,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
// the trip never receives is a place that needs a page reload to appear.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
// Plan 3h Task 3: the re-read is `PlacesRepository.findWithTagsAndRatings`
|
||||
// now, injected directly (no more `DatabaseService.getPlaceWithTags`
|
||||
// indirection) — spied directly on the real repository instance.
|
||||
@@ -1336,13 +1318,13 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const id = seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'place_edit');
|
||||
|
||||
await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id })).rejects.toThrow(AcceptError);
|
||||
// Nothing written, and the stay is still waiting rather than marked handled.
|
||||
expect(placesStub.create).not.toHaveBeenCalled();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', async () => {
|
||||
@@ -1351,12 +1333,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const id = seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'day_edit');
|
||||
|
||||
await expect(svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).rejects.toThrow(AcceptError);
|
||||
expect(placesStub.create).not.toHaveBeenCalled();
|
||||
expect(rowOf(id).state).toBe('new');
|
||||
expect((await rowOf(id)).state).toBe('new');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', async () => {
|
||||
@@ -1364,7 +1346,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const id = seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: member.id, tripId: trip.id });
|
||||
|
||||
await svc.accept(member.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
@@ -1374,21 +1356,19 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
it('DAWARICH-SUG-038: a place made from a recording says where it came from', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
|
||||
const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
const place = testDb
|
||||
.prepare('SELECT source FROM places WHERE id = ?')
|
||||
.get(result.createdPlaceId) as { source: string | null };
|
||||
expect(place.source).toBe('dawarich');
|
||||
const place = await findRow(t, Places, { id: result.createdPlaceId });
|
||||
expect(place?.source).toBe('dawarich');
|
||||
});
|
||||
|
||||
it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
assignmentsStub.createAssignment.mockReturnValue({ id: 4242, day_id: day.id });
|
||||
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9');
|
||||
@@ -1405,7 +1385,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
assignmentsStub.createAssignment.mockReturnValue(undefined);
|
||||
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id });
|
||||
@@ -1424,11 +1404,11 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
expect(rowOf(id).state).toBe('accepted');
|
||||
expect((await rowOf(id)).state).toBe('accepted');
|
||||
|
||||
testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
|
||||
await deleteRows(t, Places, { id: result.createdPlaceId });
|
||||
|
||||
const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!;
|
||||
expect(listed.state).toBe('new');
|
||||
@@ -1441,7 +1421,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: trip.id });
|
||||
await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
|
||||
|
||||
const listed = (await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!;
|
||||
@@ -1451,27 +1431,23 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
|
||||
it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = seedSuggestion({ userId: user.id, tripId: null });
|
||||
const journeyId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)",
|
||||
)
|
||||
.run(user.id).lastInsertRowid,
|
||||
);
|
||||
const entryId = Number(
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'entry', 'Cafe', '2026-09-01', 0, 0)",
|
||||
)
|
||||
.run(journeyId, user.id).lastInsertRowid,
|
||||
);
|
||||
const id = await seedSuggestion({ userId: user.id, tripId: null });
|
||||
const journeyId = await insertRow(t, Journeys, { user: user.id, title: 'Trip diary', status: 'active', created_at: 0, updated_at: 0 });
|
||||
const entryId = await insertRow(t, JourneyEntries, {
|
||||
journey: journeyId,
|
||||
author: user.id,
|
||||
type: 'entry',
|
||||
title: 'Cafe',
|
||||
entry_date: '2026-09-01',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
});
|
||||
journeyStub.createEntry.mockReturnValue({ id: entryId });
|
||||
|
||||
await svc.accept(user.id, id, { target: 'journal', journalId: journeyId });
|
||||
expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('accepted');
|
||||
|
||||
testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
await deleteRows(t, JourneyEntries, { id: entryId });
|
||||
|
||||
expect((await svc.list(user.id, {})).suggestions.find((s) => s.id === id)!.state).toBe('new');
|
||||
});
|
||||
@@ -1480,12 +1456,12 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
|
||||
const { user: mine } = createUser(testDb);
|
||||
const { user: theirs } = createUser(testDb);
|
||||
const trip = createTrip(testDb, theirs.id);
|
||||
const id = seedSuggestion({ userId: theirs.id, tripId: trip.id });
|
||||
const id = await seedSuggestion({ userId: theirs.id, tripId: trip.id });
|
||||
const result = await svc.accept(theirs.id, id, { target: 'place', tripId: trip.id });
|
||||
testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
|
||||
await deleteRows(t, Places, { id: result.createdPlaceId });
|
||||
|
||||
await svc.list(mine.id, {});
|
||||
|
||||
expect(rowOf(id).state).toBe('accepted');
|
||||
expect((await rowOf(id)).state).toBe('accepted');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,14 @@ import type {
|
||||
import type { User } from '../../../../src/types';
|
||||
import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows';
|
||||
import { addTripMember } from '../../../helpers/factories/trips';
|
||||
import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity';
|
||||
import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity';
|
||||
import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity';
|
||||
import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity';
|
||||
import { TripFiles } from '../../../../src/db/entities/TripFiles.entity';
|
||||
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
|
||||
import {
|
||||
createTestDocumentConnectionsRepo,
|
||||
createTestDocumentProviderFieldsRepo,
|
||||
@@ -126,23 +134,11 @@ const sync = {
|
||||
// real read against this file's own testDb, not a stub: "the document
|
||||
// list" describe block below is testing the join/trip-scoping SHAPE this
|
||||
// method now owns, and a canned return would only restate the assertion.
|
||||
itemsForTrip: vi.fn((tripId: number, state?: string) =>
|
||||
state
|
||||
? testDb
|
||||
.prepare(
|
||||
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
|
||||
LEFT JOIN trip_files f ON f.id = i.file_id
|
||||
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
|
||||
)
|
||||
.all(tripId, state)
|
||||
: testDb
|
||||
.prepare(
|
||||
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
|
||||
LEFT JOIN trip_files f ON f.id = i.file_id
|
||||
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
|
||||
)
|
||||
.all(tripId),
|
||||
),
|
||||
// It answers through the same two repository reads DocSyncService makes.
|
||||
itemsForTrip: vi.fn(async (tripId: number, state?: string) => {
|
||||
const items = await createTestDocumentSyncItemsRepo(testDb);
|
||||
return state ? items.listForTripByState(tripId, state) : items.listForTrip(tripId);
|
||||
}),
|
||||
};
|
||||
|
||||
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
|
||||
@@ -228,7 +224,7 @@ beforeAll(async () => {
|
||||
admin = { id: a.id, role: 'admin' } as User;
|
||||
tripId = createTrip(testDb, o.id, { title: 'Japan' }).id;
|
||||
otherTripId = createTrip(testDb, m.id, { title: 'Norway' }).id;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, m.id);
|
||||
await addTripMember(t, tripId, m.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -236,11 +232,14 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections; DELETE FROM trip_files');
|
||||
beforeEach(async () => {
|
||||
await deleteRows(t, DocumentSyncItems);
|
||||
await deleteRows(t, TripDocumentLinks);
|
||||
await deleteRows(t, DocumentConnections);
|
||||
await deleteRows(t, TripFiles);
|
||||
// Providers ship switched off; these three are what the cases below need.
|
||||
testDb.prepare("UPDATE document_providers SET enabled = 0").run();
|
||||
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud', 'papra')").run();
|
||||
await updateRows(t, DocumentProviders, {}, { enabled: 0 });
|
||||
await updateRows(t, DocumentProviders, { id: { $in: ['paperless', 'nextcloud', 'papra'] } }, { enabled: 1 });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -330,7 +329,7 @@ describe('storing a connection', () => {
|
||||
});
|
||||
|
||||
it('refuses a provider the instance admin has not switched on, and names it', async () => {
|
||||
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'papra'").run();
|
||||
await updateRows(t, DocumentProviders, { id: 'papra' }, { enabled: 0 });
|
||||
const err = await thrown(() =>
|
||||
controller.upsertConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k', organization_id: 'org_1' } })),
|
||||
);
|
||||
@@ -428,7 +427,7 @@ describe('a secret the adapter earned itself', () => {
|
||||
const EARNED = 'a1b2c3:DEVICE-SECRET';
|
||||
|
||||
it('is in neither connection payload the form is rendered from', async () => {
|
||||
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run();
|
||||
await updateRows(t, DocumentProviders, { id: 'synologydrive' }, { enabled: 1 });
|
||||
const nas = connBody({
|
||||
providerId: 'synologydrive',
|
||||
baseUrl: 'https://nas.example.com:5001',
|
||||
@@ -480,14 +479,11 @@ describe('reading the trip state', () => {
|
||||
describe('removing a connection', () => {
|
||||
it('unbinds without touching the documents that came through it', async () => {
|
||||
const conn = await storedPaperless();
|
||||
const fileId = Number(
|
||||
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
||||
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
||||
);
|
||||
const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' });
|
||||
|
||||
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
|
||||
expect(await config.getConnection(conn.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
|
||||
expect(await findRow(t, TripFiles, { id: fileId })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('takes down every subscription TREK registered for its bindings, before the credential goes', async () => {
|
||||
@@ -577,10 +573,12 @@ describe('the scope picker', () => {
|
||||
});
|
||||
|
||||
it('refuses a connection whose provider has no adapter on this instance', async () => {
|
||||
const id = Number(
|
||||
testDb.prepare('INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url) VALUES (?, ?, ?, ?)')
|
||||
.run(tripId, 'papra', Number(owner.id), 'https://papra.example.com').lastInsertRowid,
|
||||
);
|
||||
const id = await insertRow(t, DocumentConnections, {
|
||||
trip: tripId,
|
||||
provider: 'papra',
|
||||
ownerUser: Number(owner.id),
|
||||
base_url: 'https://papra.example.com',
|
||||
});
|
||||
const err = await thrown(() => controller.listScopes(String(tripId), String(id), owner));
|
||||
expect(err.getStatus()).toBe(400);
|
||||
expect(err.message).toBe('Provider not available');
|
||||
@@ -780,7 +778,7 @@ describe('a manual run', () => {
|
||||
if ('error' in res) throw new Error('fixture failed');
|
||||
const created = (await controller.createLink(String(tripId), owner, linkBody(res.data.id, { syncEnabled: false }), makeReq())) as { id: number };
|
||||
sync.syncLink.mockClear();
|
||||
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, member.id);
|
||||
await deleteRows(t, TripMembers, { trip: tripId, user: member.id });
|
||||
try {
|
||||
const err = await thrown(() => controller.syncNow(String(tripId), String(created.id), { full: false }));
|
||||
|
||||
@@ -788,7 +786,7 @@ describe('a manual run', () => {
|
||||
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id);
|
||||
await addTripMember(t, tripId, member.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -796,21 +794,19 @@ describe('a manual run', () => {
|
||||
describe('the document list', () => {
|
||||
async function seedItems() {
|
||||
const conn = await storedPaperless();
|
||||
const fileId = Number(
|
||||
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
||||
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
||||
);
|
||||
const linkId = Number(
|
||||
testDb.prepare(
|
||||
`INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(tripId, conn.id, 'paperless', 'tag:1').lastInsertRowid,
|
||||
);
|
||||
const insert = testDb.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)');
|
||||
insert.run(linkId, tripId, fileId, 'uid-1', 'synced');
|
||||
insert.run(linkId, tripId, null, 'uid-2', 'conflict');
|
||||
const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' });
|
||||
const linkId = await insertRow(t, TripDocumentLinks, {
|
||||
trip: tripId,
|
||||
connection: conn.id,
|
||||
provider_id: 'paperless',
|
||||
remote_scope_key: 'tag:1',
|
||||
});
|
||||
const insert = (trip: number, file: number | null, trekDocUid: string, state: string) =>
|
||||
insertRow(t, DocumentSyncItems, { link: linkId, trip, file, trek_doc_uid: trekDocUid, state });
|
||||
await insert(tripId, fileId, 'uid-1', 'synced');
|
||||
await insert(tripId, null, 'uid-2', 'conflict');
|
||||
// Another trip's row, which must never appear in this trip's list.
|
||||
insert.run(linkId, otherTripId, null, 'uid-3', 'conflict');
|
||||
await insert(otherTripId, null, 'uid-3', 'conflict');
|
||||
}
|
||||
|
||||
it('carries the local file name, so the list reads as documents rather than as ids', async () => {
|
||||
@@ -845,20 +841,15 @@ describe('removing a binding', () => {
|
||||
linkBody(conn.id),
|
||||
makeReq({ host: 'trek.example' }),
|
||||
)) as { id: number };
|
||||
const fileId = Number(
|
||||
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
|
||||
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
|
||||
);
|
||||
testDb
|
||||
.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(created.id, tripId, fileId, 'uid-1', 'synced');
|
||||
const fileId = await insertRow(t, TripFiles, { trip: tripId, filename: 'stored.pdf', original_name: 'boarding.pdf' });
|
||||
await insertRow(t, DocumentSyncItems, { link: created.id, trip: tripId, file: fileId, trek_doc_uid: 'uid-1', state: 'synced' });
|
||||
|
||||
const res = await controller.deleteLink(String(tripId), String(created.id), owner);
|
||||
|
||||
expect(res).toEqual({ success: true, documentsKept: true });
|
||||
expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7');
|
||||
expect(await config.getLink(created.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
|
||||
expect(await findRow(t, TripFiles, { id: fileId })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('pings the trip that the binding is gone', async () => {
|
||||
|
||||
@@ -37,6 +37,17 @@ import { createTestTodoItemsRepo } from '../../helpers/todo-repos';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
|
||||
import type { TodoItemsRepository } from '../../../src/db/repositories/TodoItems.repository';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
import { TodoItems } from '../../../src/db/entities/TodoItems.entity';
|
||||
|
||||
/** The stored reminded_at of a to-do; fails the case when the item is gone. */
|
||||
async function remindedAtOf(id: number): Promise<string | null | undefined> {
|
||||
const todo = await findRow(await sharedTestOrm(testDb), TodoItems, { id });
|
||||
if (!todo) throw new Error(`no to-do ${id}`);
|
||||
return todo.reminded_at;
|
||||
}
|
||||
|
||||
interface Registered {
|
||||
name: string;
|
||||
@@ -75,9 +86,22 @@ function makeJobs(overrides: { notifications?: NotificationsService } = {}) {
|
||||
}
|
||||
|
||||
/** A trip whose start_date sits exactly `days` ahead, with reminders on. */
|
||||
function tripWithReminder(userId: number, days: number, title = 'Lisbon'): number {
|
||||
/** The UTC calendar date `days` from today, as SQLite's `date('now', '+N days')` renders it. */
|
||||
const dayFromToday = (days: number): string => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
|
||||
const orm = () => sharedTestOrm(testDb);
|
||||
|
||||
async function setTrip(tripId: number, data: { reminder_days?: number; start_date?: string }): Promise<void> {
|
||||
await updateRows(await orm(), Trips, { id: tripId }, data);
|
||||
}
|
||||
|
||||
async function setTodo(todoId: number, data: { due_date?: string; assignedUser?: number }): Promise<void> {
|
||||
await updateRows(await orm(), TodoItems, { id: todoId }, data);
|
||||
}
|
||||
|
||||
async function tripWithReminder(userId: number, days: number, title = 'Lisbon'): Promise<number> {
|
||||
const trip = createTrip(testDb, userId, { title });
|
||||
testDb.prepare("UPDATE trips SET reminder_days = ?, start_date = date('now', '+' || ? || ' days') WHERE id = ?").run(days, days, trip.id);
|
||||
await setTrip(trip.id, { reminder_days: days, start_date: dayFromToday(days) });
|
||||
return trip.id;
|
||||
}
|
||||
|
||||
@@ -140,7 +164,7 @@ describe('ReminderJobsService bootstrap', () => {
|
||||
it('RJOB-004 — the trip banner carries the active channels and the reminder-trip count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'email');
|
||||
tripWithReminder(user.id, 5);
|
||||
await tripWithReminder(user.id, 5);
|
||||
const { svc } = makeJobs();
|
||||
await svc.onApplicationBootstrap();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Trip reminders: enabled via [email], 1 trip(s) with active reminders');
|
||||
@@ -150,11 +174,11 @@ describe('ReminderJobsService bootstrap', () => {
|
||||
describe('trip reminder tick', () => {
|
||||
it('RJOB-005 — sends for a trip starting exactly reminder_days out and logs the summary', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripId = tripWithReminder(user.id, 3);
|
||||
tripWithReminder(user.id, 5, 'Also due'); // its own window lines up too (start = now + reminder_days)
|
||||
const tripId = await tripWithReminder(user.id, 3);
|
||||
await tripWithReminder(user.id, 5, 'Also due'); // its own window lines up too (start = now + reminder_days)
|
||||
// A trip whose start date does NOT line up with its reminder window:
|
||||
const off = createTrip(testDb, user.id, { title: 'Off-window' });
|
||||
testDb.prepare("UPDATE trips SET reminder_days = 2, start_date = date('now', '+9 days') WHERE id = ?").run(off.id);
|
||||
await setTrip(off.id, { reminder_days: 2, start_date: dayFromToday(9) });
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.tripTick();
|
||||
@@ -170,10 +194,10 @@ describe('trip reminder tick', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Due yesterday (start in 2 days, reminder 3 days ahead): the job did not run then.
|
||||
const missed = createTrip(testDb, user.id, { title: 'Missed' });
|
||||
testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '+2 days') WHERE id = ?").run(missed.id);
|
||||
await setTrip(missed.id, { reminder_days: 3, start_date: dayFromToday(2) });
|
||||
// Already started: no reminder any more.
|
||||
const started = createTrip(testDb, user.id, { title: 'Started' });
|
||||
testDb.prepare("UPDATE trips SET reminder_days = 3, start_date = date('now', '-1 days') WHERE id = ?").run(started.id);
|
||||
await setTrip(started.id, { reminder_days: 3, start_date: dayFromToday(-1) });
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.tripTick();
|
||||
@@ -183,14 +207,14 @@ describe('trip reminder tick', () => {
|
||||
await svc.tripTick();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
|
||||
testDb.prepare("UPDATE trips SET start_date = date('now', '+1 days') WHERE id = ?").run(missed.id);
|
||||
await setTrip(missed.id, { start_date: dayFromToday(1) });
|
||||
await svc.tripTick();
|
||||
expect(send.mock.calls.map(([p]) => p.targetId)).toEqual([missed.id]);
|
||||
});
|
||||
|
||||
it('RJOB-006 — the per-tick gate skips everything when notify_trip_reminder is false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
tripWithReminder(user.id, 3);
|
||||
await tripWithReminder(user.id, 3);
|
||||
setAppSetting(testDb, 'notify_trip_reminder', 'false');
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.tripTick();
|
||||
@@ -216,43 +240,46 @@ describe('trip reminder tick', () => {
|
||||
});
|
||||
|
||||
describe('trip reminder claims', () => {
|
||||
const sentFor = (id: number) =>
|
||||
(testDb.prepare('SELECT reminder_sent_for FROM trips WHERE id = ?').get(id) as { reminder_sent_for: string | null }).reminder_sent_for;
|
||||
const sentFor = async (id: number) => {
|
||||
const trip = await findRow(await orm(), Trips, { id });
|
||||
if (!trip) throw new Error(`no trip ${id}`);
|
||||
return trip.reminder_sent_for;
|
||||
};
|
||||
|
||||
it('RJOB-013: a send that throws gives the claim back, logs it, and the next run sends again', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripId = tripWithReminder(user.id, 2);
|
||||
const tripId = await tripWithReminder(user.id, 2);
|
||||
const { svc, send } = makeJobs();
|
||||
send.mockRejectedValueOnce(new Error('db gone mid-send'));
|
||||
|
||||
await svc.tripTick();
|
||||
expect(sentFor(tripId)).toBeNull();
|
||||
expect(await sentFor(tripId)).toBeNull();
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('db gone mid-send'));
|
||||
expect(logMock.logWarn).toHaveBeenCalledWith('Trip reminder for "Lisbon" was not delivered; the next run tries again');
|
||||
expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringMatching(/^Trip reminders sent/));
|
||||
|
||||
await svc.tripTick();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(sentFor(tripId)).not.toBeNull();
|
||||
expect(await sentFor(tripId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('RJOB-014: every channel failing is retried, a send nobody wanted is not', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const failing = tripWithReminder(user.id, 2, 'Failing');
|
||||
const unwanted = tripWithReminder(user.id, 3, 'Unwanted');
|
||||
const failing = await tripWithReminder(user.id, 2, 'Failing');
|
||||
const unwanted = await tripWithReminder(user.id, 3, 'Unwanted');
|
||||
const { svc, send } = makeJobs();
|
||||
send.mockImplementation(async (payload: { targetId: number }) =>
|
||||
payload.targetId === failing ? { attempted: 2, delivered: 0 } : { attempted: 0, delivered: 0 },
|
||||
);
|
||||
|
||||
await svc.tripTick();
|
||||
expect(sentFor(failing)).toBeNull();
|
||||
expect(sentFor(unwanted)).not.toBeNull();
|
||||
expect(await sentFor(failing)).toBeNull();
|
||||
expect(await sentFor(unwanted)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('RJOB-015: a reminder another process claimed first is not sent again', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tripId = tripWithReminder(user.id, 2);
|
||||
const tripId = await tripWithReminder(user.id, 2);
|
||||
// Both processes read the candidates; the other one claims first.
|
||||
const stale = await tripsRepo.listReminderCandidates();
|
||||
const start = stale.find((t) => t.id === tripId)!.start_date;
|
||||
@@ -271,7 +298,7 @@ describe('todo reminder tick', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now', '+1 day') WHERE id = ?").run(todo.id);
|
||||
await setTodo(todo.id, { due_date: dayFromToday(1) });
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.todoTick();
|
||||
@@ -279,8 +306,7 @@ describe('todo reminder tick', () => {
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(expect.objectContaining({ event: 'todo_due', scope: 'trip', targetId: trip.id, params: expect.objectContaining({ todo: 'Pack bags', trip: 'Lisbon' }) }));
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith('Todo reminders sent for 1 item(s)');
|
||||
const { reminded_at } = testDb.prepare('SELECT reminded_at FROM todo_items WHERE id = ?').get(todo.id) as { reminded_at: string | null };
|
||||
expect(reminded_at).not.toBeNull();
|
||||
expect(await remindedAtOf(todo.id)).not.toBeNull();
|
||||
|
||||
// The 20h dedup keeps a second same-day run silent.
|
||||
send.mockClear();
|
||||
@@ -293,7 +319,7 @@ describe('todo reminder tick', () => {
|
||||
const { user: assignee } = createUser(testDb, { email: 'b@example.test', username: 'assignee' });
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const todo = createTodoItem(testDb, trip.id, { name: 'Book train' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now'), assigned_user_id = ? WHERE id = ?").run(assignee.id, todo.id);
|
||||
await setTodo(todo.id, { due_date: dayFromToday(0), assignedUser: assignee.id });
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.todoTick();
|
||||
@@ -304,11 +330,11 @@ describe('todo reminder tick', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const checked = createTodoItem(testDb, trip.id, { name: 'Done', checked: 1 });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now') WHERE id = ?").run(checked.id);
|
||||
await setTodo(checked.id, { due_date: dayFromToday(0) });
|
||||
const far = createTodoItem(testDb, trip.id, { name: 'Far' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now', '+10 days') WHERE id = ?").run(far.id);
|
||||
await setTodo(far.id, { due_date: dayFromToday(10) });
|
||||
const past = createTodoItem(testDb, trip.id, { name: 'Past' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now', '-1 day') WHERE id = ?").run(past.id);
|
||||
await setTodo(past.id, { due_date: dayFromToday(-1) });
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
await svc.todoTick();
|
||||
@@ -320,7 +346,7 @@ describe('todo reminder tick', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now') WHERE id = ?").run(todo.id);
|
||||
await setTodo(todo.id, { due_date: dayFromToday(0) });
|
||||
setAppSetting(testDb, 'notify_todo_due', 'false');
|
||||
|
||||
const { svc, send } = makeJobs();
|
||||
@@ -330,33 +356,32 @@ describe('todo reminder tick', () => {
|
||||
});
|
||||
|
||||
describe('todo reminder claims', () => {
|
||||
function dueTodo(): number {
|
||||
async function dueTodo(): Promise<number> {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Lisbon' });
|
||||
const todo = createTodoItem(testDb, trip.id, { name: 'Pack bags' });
|
||||
testDb.prepare("UPDATE todo_items SET due_date = date('now', '+1 day') WHERE id = ?").run(todo.id);
|
||||
await setTodo(todo.id, { due_date: dayFromToday(1) });
|
||||
return todo.id;
|
||||
}
|
||||
const remindedAt = (id: number) =>
|
||||
(testDb.prepare('SELECT reminded_at FROM todo_items WHERE id = ?').get(id) as { reminded_at: string | null }).reminded_at;
|
||||
const remindedAt = remindedAtOf;
|
||||
|
||||
it('RJOB-016: a failed send gives the claim back, so the next run sends again', async () => {
|
||||
const todoId = dueTodo();
|
||||
const todoId = await dueTodo();
|
||||
const { svc, send } = makeJobs();
|
||||
send.mockResolvedValueOnce({ attempted: 1, delivered: 0 });
|
||||
|
||||
await svc.todoTick();
|
||||
expect(remindedAt(todoId)).toBeNull();
|
||||
expect(await remindedAt(todoId)).toBeNull();
|
||||
expect(logMock.logWarn).toHaveBeenCalledWith('Todo reminder for "Pack bags" was not delivered; the next run tries again');
|
||||
expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringMatching(/^Todo reminders sent/));
|
||||
|
||||
await svc.todoTick();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(remindedAt(todoId)).not.toBeNull();
|
||||
expect(await remindedAt(todoId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('RJOB-017: a todo another process claimed first is not sent again', async () => {
|
||||
const todoId = dueTodo();
|
||||
const todoId = await dueTodo();
|
||||
const stale = await todoItemsRepo.listDueForReminder(new Date().toISOString().slice(0, 10), '2999-12-31');
|
||||
await expect(todoItemsRepo.claimReminder(todoId)).resolves.toBe(true);
|
||||
vi.spyOn(todoItemsRepo, 'listDueForReminder').mockResolvedValueOnce(stale);
|
||||
|
||||
@@ -23,20 +23,8 @@ const { broadcast, notifySend } = vi.hoisted(() => ({
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||||
const db = createSnapshotTestDb();
|
||||
return {
|
||||
db,
|
||||
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),
|
||||
};
|
||||
// Trip access reads through TripsRepository now; the module only hands out the handle.
|
||||
return { db, closeDb: () => {}, reinitialize: () => {} };
|
||||
});
|
||||
|
||||
import { db as testDb } from '../../../src/db/database';
|
||||
@@ -62,6 +50,23 @@ import type { User } from '../../../src/types';
|
||||
import { notificationsStub } from '../../helpers/notifications';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||||
import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { readUser } from '../../helpers/factories/users';
|
||||
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
|
||||
import { Trips } from '../../../src/db/entities/Trips.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
const orm = () => sharedTestOrm(testDb);
|
||||
|
||||
/** The trip_members row for the pair, or null. */
|
||||
async function memberRow(tripId: number, userId: number) {
|
||||
return findRow(await orm(), TripMembers, { trip: tripId, user: userId });
|
||||
}
|
||||
|
||||
/** The trip's owner as stored. */
|
||||
async function ownerOf(tripId: number): Promise<number | undefined> {
|
||||
return (await findRow(await orm(), Trips, { id: tripId }))?.user_id;
|
||||
}
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
|
||||
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
|
||||
@@ -221,7 +226,7 @@ describe('addMember fallbacks', () => {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(result.tripTitle).toBe('Untitled');
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeDefined();
|
||||
expect(await memberRow(trip.id, invitee.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', async () => {
|
||||
@@ -257,7 +262,7 @@ describe('transferOwnership guard rails', () => {
|
||||
// its owner either way.
|
||||
await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow(NotFoundError);
|
||||
await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow('User not found');
|
||||
expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(owner.id);
|
||||
expect(await ownerOf(trip.id)).toBe(owner.id);
|
||||
});
|
||||
|
||||
// R8: the same rewrite as MEMBERS-SVC-007 above, on `UsersRepository.getEmail`
|
||||
@@ -281,7 +286,7 @@ describe('transferOwnership guard rails', () => {
|
||||
}
|
||||
expect(result.fromEmail).toBe('');
|
||||
expect(result.toEmail).toBe(member.email);
|
||||
expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(member.id);
|
||||
expect(await ownerOf(trip.id)).toBe(member.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,7 +303,7 @@ describe('guest name validation', () => {
|
||||
|
||||
// The guards run ahead of the transaction, so a rejected name can never leave
|
||||
// a credential-less users row behind with no trip to belong to.
|
||||
expect((testDb.prepare('SELECT COUNT(*) AS n FROM users WHERE is_guest = 1').get() as { n: number }).n).toBe(0);
|
||||
expect(await countRows(await orm(), Users, { is_guest: 1 })).toBe(0);
|
||||
|
||||
// 50 is the accepted boundary the DTO shares — off by one here and the API
|
||||
// starts refusing names the client believes are valid.
|
||||
@@ -317,11 +322,11 @@ describe('guest name validation', () => {
|
||||
// Order matters for the status code: an unusable name throws (400) even for an
|
||||
// id that is not a guest of this trip, where the scope check returns false (404).
|
||||
await expect(roster.renameGuest(trip.id, owner.id, '')).rejects.toThrow('Guest name is required');
|
||||
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida');
|
||||
expect((await readUser(await orm(), guest.id)).display_name).toBe('Ida');
|
||||
|
||||
// A padded name is stored trimmed, so the roster does not render the spaces.
|
||||
expect(await roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true);
|
||||
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida M.');
|
||||
expect((await readUser(await orm(), guest.id)).display_name).toBe('Ida M.');
|
||||
});
|
||||
|
||||
it("MEMBERS-SVC-014: deleteGuest is trip-scoped — another trip's owner cannot erase this trip's guest", async () => {
|
||||
@@ -335,7 +340,7 @@ describe('guest name validation', () => {
|
||||
// only thing between it and a foreign guest's users row — and the delete
|
||||
// cascades every assignment that guest is on.
|
||||
expect(await roster.deleteGuest(otherTrip.id, guest.id)).toBe(false);
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined();
|
||||
expect(await findRow(await orm(), Users, { id: guest.id })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,11 +353,11 @@ describe('listMembers shaping', () => {
|
||||
const { user: sso } = createUser(testDb);
|
||||
const { user: bare } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
testDb.prepare('UPDATE users SET avatar = ?, display_name = ? WHERE id = ?').run('me.png', 'Owner Displayed', owner.id);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('a.png', uploaded.id);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp.example.test/p.jpg', sso.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, uploaded.id, owner.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, sso.id, owner.id);
|
||||
await updateRows(await orm(), Users, { id: owner.id }, { avatar: 'me.png', display_name: 'Owner Displayed' });
|
||||
await updateRows(await orm(), Users, { id: uploaded.id }, { avatar: 'a.png' });
|
||||
await updateRows(await orm(), Users, { id: sso.id }, { avatar: 'https://idp.example.test/p.jpg' });
|
||||
await insertRow(await orm(), TripMembers, { trip: trip.id, user: uploaded.id, invitedByRef: owner.id });
|
||||
await insertRow(await orm(), TripMembers, { trip: trip.id, user: sso.id, invitedByRef: owner.id });
|
||||
addTripMember(testDb, trip.id, bare.id);
|
||||
|
||||
const { owner: ownerRow, members } = await roster.listMembers(trip.id, owner.id);
|
||||
@@ -407,15 +412,14 @@ describe('Task 6 review items — rollback and concurrency', () => {
|
||||
// setOwner (TM13) ran, then remove (TM14) ran, then addIgnoringConflict (TM15)
|
||||
// rejected — a fix that moved TM15 outside the transaction would leave TM13/TM14
|
||||
// committed while this assertion still expects them rolled back.
|
||||
const tripRow = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
|
||||
expect(tripRow.user_id).toBe(owner.id);
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id)).toBeDefined();
|
||||
expect(await ownerOf(trip.id)).toBe(owner.id);
|
||||
expect(await memberRow(trip.id, member.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('MEMBERS-SVC-017 (mutation-proved): createGuest rolls back when the membership INSERT rejects — no orphan guest user row', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n;
|
||||
const before = await countRows(await orm(), Users);
|
||||
|
||||
const spy = vi.spyOn(tripMembersRepo, 'addMember').mockRejectedValueOnce(new Error('boom'));
|
||||
try {
|
||||
@@ -426,7 +430,7 @@ describe('Task 6 review items — rollback and concurrency', () => {
|
||||
|
||||
// insertGuest (TM16) ran, then addMember (TM17) rejected — a fix that moved
|
||||
// TM17 outside the transaction would leave the guest's `users` row behind.
|
||||
const after = (testDb.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }).n;
|
||||
const after = await countRows(await orm(), Users);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
@@ -490,7 +494,6 @@ describe('Task 6 review items — rollback and concurrency', () => {
|
||||
// Without this the test stayed green against a mutation that serializes
|
||||
// `addMember` (forcing the TM5-catch branch instead) — not load-bearing.
|
||||
expect((rejected[0] as PromiseRejectedResult).reason.message).toMatch(/UNIQUE constraint failed: trip_members/);
|
||||
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id) as { n: number };
|
||||
expect(count.n).toBe(1);
|
||||
expect(await countRows(await orm(), TripMembers, { trip: trip.id, user: invitee.id })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,19 @@ import path from 'node:path';
|
||||
import { UserProfileService } from '../../../src/nest/auth/user-profile.service';
|
||||
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { updateRows } from '../../helpers/factories/rows';
|
||||
import { readUser } from '../../helpers/factories/users';
|
||||
import { readAppSetting, setAppSetting } from '../../helpers/factories/settings';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
|
||||
/** Writes columns of the user row directly, the state a case starts from. */
|
||||
async function setUserColumns(
|
||||
userId: number,
|
||||
data: { maps_api_key?: string | null; openweather_api_key?: string | null; avatar?: string | null },
|
||||
): Promise<void> {
|
||||
await updateRows(await sharedTestOrm(testDb), Users, { id: userId }, data);
|
||||
}
|
||||
import { SEARCH_TEXT_FIELD_MASK } from '../../../src/nest/maps/maps.helpers';
|
||||
|
||||
const avatarsFx = makeStorageFixture('avatars/');
|
||||
@@ -141,9 +153,7 @@ describe('getSettings', () => {
|
||||
|
||||
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
|
||||
.run('maps-key-value', 'weather-key-value', user.id);
|
||||
await setUserColumns(user.id, { maps_api_key: 'maps-key-value', openweather_api_key: 'weather-key-value' });
|
||||
const result = await profile.getSettings(user.id);
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.settings).toBeDefined();
|
||||
@@ -233,7 +243,7 @@ describe('validateKeys', () => {
|
||||
|
||||
it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
||||
await setUserColumns(user.id, { maps_api_key: 'test-key' });
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
@@ -250,7 +260,7 @@ describe('validateKeys', () => {
|
||||
|
||||
it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
||||
await setUserColumns(user.id, { maps_api_key: 'test-key' });
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(global, 'fetch')
|
||||
@@ -266,7 +276,7 @@ describe('validateKeys', () => {
|
||||
|
||||
it('AUTH-DB-098: sends Referer from APP_URL so referrer-restricted keys validate like real requests', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
||||
await setUserColumns(user.id, { maps_api_key: 'test-key' });
|
||||
|
||||
const prevAppUrl = process.env.APP_URL;
|
||||
process.env.APP_URL = 'https://trek.example.com';
|
||||
@@ -315,8 +325,8 @@ describe('validateKeys', () => {
|
||||
// What a second admin sees after the instance key was saved by the first
|
||||
// one: their column still holds whatever they pasted long ago, while every
|
||||
// search on the install runs on the app_settings row.
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
|
||||
await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' });
|
||||
await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key');
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
@@ -351,9 +361,9 @@ describe('updateMapsKey / avatar', () => {
|
||||
|
||||
it('AUTH-DB-070: deleteAvatar nulls the column; an OIDC https avatar skips the file rm', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('https://idp/pic.jpg', user.id);
|
||||
await setUserColumns(user.id, { avatar: 'https://idp/pic.jpg' });
|
||||
expect(await profile.deleteAvatar(user.id)).toEqual({ success: true });
|
||||
const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as { avatar: string | null };
|
||||
const row = await readUser(await sharedTestOrm(testDb), user.id);
|
||||
expect(row.avatar).toBeNull();
|
||||
});
|
||||
|
||||
@@ -368,7 +378,7 @@ describe('updateMapsKey / avatar', () => {
|
||||
it('AUTH-DB-069b: saveAvatar reclaims the previous uploaded avatar file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const old = writeAvatar('old.png');
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('old.png', user.id);
|
||||
await setUserColumns(user.id, { avatar: 'old.png' });
|
||||
|
||||
await profile.saveAvatar(user.id, 'new.png');
|
||||
expect(fs.existsSync(old)).toBe(false);
|
||||
@@ -377,7 +387,7 @@ describe('updateMapsKey / avatar', () => {
|
||||
it('AUTH-DB-070b: deleteAvatar removes the uploaded avatar file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const fp = writeAvatar('mine.png');
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('mine.png', user.id);
|
||||
await setUserColumns(user.id, { avatar: 'mine.png' });
|
||||
|
||||
expect(await profile.deleteAvatar(user.id)).toEqual({ success: true });
|
||||
expect(fs.existsSync(fp)).toBe(false);
|
||||
@@ -385,7 +395,7 @@ describe('updateMapsKey / avatar', () => {
|
||||
|
||||
it('AUTH-DB-070c: a hostile stored avatar value is swallowed, never thrown', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('../../etc/passwd', user.id);
|
||||
await setUserColumns(user.id, { avatar: '../../etc/passwd' });
|
||||
// Central key validation rejects the value; the delete swallows it exactly
|
||||
// like the old rm().catch did — the DB update still wins.
|
||||
expect(await profile.deleteAvatar(user.id)).toEqual({ success: true });
|
||||
@@ -408,17 +418,16 @@ describe('profile quirk fixes', () => {
|
||||
// tests/integration/security.test.ts, which runs with a real ENCRYPTION_KEY.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const instanceRow = (key: string) =>
|
||||
(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const instanceRow = async (key: string) => (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined;
|
||||
|
||||
describe('instance-wide API keys', () => {
|
||||
it('AUTH-DB-102: an admin save lands in app_settings AND in their own column', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
await profile.updateApiKeys(user.id, { maps_api_key: 'instance-google-key', unsplash_api_key: 'instance-unsplash-key' });
|
||||
expect(instanceRow('maps_api_key')).toBe('instance-google-key');
|
||||
expect(instanceRow('unsplash_api_key')).toBe('instance-unsplash-key');
|
||||
expect(await instanceRow('maps_api_key')).toBe('instance-google-key');
|
||||
expect(await instanceRow('unsplash_api_key')).toBe('instance-unsplash-key');
|
||||
// The column stays in step so clearing the field clears both.
|
||||
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
|
||||
const row = await readUser(await sharedTestOrm(testDb), user.id);
|
||||
expect(row.maps_api_key).toBe('instance-google-key');
|
||||
});
|
||||
|
||||
@@ -427,8 +436,8 @@ describe('instance-wide API keys', () => {
|
||||
await profile.updateApiKeys(admin.id, { maps_api_key: 'admin-set' });
|
||||
const { user } = createUser(testDb);
|
||||
await profile.updateApiKeys(user.id, { maps_api_key: 'members-own' });
|
||||
expect(instanceRow('maps_api_key')).toBe('admin-set');
|
||||
const row = testDb.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(user.id) as { maps_api_key: string };
|
||||
expect(await instanceRow('maps_api_key')).toBe('admin-set');
|
||||
const row = await readUser(await sharedTestOrm(testDb), user.id);
|
||||
expect(row.maps_api_key).toBe('members-own');
|
||||
});
|
||||
|
||||
@@ -438,13 +447,13 @@ describe('instance-wide API keys', () => {
|
||||
await profile.updateApiKeys(user.id, { maps_api_key: '' });
|
||||
// '' and not a missing row: a missing row would fall through to whatever
|
||||
// still sat in the admin's own column.
|
||||
expect(instanceRow('maps_api_key')).toBe('');
|
||||
expect(await instanceRow('maps_api_key')).toBe('');
|
||||
});
|
||||
|
||||
it('AUTH-DB-105: getSettings reads the instance value, not the admin own column', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('stale-personal-key', user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'live-instance-key')").run();
|
||||
await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' });
|
||||
await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key');
|
||||
expect((await profile.getSettings(user.id)).settings?.maps_api_key).toBe('live-instance-key');
|
||||
// openweather is per-user and unaffected.
|
||||
expect((await profile.getSettings(user.id)).settings?.openweather_api_key).toBeNull();
|
||||
@@ -453,10 +462,10 @@ describe('instance-wide API keys', () => {
|
||||
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
expect((await profile.updateMapsKey(admin.id, 'via-maps-key-route')).changedKeys).toEqual(['maps_api_key']);
|
||||
expect(instanceRow('maps_api_key')).toBe('via-maps-key-route');
|
||||
expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route');
|
||||
const { user } = createUser(testDb);
|
||||
await profile.updateMapsKey(user.id, 'members-own');
|
||||
expect(instanceRow('maps_api_key')).toBe('via-maps-key-route');
|
||||
expect(await instanceRow('maps_api_key')).toBe('via-maps-key-route');
|
||||
});
|
||||
|
||||
it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => {
|
||||
@@ -464,7 +473,7 @@ describe('instance-wide API keys', () => {
|
||||
const result = await profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.username).toBe('renamed');
|
||||
expect(instanceRow('maps_api_key')).toBe('from-settings-route');
|
||||
expect(await instanceRow('maps_api_key')).toBe('from-settings-route');
|
||||
expect(result.changedKeys).toEqual(['maps_api_key']);
|
||||
});
|
||||
});
|
||||
@@ -497,7 +506,7 @@ describe('changedKeys', () => {
|
||||
const result = await profile.updateApiKeys(user.id, { maps_api_key: 'operator-owns-this' });
|
||||
expect(result.managed_keys).toEqual(['maps_api_key']);
|
||||
expect(result.changedKeys).toEqual([]);
|
||||
expect(instanceRow('maps_api_key')).toBeUndefined();
|
||||
expect(await instanceRow('maps_api_key')).toBeUndefined();
|
||||
expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]);
|
||||
expect((await profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' })).changedKeys).toEqual([]);
|
||||
} finally {
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Days } from '../../../src/db/entities/Days.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
|
||||
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
@@ -36,11 +38,16 @@ afterAll(async () => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function seed() {
|
||||
async function seed(): Promise<void> {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
testDb
|
||||
.prepare("INSERT INTO users (id, username, email, password_hash, display_name, avatar) VALUES (?, ?, ?, 'x', ?, ?)")
|
||||
.run(i, 'u' + i, `u${i}@x`, i % 2 ? 'D' + i : null, i === 3 ? 'a.png' : null);
|
||||
await insertRow(t, Users, {
|
||||
id: i,
|
||||
username: 'u' + i,
|
||||
email: `u${i}@x`,
|
||||
password_hash: 'x',
|
||||
display_name: i % 2 ? 'D' + i : null,
|
||||
avatar: i === 3 ? 'a.png' : null,
|
||||
});
|
||||
}
|
||||
const trips: Array<[number, number]> = [
|
||||
[1, 1],
|
||||
@@ -48,7 +55,7 @@ function seed() {
|
||||
[3, 1],
|
||||
[4, 5],
|
||||
];
|
||||
for (const [id, owner] of trips) testDb.prepare('INSERT INTO trips (id, user_id, title) VALUES (?, ?, ?)').run(id, owner, 't');
|
||||
for (const [id, owner] of trips) await insertRow(t, Trips, { id, user: owner, title: 't' });
|
||||
for (const [trip, user] of [
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
@@ -56,15 +63,23 @@ function seed() {
|
||||
[3, 4],
|
||||
[4, 1],
|
||||
]) {
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip, user);
|
||||
await insertRow(t, TripMembers, { trip, user });
|
||||
}
|
||||
for (let d = 1; d <= 5; d++) testDb.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (?, ?, ?)').run(10 + d, d % 2 ? 1 : 2, d);
|
||||
for (let d = 1; d <= 5; d++) await insertRow(t, Days, { id: 10 + d, trip: d % 2 ? 1 : 2, day_number: d });
|
||||
}
|
||||
|
||||
/** The stored metadata row for the plugin and key; fails the case when it is gone. */
|
||||
async function metadataRow(pluginId: string, key: string) {
|
||||
const row = await findRow(t, PluginEntityMetadata, { plugin_id: pluginId, key });
|
||||
if (!row) throw new Error(`no metadata ${pluginId}/${key}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
it('HR9 Trips.sharesTripWith == legacy bilateral SQL for every ordered user pair (incl. missing users)', async () => {
|
||||
seed();
|
||||
await seed();
|
||||
const repo = t.repo(Trips);
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = testDb.prepare(`SELECT 1 FROM trips t
|
||||
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
|
||||
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
|
||||
@@ -83,18 +98,20 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
});
|
||||
|
||||
it('Trips.existsById == legacy row-exists SQL, including a miss', async () => {
|
||||
seed();
|
||||
await seed();
|
||||
const repo = t.repo(Trips);
|
||||
for (const id of [1, 2, 3, 4, 99]) {
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = !!testDb.prepare('SELECT 1 FROM trips WHERE id = ?').get(id);
|
||||
expect(await repo.existsById(id)).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('HR1 Users.findPublicIdentity == legacy row (full key, key order) and miss', async () => {
|
||||
seed();
|
||||
await seed();
|
||||
const repo = t.repo(Users);
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = testDb.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(i);
|
||||
const converted = (await repo.findPublicIdentity(i)) ?? undefined;
|
||||
// JSON.stringify: same keys, same order, same values — catches M15 (key order)
|
||||
@@ -110,32 +127,30 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
});
|
||||
|
||||
it('CT1/CT2 Days.listIdsByTrip + MR9 Days.findTripId == legacy', async () => {
|
||||
seed();
|
||||
await seed();
|
||||
const repo = t.repo(Days);
|
||||
for (const trip of [1, 2, 3, 99]) {
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = (testDb.prepare('SELECT id FROM days WHERE trip_id = ?').all(trip) as Array<{ id: number }>).map((r) => r.id);
|
||||
expect(await repo.listIdsByTrip(trip)).toEqual(legacy);
|
||||
}
|
||||
for (const id of [11, 12, 99]) {
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
const legacy = (testDb.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
|
||||
expect(await repo.findTripId(id)).toEqual(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('MR1-MR6 PluginEntityMetadata repository == legacy statements', async () => {
|
||||
seed();
|
||||
await seed();
|
||||
const repo = t.repo(PluginEntityMetadata);
|
||||
await repo.upsertValue('p', 'trip', 1, 'b', '"1"');
|
||||
await repo.upsertValue('p', 'trip', 1, 'a', '"2"');
|
||||
await repo.upsertValue('q', 'trip', 1, 'a', '"x"');
|
||||
const before = testDb.prepare("SELECT updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as { updated_at: string };
|
||||
testDb.prepare("UPDATE plugin_entity_metadata SET updated_at='2000-01-01 00:00:00' WHERE plugin_id='p' AND key='b'").run();
|
||||
const before = await metadataRow('p', 'b');
|
||||
await updateRows(t, PluginEntityMetadata, { plugin_id: 'p', key: 'b' }, { updated_at: '2000-01-01 00:00:00' });
|
||||
await repo.upsertValue('p', 'trip', 1, 'b', '"3"');
|
||||
const after = testDb.prepare("SELECT id, value, updated_at FROM plugin_entity_metadata WHERE plugin_id='p' AND key='b'").get() as {
|
||||
id: number;
|
||||
value: string;
|
||||
updated_at: string;
|
||||
};
|
||||
const after = await metadataRow('p', 'b');
|
||||
expect(after.value).toBe('"3"'); // MR4: upsert replaces the value
|
||||
expect(after.updated_at).not.toBe('2000-01-01 00:00:00'); // MR4: updated_at refreshed on conflict
|
||||
expect(after.updated_at).toMatch(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/);
|
||||
@@ -144,19 +159,29 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
expect(await repo.findValue('p', 'trip', 1, 'zz')).toBeNull(); // MR1 miss
|
||||
expect(await repo.countForEntity('p', 'trip', 1)).toBe(2); // MR3
|
||||
expect(await repo.listForEntity('p', 'trip', 1)).toEqual(
|
||||
// test-sql-allow: the legacy statement is the oracle the repository read is held to.
|
||||
testDb.prepare("SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key").all('p', 'trip', 1),
|
||||
); // MR5, ORDER BY key
|
||||
expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(true); // MR6
|
||||
expect(await repo.deleteValue('p', 'trip', 1, 'a')).toBe(false); // MR6, already gone
|
||||
expect(testDb.prepare('SELECT COUNT(*) c FROM plugin_entity_metadata').get()).toEqual({ c: 2 }); // 'p'/'b' + 'q'/'a' survive
|
||||
expect(await countRows(t, PluginEntityMetadata)).toBe(2); // 'p'/'b' + 'q'/'a' survive
|
||||
});
|
||||
|
||||
it('HR5-HR8 PluginScheduledTasks repository == legacy statements (upsert keeps id, replaces fields)', async () => {
|
||||
const repo = t.repo(PluginScheduledTasks);
|
||||
await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 5, payload: '1', every_ms: null });
|
||||
const id1 = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='n'").get() as { id: number }).id;
|
||||
const id1 = (await findRow(t, PluginScheduledTasks, { name: 'n' }))!.id;
|
||||
await repo.upsertTask({ plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 });
|
||||
expect(testDb.prepare('SELECT id, plugin_id, name, due_at, payload, every_ms FROM plugin_scheduled_tasks').all()).toEqual([
|
||||
expect(
|
||||
(await findRows(t, PluginScheduledTasks)).map(({ id, plugin_id, name, due_at, payload, every_ms }) => ({
|
||||
id,
|
||||
plugin_id,
|
||||
name,
|
||||
due_at,
|
||||
payload,
|
||||
every_ms,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: id1, plugin_id: 'p', name: 'n', due_at: 9, payload: '2', every_ms: 60000 },
|
||||
]); // HR7: same id, replaced fields — an upsert, not a delete+insert
|
||||
expect(await repo.existsForPluginAndName('p', 'n')).toBeTruthy(); // HR5
|
||||
@@ -170,14 +195,14 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
const repo = t.repo(PluginScheduledTasks);
|
||||
const now = Date.now();
|
||||
await repo.upsertTask({ plugin_id: 'p', name: 'claim', due_at: now - 1000, payload: '1', every_ms: 60000 });
|
||||
const id = (testDb.prepare("SELECT id FROM plugin_scheduled_tasks WHERE name='claim'").get() as { id: number }).id;
|
||||
const id = (await findRow(t, PluginScheduledTasks, { name: 'claim' }))!.id;
|
||||
// The winning claim: due_at <= claimedFromDueAt (the row's own current due_at).
|
||||
expect(await repo.rearm(id, now + 60000, now - 1000)).toBe(true);
|
||||
// A second, losing claim against the SAME stale claimedFromDueAt now fails — the
|
||||
// row's due_at already moved forward past it (RACE-SCHED-001/002's guard).
|
||||
expect(await repo.rearm(id, now + 120000, now - 1000)).toBe(false);
|
||||
const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE id = ?').get(id) as { due_at: number };
|
||||
expect(row.due_at).toBe(now + 60000); // the losing rearm never wrote
|
||||
const row = await findRow(t, PluginScheduledTasks, { id });
|
||||
expect(row?.due_at).toBe(now + 60000); // the losing rearm never wrote
|
||||
// deleteById carries the identical guard shape for the one-shot branch.
|
||||
expect(await repo.deleteById(id, now - 1000)).toBe(false); // stale claim, no-op
|
||||
expect(await repo.deleteById(id, now + 60000)).toBe(true); // current due_at, claims + deletes
|
||||
@@ -196,7 +221,7 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
);
|
||||
expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted
|
||||
expect(await repo.countForEntity('capbomb', 'trip', 1)).toBe(100); // never more than 100 rows
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id='capbomb'").get()).toEqual({ c: 100 });
|
||||
expect(await countRows(t, PluginEntityMetadata, { plugin_id: 'capbomb' })).toBe(100);
|
||||
});
|
||||
|
||||
it('must-land 3: 130 concurrent PluginScheduledTasks.upsertTaskCapped calls for the SAME plugin, 130 DIFFERENT new names, never exceed the 100 cap', async () => {
|
||||
@@ -207,6 +232,6 @@ describe('R3J-PARITY (Plan 3j Task 7 fix wave, must-land 4c)', () => {
|
||||
);
|
||||
expect(results.filter(Boolean).length).toBe(100); // exactly 100 writes accepted
|
||||
expect(await repo.countForPlugin('schedbomb')).toBe(100); // never more than 100 rows
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id='schedbomb'").get()).toEqual({ c: 100 });
|
||||
expect(await countRows(t, PluginScheduledTasks, { plugin_id: 'schedbomb' })).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user