test(server): seed the unified memories and admin service specs through the factories

Converts the raw prepare() fixtures and reads in the unified memories
service spec and the admin service spec to the ORM factories and readers.

The memories spec drops the raw canAccessTrip and isOwner on its database
mock, which nothing reads any more; its two parity cases keep the legacy
joins they are held to behind allow markers and build the provider list
they bind through the reader. The admin spec's password reset helpers
(password version, token and push device counts, the stored hash) now read
through the ORM, and the audit log parity case keeps its legacy statement
as an allowed oracle.
This commit is contained in:
Maurice
2026-10-08 16:59:01 +02:00
parent 345d459e5a
commit fecaa82ad2
2 changed files with 113 additions and 122 deletions
+62 -74
View File
@@ -110,6 +110,12 @@ import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.en
import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import type { TestOrm } from '../../helpers/test-orm';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { readAppSetting } from '../../helpers/factories/settings';
import { readUser } from '../../helpers/factories/users';
import { McpTokens } from '../../../src/db/entities/McpTokens.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
@@ -155,6 +161,7 @@ beforeAll(async () => {
await createTestSessionsService(testDb),
);
const t = await sharedTestOrm(testDb);
orm = t;
mcpTokensRepo = await createTestMcpTokensRepo(testDb);
auditLogRepo = t.repo(AuditLog);
svc = new AdminService(
@@ -209,6 +216,10 @@ afterAll(() => {
testDb.close();
});
let orm: TestOrm;
const setJourney = (enabled: boolean) => updateRows(orm, Addons, { id: 'journey' }, { enabled });
// ── listUsers ─────────────────────────────────────────────────────────────────
describe('listUsers', () => {
@@ -389,9 +400,7 @@ describe('getAuditLog', () => {
describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-045 — parses JSON details when present', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'test_action', JSON.stringify({ key: 'val' })
);
await insertRow(orm, AuditLog, { user: user.id, action: 'test_action', details: JSON.stringify({ key: 'val' }) });
const result = (await getAuditLog({})) as any;
expect(result.entries.length).toBeGreaterThanOrEqual(1);
const entry = result.entries.find((e: any) => e.action === 'test_action');
@@ -401,9 +410,7 @@ describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'bad_json_action', 'not-valid-json{'
);
await insertRow(orm, AuditLog, { user: user.id, action: 'bad_json_action', details: 'not-valid-json{' });
const result = (await getAuditLog({})) as any;
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
expect(entry).toBeDefined();
@@ -642,32 +649,32 @@ describe('updateAddon', () => {
});
it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', async () => {
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run();
testDb.prepare("UPDATE photo_providers SET enabled = 0 WHERE id = 'immich'").run();
await setJourney(false);
await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 0 });
const result = await updateAddon('immich', { enabled: true }) as any;
expect(result).toEqual({ error: 'Enable the Journey addon first', status: 409 });
expect(testDb.prepare("SELECT enabled FROM photo_providers WHERE id = 'immich'").get()).toEqual({ enabled: 0 });
expect((await findRow(orm, PhotoProviders, { id: 'immich' }))?.enabled).toBe(0);
});
it('ADMIN-SVC-088 — enables a provider under an enabled journey; disabling never needs journey', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run();
await setJourney(true);
const enabled = await updateAddon('immich', { enabled: true }) as any;
expect(enabled.addon).toMatchObject({ id: 'immich', type: 'photo_provider', enabled: true });
// Switching a provider OFF stays possible with journey off — cleanup must not dead-end.
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'journey'").run();
await setJourney(false);
const disabled = await updateAddon('immich', { enabled: false }) as any;
expect(disabled.addon.enabled).toBe(false);
});
it('ADMIN-SVC-089 — disabling journey cascades every photo provider off', async () => {
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'journey'").run();
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
await setJourney(true);
await updateRows(orm, PhotoProviders, {}, { enabled: 1 });
const result = await updateAddon('journey', { enabled: false }) as any;
expect(result.addon.enabled).toBe(false);
const rows = testDb.prepare('SELECT enabled FROM photo_providers').all() as Array<{ enabled: number }>;
const rows = await findRows(orm, PhotoProviders);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.enabled === 0)).toBe(true);
});
@@ -691,10 +698,7 @@ describe('version-check job', () => {
await new VersionCheckJob(svc, registrarStub, envStub).tick();
const notified = testDb
.prepare('SELECT value FROM app_settings WHERE key = ?')
.get('last_notified_version') as { value: string } | undefined;
expect(notified?.value).toBe('99.9.9');
expect(await readAppSetting(orm, 'last_notified_version')).toBe('99.9.9');
// The version cache is module-scoped in admin.helpers, so the cron and
// GET /api/admin/version-check hit GitHub once between them.
@@ -714,7 +718,7 @@ describe('admin quirk fixes (post-fold)', () => {
expect((await updateUser(String(user.id), { username: '' })) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' });
expect((await updateUser(String(user.id), { email: ' ' })) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' });
// The row is untouched.
const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string };
const row = await readUser(orm, user.id);
expect(row.username).toBe(user.username);
});
@@ -722,29 +726,20 @@ describe('admin quirk fixes (post-fold)', () => {
// ── What an admin password reset ends, and what an ordinary edit must not ─────
const pv = (id: number): number =>
(testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number | null })
?.password_version ?? 0;
const pv = async (id: number): Promise<number> => (await findRow(orm, Users, { id }))?.password_version ?? 0;
const mcpTokenCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n;
const mcpTokenCount = (id: number): Promise<number> => countRows(orm, McpTokens, { user: id });
const addPushDevice = (userId: number, endpoint: string): void => {
testDb
.prepare(
"INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key) VALUES (?, ?, 'p', 'a', 'k')",
)
.run(userId, endpoint);
const addPushDevice = async (userId: number, endpoint: string): Promise<void> => {
await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' });
};
const liveSessions = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL').get(id) as { n: number }).n;
const pushDeviceCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?').get(id) as { n: number }).n;
const pushDeviceCount = (id: number): Promise<number> => countRows(orm, PushSubscriptions, { user: id });
const passwordHash = (id: number): string =>
(testDb.prepare('SELECT password_hash FROM users WHERE id = ?').get(id) as { password_hash: string }).password_hash;
const passwordHash = async (id: number): Promise<string> => (await readUser(orm, id)).password_hash;
describe('admin password reset revokes what an intruder already holds', () => {
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', async () => {
@@ -753,41 +748,41 @@ describe('admin password reset revokes what an intruder already holds', () => {
// accepting every cookie the intruder holds, and the one action taken to
// lock them out is the one action that did not.
const { user } = createUser(testDb);
const before = pv(user.id);
const before = await pv(user.id);
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(pv(user.id)).toBe(before + 1);
expect(await pv(user.id)).toBe(before + 1);
});
it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(mcpTokenCount(user.id)).toBe(0);
expect(await mcpTokenCount(user.id)).toBe(0);
});
it('ADMIN-SVC-081b: and forgets the push devices, which outlive every session, of that user only', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
addPushDevice(user.id, 'https://web.push.apple.com/owner');
addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander');
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
await addPushDevice(user.id, 'https://web.push.apple.com/owner');
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/bystander');
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(pushDeviceCount(user.id)).toBe(0);
expect(pushDeviceCount(other.id)).toBe(1);
expect(await pushDeviceCount(user.id)).toBe(0);
expect(await pushDeviceCount(other.id)).toBe(1);
});
it('ADMIN-SVC-081c: drops them in the same transaction as the password, so a failure leaves the account as it was', async () => {
const { user } = createUser(testDb);
const before = pv(user.id);
const hashBefore = passwordHash(user.id);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
const before = await pv(user.id);
const hashBefore = await passwordHash(user.id);
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/intruder');
testDb.exec("CREATE TRIGGER boom BEFORE DELETE ON push_subscriptions BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
await expect(updateUser(String(user.id), { password: 'ANewStrongPass123!' })).rejects.toThrow('boom');
@@ -795,10 +790,10 @@ describe('admin password reset revokes what an intruder already holds', () => {
testDb.exec('DROP TRIGGER boom');
}
expect(pv(user.id)).toBe(before);
expect(passwordHash(user.id)).toBe(hashBefore);
expect(mcpTokenCount(user.id)).toBe(1);
expect(pushDeviceCount(user.id)).toBe(1);
expect(await pv(user.id)).toBe(before);
expect(await passwordHash(user.id)).toBe(hashBefore);
expect(await mcpTokenCount(user.id)).toBe(1);
expect(await pushDeviceCount(user.id)).toBe(1);
});
it('ADMIN-SVC-081d: and ends every session of that user, and only of that user', async () => {
@@ -817,15 +812,15 @@ describe('admin password reset revokes what an intruder already holds', () => {
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => {
const { user } = createUser(testDb);
const before = pv(user.id);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed');
const before = await pv(user.id);
await insertRow(orm, McpTokens, { user: user.id, token_hash: 'hash', token_prefix: 'trek_ab', name: 'cli' });
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/renamed');
await updateUser(String(user.id), { username: 'renamed' });
expect(pv(user.id)).toBe(before);
expect(mcpTokenCount(user.id)).toBe(1);
expect(pushDeviceCount(user.id)).toBe(1);
expect(await pv(user.id)).toBe(before);
expect(await mcpTokenCount(user.id)).toBe(1);
expect(await pushDeviceCount(user.id)).toBe(1);
});
});
@@ -852,9 +847,7 @@ describe('resetUserMfa', () => {
expect(result.success).toBe(true);
expect(result.email).toBe(user.email);
const row = testDb
.prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?')
.get(user.id) as { mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null };
const row = await readUser(orm, user.id);
expect(row.mfa_enabled).toBe(0);
expect(row.mfa_secret).toBeNull();
expect(row.mfa_backup_codes).toBeNull();
@@ -912,7 +905,7 @@ describe('updateUser — last-admin guard (AD9/AD10, R4)', () => {
const result = (await updateUser(String(soleAdmin.id), { role: 'user' })) as { error?: string; status?: number };
expect(result).toEqual({ error: 'Cannot remove the last admin', status: 400 });
const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(soleAdmin.id) as { role: string };
const row = await readUser(orm, soleAdmin.id);
expect(row.role).toBe('admin');
});
@@ -923,7 +916,7 @@ describe('updateUser — last-admin guard (AD9/AD10, R4)', () => {
const result = (await updateUser(String(secondAdmin.id), { role: 'user' })) as { user?: { role: string }; error?: string };
expect(result.error).toBeUndefined();
const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(secondAdmin.id) as { role: string };
const row = await readUser(orm, secondAdmin.id);
expect(row.role).toBe('user');
});
});
@@ -933,18 +926,17 @@ describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a
const { user: liveUser } = createUser(testDb);
const { user: doomedUser } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)')
.run(liveUser.id, 'live_user_action', 'trip', null, '127.0.0.1');
testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)')
.run(doomedUser.id, 'about_to_be_deleted_action', 'trip', null, '127.0.0.1');
await insertRow(orm, AuditLog, { user: liveUser.id, action: 'live_user_action', resource: 'trip', details: null, ip: '127.0.0.1' });
await insertRow(orm, AuditLog, { user: doomedUser.id, action: 'about_to_be_deleted_action', resource: 'trip', details: null, ip: '127.0.0.1' });
// audit_log.user_id is ON DELETE SET NULL (Task 0's report) — deleting the
// user directly (not through the service) makes the row's user_id
// genuinely NULL, the exact LEFT JOIN shape rule 16 warns about.
testDb.prepare('DELETE FROM users WHERE id = ?').run(doomedUser.id);
await deleteRows(orm, Users, { id: doomedUser.id });
// The legacy statement, run raw on the SAME seeded rows — the parity anchor.
const legacyRows = testDb
// test-sql-allow: the legacy statement is this parity test's oracle and has to run as written.
.prepare(
`SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
@@ -986,9 +978,7 @@ describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a
describe('updateUser — password-reset transaction boundary (AD11/12/13)', () => {
it('ADMIN-SVC-093 — a failure on the mcp_tokens delete (AD12, not try/caught) rolls back the already-run users UPDATE (AD11) too', async () => {
const { user } = createUser(testDb);
const before = testDb
.prepare('SELECT username, password_version FROM users WHERE id = ?')
.get(user.id) as { username: string; password_version: number };
const before = await readUser(orm, user.id);
const spy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('simulated mcp_tokens failure'));
@@ -996,9 +986,7 @@ describe('updateUser — password-reset transaction boundary (AD11/12/13)', () =
updateUser(String(user.id), { username: 'should-roll-back', password: 'ANewStrongPass123!' }),
).rejects.toThrow('simulated mcp_tokens failure');
const after = testDb
.prepare('SELECT username, password_version FROM users WHERE id = ?')
.get(user.id) as { username: string; password_version: number };
const after = await readUser(orm, user.id);
// The users UPDATE (AD11) ran FIRST, inside the SAME uow.transactional
// boundary as the failing mcp_tokens delete — proves the TX boundary
@@ -1069,6 +1057,6 @@ describe('a create or edit that loses the race for an email or username', () =>
const { user } = createUser(testDb, { username: 'carl', email: 'carl@example.com' });
vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null);
expect(await updateUser(String(user.id), { email: 'Anna@Example.com' })).toEqual({ error: 'Email already taken', status: 409 });
expect(testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id)).toEqual({ email: 'carl@example.com' });
expect((await readUser(orm, user.id)).email).toBe('carl@example.com');
});
});
@@ -16,14 +16,6 @@ vi.mock('../../../src/db/database', async () => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return mock;
});
@@ -60,6 +52,8 @@ import type { TripAlbumLinksRepository } from '../../../src/db/repositories/Trip
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, sharedTestOrm, createTestUsersRepo } from '../../helpers/test-uow';
import type { TestOrm } from '../../helpers/test-orm';
import { countRows, findRow, findRows, insertRow, insertRowIgnoringConflict, updateRows } from '../../helpers/factories/rows';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
@@ -104,6 +98,7 @@ beforeAll(async () => {
// never read it, so the `dbs.canAccessTrip` spy this block used to route
// to a real `DatabaseService` was already dead; removed with it.
const t = await sharedTestOrm(testDb);
orm = t;
tripPhotosRepo = t.repo(TripPhotos);
tripAlbumLinksRepo = t.repo(TripAlbumLinks);
usersRepo = await createTestUsersRepo(testDb);
@@ -124,10 +119,10 @@ beforeAll(async () => {
);
});
beforeEach(() => {
beforeEach(async () => {
resetTestDb(testDb);
// Ensure default providers are enabled (resetTestDb seeds them but doesn't reset enabled flag)
testDb.prepare('UPDATE photo_providers SET enabled = 1').run();
await updateRows(orm, PhotoProviders, {}, { enabled: 1 });
// Providers only count as enabled under an enabled journey addon (migration 84 seeds it off).
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
realtimeMock.broadcast.mockClear();
@@ -137,6 +132,13 @@ afterAll(() => {
testDb.close();
});
let orm: TestOrm;
/** The ids of the providers switched on, the list the legacy statements bind into their IN (...). */
async function enabledProviderIds(): Promise<string[]> {
return (await findRows(orm, PhotoProviders, { enabled: 1 })).map((r) => r.id as string);
}
// ── listTripPhotos ────────────────────────────────────────────────────────────
describe('listTripPhotos', () => {
@@ -151,7 +153,7 @@ describe('listTripPhotos', () => {
const trip = createTrip(testDb, user.id);
// Disable all providers
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
await updateRows(orm, PhotoProviders, {}, { enabled: 0 });
const result = await listTripPhotos(String(trip.id), user.id);
expect(result.success).toBe(false);
@@ -185,7 +187,7 @@ describe('listTripAlbumLinks', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('UPDATE photo_providers SET enabled = 0').run();
await updateRows(orm, PhotoProviders, {}, { enabled: 0 });
const result = await listTripAlbumLinks(String(trip.id), user.id);
expect(result.success).toBe(false);
@@ -207,9 +209,9 @@ describe('addTripPhotos', () => {
const trip = createTrip(testDb, user.id);
// Insert a disabled provider
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99);
await insertRowIgnoringConflict(orm, PhotoProviders, {
id: 'disabled-prov', name: 'Disabled', description: 'Disabled provider', icon: 'Image', enabled: 0, sort_order: 99,
});
const result = await addTripPhotos(
String(trip.id),
@@ -292,9 +294,9 @@ describe('createTripAlbumLink', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100);
await insertRowIgnoringConflict(orm, PhotoProviders, {
id: 'disabled-prov2', name: 'Disabled2', description: 'desc', icon: 'Image', enabled: 0, sort_order: 100,
});
const result = await createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album');
expect(result.success).toBe(false);
@@ -311,8 +313,7 @@ describe('createTripAlbumLink', () => {
const second = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-dup-1', 'Second Name');
expect(expectFailure(second).status).toBe(409);
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_album_links WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
expect(rows.c).toBe(1);
expect(await countRows(orm, TripAlbumLinks, { trip: trip.id, user: user.id })).toBe(1);
});
});
@@ -331,22 +332,23 @@ describe('removeAlbumLink', () => {
const linked = await createTripAlbumLink(String(trip.id), user.id, 'immich', 'album-rm-1', 'To Remove');
expect(linked.success).toBe(true);
const linkRow = testDb.prepare('SELECT id FROM trip_album_links WHERE trip_id = ? AND user_id = ? AND album_id = ?').get(trip.id, user.id, 'album-rm-1') as { id: number };
const linkRow = await findRow(orm, TripAlbumLinks, { trip: trip.id, user: user.id, album_id: 'album-rm-1' });
if (!linkRow) throw new Error('album link album-rm-1 was not created');
await addTripPhotos(String(trip.id), user.id, true, [{ provider: 'immich', asset_ids: ['asset-rm-1'] }], 'sid-6', String(linkRow.id));
const photoRow = testDb.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id) as { photo_id: number };
expect(photoRow).toBeDefined();
const photoRow = await findRow(orm, TripPhotos, { trip: trip.id, albumLink: linkRow.id });
expect(photoRow).not.toBeNull();
const result = await removeAlbumLink(String(trip.id), String(linkRow.id), user.id);
expect(result.success).toBe(true);
const remainingLink = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkRow.id);
expect(remainingLink).toBeUndefined();
const remainingPhoto = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND album_link_id = ?').get(trip.id, linkRow.id);
expect(remainingPhoto).toBeUndefined();
const remainingLink = await findRow(orm, TripAlbumLinks, { id: linkRow.id });
expect(remainingLink).toBeNull();
const remainingPhoto = await findRow(orm, TripPhotos, { trip: trip.id, albumLink: linkRow.id });
expect(remainingPhoto).toBeNull();
// The orphaned trek_photos row is reclaimed too (TrekPhotoRegistrationService.deleteIfOrphan, PH10/PH11).
const orphan = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoRow.photo_id);
expect(orphan).toBeUndefined();
const orphan = await findRow(orm, TrekPhotos, { id: photoRow?.photo_id });
expect(orphan).toBeNull();
});
});
@@ -359,17 +361,18 @@ describe('listTripPhotos / listTripAlbumLinks — parity', () => {
// A photo under a disabled provider must not appear — the legacy `tkp.provider IN (...)`
// only ever names the enabled set.
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, 50)'
).run('disabled-x', 'Disabled X', 'desc', 'Image');
await insertRowIgnoringConflict(orm, PhotoProviders, {
id: 'disabled-x', name: 'Disabled X', description: 'desc', icon: 'Image', enabled: 0, sort_order: 50,
});
const enabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-enabled', user.id).lastInsertRowid as number;
const disabledPhoto = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('disabled-x', 'asset-disabled', user.id).lastInsertRowid as number;
const enabledPhoto = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-enabled', owner: user.id });
const disabledPhoto = await insertRow(orm, TrekPhotos, { provider: 'disabled-x', asset_id: 'asset-disabled', owner: user.id });
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, enabledPhoto);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, disabledPhoto);
await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: enabledPhoto, shared: 1 });
await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: disabledPhoto, shared: 1 });
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
const enabledProviders = await enabledProviderIds();
// test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to.
const oracle = testDb.prepare(`
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
@@ -394,7 +397,8 @@ describe('listTripPhotos / listTripAlbumLinks — parity', () => {
await createTripAlbumLink(String(trip.id), user.id, 'synologyphotos', 'album-p1', 'Passphrase Album', 'secret-pass');
const enabledProviders = testDb.prepare('SELECT id FROM photo_providers WHERE enabled = 1').all().map((r: { id: string }) => r.id);
const enabledProviders = await enabledProviderIds();
// test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to.
const oracle = testDb.prepare(`
SELECT tal.id,
tal.trip_id,
@@ -452,16 +456,16 @@ describe('R8 — injected RealtimeService, not the legacy module broadcast', ()
it('MEM-UNIFIED-016: setTripPhotoSharing calls this.realtime.broadcast', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-2', user.id).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
const photoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-r8-2', owner: user.id });
await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 0 });
const result = await setTripPhotoSharing(String(trip.id), user.id, photoId, true, 'sid-2');
expect(result.success).toBe(true);
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-2');
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId) as { shared: number };
expect(row.shared).toBe(1);
const row = await findRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId });
expect(row?.shared).toBe(1);
});
it('MEM-UNIFIED-018: UM4 insertIgnore reports 0 added on a re-add of the same photo (duplicate-ignore, not a crash)', async () => {
@@ -475,22 +479,21 @@ describe('R8 — injected RealtimeService, not the legacy module broadcast', ()
const second = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'immich', asset_ids: ['asset-dup-1'] }], 'sid-5');
expect(expectSuccess(second).added).toBe(0);
const rows = testDb.prepare('SELECT COUNT(*) as c FROM trip_photos WHERE trip_id = ? AND user_id = ?').get(trip.id, user.id) as { c: number };
expect(rows.c).toBe(1);
expect(await countRows(orm, TripPhotos, { trip: trip.id, user: user.id })).toBe(1);
});
it('MEM-UNIFIED-017: removeTripPhoto calls this.realtime.broadcast and deletes the row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const photoId = testDb.prepare('INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-r8-3', user.id).lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 0)').run(trip.id, user.id, photoId);
const photoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-r8-3', owner: user.id });
await insertRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId, shared: 0 });
const result = await removeTripPhoto(String(trip.id), user.id, photoId, 'sid-3');
expect(result.success).toBe(true);
expect(realtimeMock.broadcast).toHaveBeenCalledTimes(1);
expect(realtimeMock.broadcast).toHaveBeenCalledWith(String(trip.id), 'memories:updated', { userId: user.id }, 'sid-3');
const row = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?').get(trip.id, user.id, photoId);
expect(row).toBeUndefined();
const row = await findRow(orm, TripPhotos, { trip: trip.id, user: user.id, photo: photoId });
expect(row).toBeNull();
});
});