refactor(photos): trek photos, memories access and cache sweeps through repositories

This commit is contained in:
jubnl
2026-09-23 17:58:20 +02:00
parent 6b4c124a38
commit db2db7f5a3
36 changed files with 942 additions and 270 deletions
@@ -6,7 +6,9 @@ import { JourneyDomainService } from '../../src/nest/journey/journey-domain.serv
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
@@ -41,10 +43,11 @@ export async function makeAccommodationsService(conn: Database): Promise<Accommo
export async function accommodationsOver(dbs: DatabaseService): Promise<AccommodationsService> {
const permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
const realtime = new RealtimeService();
const t = await sharedTestOrm(dbs.connection);
const assignments = new AssignmentsService(
dbs, permissions, realtime,
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
new JourneyDomainService(dbs, realtime, new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection)),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestAssignmentParticipantsRepo(dbs.connection),
+12 -6
View File
@@ -62,7 +62,12 @@ import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.
import { JourneyMcp } from '../../src/nest/journey/journey.mcp';
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import { TripAlbumLinks } from '../../src/db/entities/TripAlbumLinks.entity';
import { Trips } from '../../src/db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../src/db/entities/TrekPhotoCacheMeta.entity';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
@@ -202,7 +207,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// and its on-disk set only work if all three readers see the same maps.
const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(dbService.connection), await createTestPlacesRepo(dbService.connection));
const mapsService = new MapsService(dbService, placePhotoCache, appSettings, usersRepo);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService), await createTestUnitOfWork(dbService.connection));
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), dbService), await createTestUnitOfWork(dbService.connection));
// The last three were previously omitted, which left them `undefined` at
// runtime — silently fine while nothing called them, a TypeError the moment
// the journey skeleton hooks landed on the place write paths. tsconfig.tests.json
@@ -280,10 +285,11 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// (which asks them when and where it was taken). Built for real rather than
// stubbed: an empty provider registry would make the backfill answer "unknown
// provider" for every id and hide a wiring mistake behind a caught error.
const immichService = new ImmichService(dbService, new AuditService(auditLogRepo, usersRepo), new MemoriesAccessService(dbService), generalStorage);
const synologyService = new SynologyService(dbService, new MemoriesAccessService(dbService), notificationsStub());
const trekPhotos = new TrekPhotosRepository(dbService);
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, dbService), new TrekPhotoCacheService(dbService, generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
const memoriesAccess = new MemoriesAccessService(dbService, mcpOrm.repo(TripPhotos), mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripAlbumLinks), mcpOrm.repo(Trips));
const immichService = new ImmichService(dbService, new AuditService(auditLogRepo, usersRepo), memoriesAccess, generalStorage);
const synologyService = new SynologyService(dbService, memoriesAccess, notificationsStub());
const trekPhotos = new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), dbService);
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, mcpOrm.repo(TrekPhotos)), new TrekPhotoCacheService(mcpOrm.repo(TrekPhotoCacheMeta), generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
return createTestRegistry(
[
new TagsMcp(new TagsService(await createTestTagsRepo(dbService.connection)), authService),
+4 -2
View File
@@ -69,7 +69,9 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeStorageFixture } from './storage-fixture';
import {
@@ -129,7 +131,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
);
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(dbs.connection), await createTestPlacesRepo(dbs.connection));
const unsplash = new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage);
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection));
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection));
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage, await createTestUnitOfWork(dbs.connection));
const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection));
const dayNotes = new DayNotesService(dbs, permissions, realtime);
@@ -1170,15 +1170,27 @@ describe('Synology SSRF blocked error handling', () => {
// ── Passphrase persistence fixes ─────────────────────────────────────────────
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { DatabaseService } from '../../src/nest/database/database.service';
import { db as trekDb } from '../../src/db/database';
import { MikroORM } from '@mikro-orm/core';
import { withRequestContext } from '../../src/nest/database/request-context';
import { TrekPhotoRegistrationService } from '../../src/nest/photos/trek-photos.repository';
// Was photos.bridge, which existed for consumers outside the container and had
// none left. The repository is what it delegated to.
const trekPhotos = new TrekPhotosRepository(new DatabaseService(trekDb));
const getOrCreateTrekPhoto = (...a: Parameters<TrekPhotosRepository['getOrCreate']>) => trekPhotos.getOrCreate(...a);
const deleteTrekPhotoIfOrphan = (id: number) => trekPhotos.deleteIfOrphan(id);
// none left. The repository is what it delegated to. Resolved off the real,
// DI-wired container (`nestApp`, built in the file's own `beforeAll` above)
// rather than hand-constructed — `TrekPhotoRegistrationService` now takes ORM
// repositories, not a bare `DatabaseService` (Plan 3e Task 6). These helpers
// call it directly, with no HTTP request around them (unlike the real
// `syncSynologyAlbum` call chain this pins, which always runs inside one) —
// `withRequestContext` supplies the same per-call EntityManager fork a real
// request's `MikroOrmMiddleware` would.
let trekPhotos: TrekPhotoRegistrationService;
let orm: MikroORM;
beforeAll(() => {
trekPhotos = nestApp.get(TrekPhotoRegistrationService);
orm = nestApp.get(MikroORM);
});
const getOrCreateTrekPhoto = (...a: Parameters<TrekPhotoRegistrationService['getOrCreate']>) => withRequestContext(orm, () => trekPhotos.getOrCreate(...a));
const deleteTrekPhotoIfOrphan = (id: number) => withRequestContext(orm, () => trekPhotos.deleteIfOrphan(id));
import { decrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
describe('trek_photos passphrase healing (SYNO-090)', () => {
@@ -54,11 +54,13 @@ import { AssignmentsService } from '../../../src/nest/assignments/assignments.se
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo, createTestDayAssignmentsRepo, createTestDaysRepo, createTestPlacesRepo,
createTestTripMembersRepo, createTestDatabaseService, createTestRoadtripViasRepo,
createTestTripMembersRepo, createTestDatabaseService, createTestRoadtripViasRepo, sharedTestOrm,
} from '../../helpers/test-uow';
let svc: AssignmentsService;
@@ -77,7 +79,7 @@ beforeAll(async () => {
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
// Real collaborator rather than a stub: reconcile() runs after every mutation
// and needs the same connection to see the rows these cases write.
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
new JourneyDomainService(dbs, realtime, new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection)),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
await createTestAssignmentParticipantsRepo(dbs.connection),
@@ -41,11 +41,13 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, addJourneyContributor } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
const dbs = new DatabaseService(dbConn);
let domain: JourneyDomainService;
@@ -65,7 +67,8 @@ beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const uow = await createTestUnitOfWork(testDb);
domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow);
const t = await sharedTestOrm(testDb);
domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), uow);
books = new JourneyBookService(dbs, domain);
});
@@ -55,7 +55,9 @@ import {
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
@@ -66,8 +68,9 @@ let svc: JourneyDomainService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
dbs = new DatabaseService(dbConn, (await sharedTestOrm(testDb)).em);
svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(testDb));
const t = await sharedTestOrm(testDb);
dbs = new DatabaseService(dbConn, t.em);
svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -36,7 +36,9 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, createJourneyEntry, addJourneyContributor } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyShareService } from '../../../src/nest/journey/journey-share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
@@ -60,7 +62,7 @@ beforeAll(async () => {
t = await sharedTestOrm(testDb);
svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), uow),
new SettingsService(uow, await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb)),
);
});
@@ -63,10 +63,12 @@ import {
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
const dbs = new DatabaseService(dbConn);
let svc: JourneyDomainService;
@@ -89,7 +91,8 @@ function placeEntry(
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(testDb));
const t = await sharedTestOrm(testDb);
svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -61,9 +61,18 @@ import { mapDbError, pipeAsset, type ServiceResult } from '../../../src/nest/mem
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity';
import { sharedTestOrm } from '../../helpers/test-uow';
const access = new MemoriesAccessService(new DatabaseService(testDb));
// Plan 3c Task 0b / Plan 3e Task 6: `access` used to be constructed at module
// load, before any `beforeAll` could resolve a real `EntityManager` — it now
// also needs `TripPhotosRepository`/`TrekPhotosRepository`/
// `TripAlbumLinksRepository`/`TripsRepository` (the ORM ones), which the
// same `sharedTestOrm(testDb)` this file already used for the
// `canAccessTrip` patch hands out. Built inside the async `beforeAll` below.
let access: MemoriesAccessService;
// A typed forwarder, not a `.bind` alias: a bound alias is typed `any`, which
// hides a missing `await` from tsc and from all three lint rules.
const getAlbumIdFromLink = (...a: Parameters<MemoriesAccessService['getAlbumIdFromLink']>) => access.getAlbumIdFromLink(...a);
@@ -72,15 +81,15 @@ import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
// Plan 3c Task 0b: `access` (and `DatabaseService.prototype.canAccessTrip`
// it holds a reference to) is constructed at module load, before any
// `beforeAll` can resolve a real `EntityManager` — patched onto the
// PROTOTYPE instead, which the already-constructed instance's method
// lookup still resolves through (`canAccessTrip` is not an own property).
const em = (await sharedTestOrm(testDb)).em;
const t = await sharedTestOrm(testDb);
const dbs = new DatabaseService(testDb, t.em);
// `DatabaseService.prototype.canAccessTrip` is patched on the PROTOTYPE
// (not just this instance) so every OTHER hand-built `DatabaseService` in
// this file's helpers resolves through the same real predicate.
vi.spyOn(DatabaseService.prototype, 'canAccessTrip').mockImplementation(async (tripId, userId) =>
em.getRepository(Trips).findAccessible(tripId, userId),
t.em.getRepository(Trips).findAccessible(tripId, userId),
);
access = new MemoriesAccessService(dbs, t.repo(TripPhotos), t.repo(TrekPhotos), t.repo(TripAlbumLinks), t.repo(Trips));
});
beforeEach(() => {
@@ -476,3 +485,130 @@ describe('canAccessTrekPhoto', () => {
expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// MA5 — canAccessTrekPhoto's trip-membership predicate, rewritten (R4) onto
// TripsRepository.findAccessible instead of a hand-translated second copy of
// the legacy `EXISTS(...trip_members...UNION ALL...trips...)` text. This
// parity suite proves the rewrite answers identically to the legacy text on
// the same rows across owner/member/stranger, and is mutation-proof-worthy
// per the plan's rule: MEMACCESS-MA5-003 is the one the report's mutation
// proof (temporarily breaking the `findAccessible` call) must turn red.
// ---------------------------------------------------------------------------
/** The MA5 statement, run raw for comparison — never converted, kept only as the oracle. */
function legacyMA5(photoId: number, userId: number): boolean {
return !!testDb.prepare(`
SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1
AND EXISTS (
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
UNION ALL
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
)
LIMIT 1
`).get(photoId, userId, userId);
}
describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewrite vs. the legacy EXISTS/UNION ALL text)', () => {
it('MEMACCESS-MA5-001: the trip owner (not the photo owner, no trip_members row) matches the legacy predicate — both true', async () => {
const { user: photoOwner } = createUser(testDb);
const { user: tripOwner } = createUser(testDb, { username: 'trip-owner' });
const trip = createTrip(testDb, tripOwner.id);
const photoId = shareInTrip(trip.id, photoOwner.id, 'ma5-owner-asset');
expect(await access.canAccessTrekPhoto(tripOwner.id, photoId)).toBe(legacyMA5(photoId, tripOwner.id));
expect(await access.canAccessTrekPhoto(tripOwner.id, photoId)).toBe(true);
});
it('MEMACCESS-MA5-002: a trip member (a trip_members row, not the trip owner) matches the legacy predicate — both true', async () => {
const { user: photoOwner } = createUser(testDb);
const { user: tripOwner } = createUser(testDb, { username: 'trip-owner-2' });
const { user: member } = createUser(testDb, { username: 'member-2' });
const trip = createTrip(testDb, tripOwner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const photoId = shareInTrip(trip.id, photoOwner.id, 'ma5-member-asset');
expect(await access.canAccessTrekPhoto(member.id, photoId)).toBe(legacyMA5(photoId, member.id));
expect(await access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
});
it('MEMACCESS-MA5-003: a stranger (neither owner nor member) matches the legacy predicate — both false. Mutation proof: breaking the findAccessible call (e.g. hard-coding it to return undefined) turns this red.', async () => {
const { user: photoOwner } = createUser(testDb);
const { user: tripOwner } = createUser(testDb, { username: 'trip-owner-3' });
const { user: stranger } = createUser(testDb, { username: 'stranger-3' });
const trip = createTrip(testDb, tripOwner.id);
const photoId = shareInTrip(trip.id, photoOwner.id, 'ma5-stranger-asset');
expect(await access.canAccessTrekPhoto(stranger.id, photoId)).toBe(legacyMA5(photoId, stranger.id));
expect(await access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
});
it('MEMACCESS-MA5-004: an unshared trip_photos row (shared=0) matches the legacy predicate — both false even for the trip owner', async () => {
const { user: photoOwner } = createUser(testDb);
const { user: tripOwner } = createUser(testDb, { username: 'trip-owner-4' });
const trip = createTrip(testDb, tripOwner.id);
const photoId = shareInTrip(trip.id, photoOwner.id, 'ma5-unshared-asset', 'immich', 0);
expect(await access.canAccessTrekPhoto(tripOwner.id, photoId)).toBe(legacyMA5(photoId, tripOwner.id));
expect(await access.canAccessTrekPhoto(tripOwner.id, photoId)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Album link syncing — MA7/MA8 (getAlbumIdFromLink/getAlbumLinkForSync, one
// shared `findScoped` repository method) and MA9 (updateSyncTimeForAlbumLink).
// ---------------------------------------------------------------------------
describe('getAlbumLinkForSync (MA8)', () => {
it('MEMACCESS-MA8-001: returns the album id and decrypted passphrase when the link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'immich', 'album-sync-1', 'Sync Album', null);
const result = await access.getAlbumLinkForSync(String(trip.id), String(ins.lastInsertRowid), user.id);
expect(result.success).toBe(true);
expect((result as { data: { albumId: string } }).data.albumId).toBe('album-sync-1');
});
it('MEMACCESS-MA8-002: is USER-scoped — a different user on the same trip gets "not found", not another user\'s link', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other-link-user' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, owner.id, 'immich', 'album-scoped', 'Scoped');
const result = await access.getAlbumLinkForSync(String(trip.id), String(ins.lastInsertRowid), other.id);
expect(result.success).toBe(false);
});
it('MEMACCESS-MA8-003: a non-canonical link id (rule 15) answers the same "not found" as a genuinely missing link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await access.getAlbumLinkForSync(String(trip.id), '1.0', user.id);
expect(result.success).toBe(false);
});
});
describe('updateSyncTimeForAlbumLink (MA9)', () => {
it('MEMACCESS-MA9-001: stamps last_synced_at on the given link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'immich', 'album-touch', 'Touch');
await access.updateSyncTimeForAlbumLink(String(ins.lastInsertRowid));
const row = testDb.prepare('SELECT last_synced_at FROM trip_album_links WHERE id = ?').get(ins.lastInsertRowid) as { last_synced_at: string | null };
expect(row.last_synced_at).not.toBeNull();
});
it('MEMACCESS-MA9-002: a non-canonical id is a silent no-op, matching a legacy UPDATE that matched zero rows', async () => {
await expect(access.updateSyncTimeForAlbumLink('not-a-number')).resolves.toBeUndefined();
});
});
@@ -10,7 +10,7 @@ vi.mock('exifr', () => ({ default: { parse: vi.fn() } }));
import exifr from 'exifr';
import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service';
import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import type { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import type { StorageService } from '../../../src/nest/storage/storage.service';
// The storage layer's job here is only to hand the EXIF reader a real path;
@@ -32,7 +32,7 @@ function build(rows: Row[], info: Record<number, unknown>) {
const photos = {
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
} as unknown as TrekPhotoRegistrationService;
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
}
@@ -125,7 +125,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
const photos = {
resolve: (id: number) => rows.find(r => r.id === id) ?? null,
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
} as unknown as TrekPhotoRegistrationService;
const resolver = { getPhotoInfo } as unknown as PhotoResolverService;
return { svc: new PhotoCaptureBackfillService(resolver, photos, storageStub), recordCaptureMetadata, getPhotoInfo };
}
@@ -216,7 +216,7 @@ describe('PhotoCaptureBackfillService — local files', () => {
const photos = {
resolve: () => { throw new Error('db gone'); },
recordCaptureMetadata,
} as unknown as TrekPhotosRepository;
} as unknown as TrekPhotoRegistrationService;
const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, storageStub);
await expect(svc.run([7], 1)).resolves.toBeUndefined();
@@ -34,7 +34,10 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
@@ -68,7 +71,6 @@ const cache = {
const storage = { exists: vi.fn(), sendToResponse: vi.fn() };
const dbs = new DatabaseService(testDb);
const repo = new TrekPhotosRepository(dbs);
// Real adapters over the stubbed services, and a real registry: the cases below
// keep asserting on immich.streamImmichAsset/synology.streamSynologyAsset, so
// they now also pin the adapters' argument mapping — which is where the two
@@ -78,13 +80,8 @@ const providers = new PhotoProviderRegistry([
new ImmichPhotoProvider(immich as unknown as ImmichService),
new SynologyPhotoProvider(synology as unknown as SynologyService),
]);
const svc = new PhotoResolverService(
repo,
thumbnails as unknown as ThumbnailService,
cache as unknown as TrekPhotoCacheService,
providers,
storage as unknown as import('../../../src/nest/storage/storage.service').StorageService,
);
let t: TestOrm;
let svc: PhotoResolverService;
function makeRes() {
return { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn(), sendFile: vi.fn(), end: vi.fn() };
@@ -99,9 +96,18 @@ function insertPhoto(cols: Record<string, unknown>): number {
return Number(res.lastInsertRowid);
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await createTestOrm(testDb);
const repo = new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs);
svc = new PhotoResolverService(
repo,
thumbnails as unknown as ThumbnailService,
cache as unknown as TrekPhotoCacheService,
providers,
storage as unknown as import('../../../src/nest/storage/storage.service').StorageService,
);
});
beforeEach(() => {
@@ -119,7 +125,10 @@ beforeEach(() => {
storage.sendToResponse.mockResolvedValue(undefined);
});
afterAll(() => testDb.close());
afterAll(async () => {
await t.close();
testDb.close();
});
describe('streamPhoto — dispatch', () => {
it('RESOLVE-001: 404s a photo id that does not exist', async () => {
@@ -52,7 +52,9 @@ import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow';
@@ -89,7 +91,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise<Pla
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), new RuntimeEnvService(), storageFx.storage),
photoCacheStub,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection)),
storageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
await createTestPlacesRepo(dbs.connection),
@@ -75,7 +75,9 @@ import { PlacesService } from '../../../src/nest/places/places.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, createTestTripsRepo, sharedTestOrm } from '../../helpers/test-uow';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
@@ -131,7 +133,7 @@ async function makePlacesService(
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), new RuntimeEnvService(), placesStorageFx.storage),
photoCacheStub,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection)),
placesStorageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
await createTestPlacesRepo(dbs.connection),
@@ -6,7 +6,7 @@
* picture or a broken tile: the addon gate, the "source is gone" bail-out, and
* the mtime check that avoids regenerating an up-to-date thumbnail.
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
@@ -15,18 +15,22 @@ import path from 'node:path';
import { Jimp } from 'jimp';
import { ThumbnailService, journeyThumbName } from '../../../src/nest/memories/thumbnail.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import Database from 'better-sqlite3';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { makeStorageFixture } from '../../helpers/storage-fixture';
// Category-addressed since slice 4: originals + thumbs are ('journey', <name>)
// objects; the fixture's 'journey/' prefix reproduces the real layout, so the
// on-disk paths below look exactly like the old uploads-root ones.
const fx = makeStorageFixture('journey/');
// Minimal real DB — the orphan sweep only SELECTs these two columns.
const thumbsDb = new Database(':memory:');
thumbsDb.exec('CREATE TABLE trek_photos (id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT, thumbnail_path TEXT)');
const svc = new ThumbnailService({ isAddonEnabled } as unknown as AddonsService, fx.storage, new DatabaseService(thumbsDb as never));
// Full migrated schema now (Plan 3e Task 6): sweepOrphanThumbs reads through
// TrekPhotosRepository (the ORM one), which needs the real trek_photos table,
// not the three-column hand-rolled stand-in this file used before.
const thumbsDb = createSnapshotTestDb();
let t: TestOrm;
let svc: ThumbnailService;
const root = fx.root;
/** A real 1200x900 JPEG — Jimp has to be able to decode it for the happy path. */
@@ -37,12 +41,18 @@ async function writeSourceImage(rel: string): Promise<void> {
await img.write(abs as `${string}.jpg`);
}
beforeAll(async () => {
t = await createTestOrm(thumbsDb);
svc = new ThumbnailService({ isAddonEnabled } as unknown as AddonsService, fx.storage, t.repo(TrekPhotos));
});
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
});
afterAll(() => {
afterAll(async () => {
await t.close();
thumbsDb.close();
fx.cleanup();
});
@@ -132,7 +142,7 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
});
it('THUMB-SWEEP-001: deletes strays, spares thumbs derivable from live journey rows', async () => {
thumbsDb.prepare('INSERT INTO trek_photos (file_path) VALUES (?)').run('journey/live.jpg');
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path) VALUES ('local', ?)").run('journey/live.jpg');
const liveName = journeyThumbName('journey/live.jpg'); // 'thumbs/<hash>.jpg'
const livePath = writeThumb(path.basename(liveName));
const strayPath = writeThumb('deadbeefdeadbeef.jpg');
@@ -145,7 +155,7 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
});
it('THUMB-SWEEP-002: spares a recorded thumbnail_path even without a matching file_path hash', async () => {
thumbsDb.prepare('INSERT INTO trek_photos (file_path, thumbnail_path) VALUES (?, ?)')
thumbsDb.prepare("INSERT INTO trek_photos (provider, file_path, thumbnail_path) VALUES ('local', ?, ?)")
.run('elsewhere/x.jpg', 'journey/thumbs/recorded00000000.jpg');
const recorded = writeThumb('recorded00000000.jpg');
@@ -13,28 +13,19 @@
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
return {
testDb: db,
dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null, isOwner: () => false, getPlaceWithTags: () => null },
};
});
vi.mock('../../../src/db/database', () => dbMock);
import fs from 'node:fs';
import path from 'node:path';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { TrekPhotoCacheMeta } from '../../../src/db/entities/TrekPhotoCacheMeta.entity';
import { TrekPhotoCacheService, CACHE_TTL } from '../../../src/nest/memories/trek-photo-cache.service';
import { StorageNotFoundError } from '../../../src/nest/storage/storage.types';
import { makeStorageFixture } from '../../helpers/storage-fixture';
const fx = makeStorageFixture('photos/trek/');
const svc = new TrekPhotoCacheService(new DatabaseService(testDb), fx.storage);
const testDb = createSnapshotTestDb();
let t: TestOrm;
let svc: TrekPhotoCacheService;
const CACHE_DIR = path.join(fx.root, 'photos/trek');
const binPath = (key: string) => path.join(CACHE_DIR, `${key}.bin`);
let counter = 0;
@@ -44,16 +35,17 @@ function freshKey(label: string): string {
return svc.cacheKey('test', `${label}-${counter++}`, 'thumbnail', 1);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
beforeAll(async () => {
t = await createTestOrm(testDb);
svc = new TrekPhotoCacheService(t.repo(TrekPhotoCacheMeta), fx.storage);
});
beforeEach(() => {
testDb.prepare('DELETE FROM trek_photo_cache_meta').run();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
fx.cleanup();
});
@@ -169,7 +161,7 @@ describe('the stampede guard', () => {
// Load-bearing: a per-instance map would hand any second instance a private
// guard and the dedup would be silently gone. The sweep cron injects the
// container singleton now, but the invariant stays pinned.
const other = new TrekPhotoCacheService(new DatabaseService(testDb), fx.storage);
const other = new TrekPhotoCacheService(t.repo(TrekPhotoCacheMeta), fx.storage);
const promise = Promise.resolve<Buffer | null>(null);
svc.setInFlight('shared-key', promise);
expect(other.getInFlight('shared-key')).toBe(promise);
@@ -1,44 +1,36 @@
/**
* TrekPhotosRepository — capture metadata (#1614).
* TrekPhotoRegistrationService (renamed from `TrekPhotosRepository`, Plan 3e
* R13 — collided with the generated ORM `TrekPhotos.repository.ts`) — PH1-11,
* now onto `TrekPhotosRepository`/`TripPhotosRepository` (the ORM ones).
*
* The three sources of a photo's "when and where" disagree in how much they know:
* a provider search answers with coordinates, the same provider's album listing
* does not, and a local file gives them up only once its EXIF has been read. These
* pin the merge rule that keeps a later, emptier answer from erasing an earlier one.
* The three sources of a photo's "when and where" disagree in how much they
* know: a provider search answers with coordinates, the same provider's
* album listing does not, and a local file gives them up only once its EXIF
* has been read (#1614). These pin the merge rule that keeps a later,
* emptier answer from erasing an earlier one, plus the rest of PH1-11.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createUser, createTrip } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { decrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
let repo: TrekPhotosRepository;
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TrekPhotoRegistrationService;
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
repo = new TrekPhotosRepository(new DatabaseService(testDb));
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), new DatabaseService(testDb, t.em));
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
// A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb
// leaves trek_photos alone, so a shared path would hand every test the same row.
@@ -47,13 +39,17 @@ async function makePhoto(): Promise<number> {
return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function rawRow(id: number): unknown {
return testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
}
function read(id: number) {
return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as {
taken_at: string | null; lat: number | null; lng: number | null;
};
}
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
describe('TrekPhotoRegistrationService.recordCaptureMetadata', () => {
it('TREKPHOTO-001: stores the capture time and coordinates', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
@@ -93,3 +89,144 @@ describe('TrekPhotosRepository.recordCaptureMetadata', () => {
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
});
describe('TrekPhotoRegistrationService.getOrCreate (PH1-3)', () => {
it('TREKPHOTO-010: registers a new remote asset', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'asset-1', user.id, undefined, 'video');
expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'asset-1', owner_id: user.id, media_type: 'video' });
});
it('TREKPHOTO-011: a repeat lookup returns the same id rather than inserting a duplicate row', async () => {
const { user } = createUser(testDb);
const first = await repo.getOrCreate('immich', 'asset-2', user.id);
const second = await repo.getOrCreate('immich', 'asset-2', user.id);
expect(second).toBe(first);
expect(testDb.prepare('SELECT COUNT(*) as c FROM trek_photos WHERE asset_id = ?').get('asset-2')).toEqual({ c: 1 });
});
it('TREKPHOTO-012 (PH2): a passphrase on an already-registered asset re-encrypts and replaces it', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'first-pass');
await repo.getOrCreate('synologyphotos', 'asset-3', user.id, 'second-pass');
const row = rawRow(id) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe('second-pass');
});
it('TREKPHOTO-013: a passphrase-free repeat lookup leaves the stored passphrase untouched', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('synologyphotos', 'asset-4', user.id, 'keep-me');
await repo.getOrCreate('synologyphotos', 'asset-4', user.id);
const row = rawRow(id) as { passphrase: string };
expect(decrypt_api_key(row.passphrase)).toBe('keep-me');
});
});
describe('TrekPhotoRegistrationService.getOrCreateLocal (PH4-5)', () => {
it('TREKPHOTO-020: registers a new local photo', async () => {
const id = await repo.getOrCreateLocal('journey/a.jpg', 'journey/thumbs/a.jpg', 800, 600, 'image', null);
expect(rawRow(id)).toMatchObject({
provider: 'local', file_path: 'journey/a.jpg', thumbnail_path: 'journey/thumbs/a.jpg', width: 800, height: 600,
});
});
it('TREKPHOTO-021: a repeat lookup on the same path returns the same id', async () => {
const first = await repo.getOrCreateLocal('journey/b.jpg');
const second = await repo.getOrCreateLocal('journey/b.jpg', 'ignored-thumb.jpg', 1, 1);
expect(second).toBe(first);
// The second call's fields never reach the row — getOrCreateLocal only inserts once.
expect(rawRow(first)).toMatchObject({ thumbnail_path: null, width: null, height: null });
});
});
describe('TrekPhotoRegistrationService.resolve (PH6) — parity with the legacy SELECT *', () => {
it('TREKPHOTO-030: a remote photo with capture metadata partially set matches the raw row byte-for-byte, full key set', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'asset-parity', user.id, 'a-passphrase', 'video');
await repo.recordCaptureMetadata(id, { takenAt: '2026-05-01T00:00:00Z', lat: null, lng: null });
const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
const resolved = await repo.resolve(id);
expect(resolved).toEqual(legacy);
});
it('TREKPHOTO-031: a local photo with a full coordinate pair matches the raw row byte-for-byte, full key set', async () => {
const id = await repo.getOrCreateLocal('journey/parity.jpg', 'journey/thumbs/parity.jpg', 800, 600, 'image', null);
await repo.recordCaptureMetadata(id, { takenAt: '2026-05-02T00:00:00Z', lat: 48.1, lng: 11.6 });
const legacy = testDb.prepare('SELECT * FROM trek_photos WHERE id = ?').get(id);
const resolved = await repo.resolve(id);
expect(resolved).toEqual(legacy);
});
it('TREKPHOTO-032: an unknown id resolves to null, not a thrown error', async () => {
expect(await repo.resolve(999999)).toBeNull();
});
});
describe('TrekPhotoRegistrationService.setProvider (PH7)', () => {
it('TREKPHOTO-040: retargets a local photo onto a provider once uploaded there', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreateLocal('journey/retarget.jpg');
await repo.setProvider(id, 'immich', 'new-asset', user.id);
expect(rawRow(id)).toMatchObject({ provider: 'immich', asset_id: 'new-asset', owner_id: user.id });
});
});
describe('TrekPhotoRegistrationService.recordLocalThumbnail (PH8)', () => {
it('TREKPHOTO-050: stamps a generated thumbnail and its dimensions', async () => {
const id = await repo.getOrCreateLocal('journey/thumb-src.jpg');
await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-src.jpg', 800, 600);
expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-src.jpg', width: 800, height: 600 });
});
it('TREKPHOTO-051: a re-generated thumbnail does not blank dimensions already known', async () => {
const id = await repo.getOrCreateLocal('journey/thumb-keep.jpg', null, 1200, 900);
await repo.recordLocalThumbnail(id, 'journey/thumbs/thumb-keep.jpg', 400, 300);
// COALESCE(width, ?) keeps the already-known 1200x900, not the new 400x300.
expect(rawRow(id)).toMatchObject({ thumbnail_path: 'journey/thumbs/thumb-keep.jpg', width: 1200, height: 900 });
});
});
describe('TrekPhotoRegistrationService.deleteIfOrphan (PH10-11)', () => {
it('TREKPHOTO-060: an unreferenced remote photo is deleted', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'orphan-1', user.id);
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeUndefined();
});
it('TREKPHOTO-061: a photo still referenced by trip_photos (3e-owned) is kept', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'referenced-1', user.id);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, id);
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
});
it('TREKPHOTO-062: a photo still referenced by journey_photos (Plan 3g, raw) is kept', async () => {
const { user } = createUser(testDb);
const id = await repo.getOrCreate('immich', 'referenced-2', user.id);
testDb.prepare("INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'J', 'draft', 0, 0)").run(user.id);
const journeyId = testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(user.id) as { id: number };
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId.id, id);
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
});
it('TREKPHOTO-063: an unreferenced LOCAL photo is never reclaimed here — its bytes are ours', async () => {
const id = await repo.getOrCreateLocal('journey/never-reclaimed.jpg');
await repo.deleteIfOrphan(id);
expect(rawRow(id)).toBeDefined();
});
});
@@ -77,7 +77,9 @@ import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-pho
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import {
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm,
@@ -136,7 +138,7 @@ beforeAll(async () => {
dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
new MapsService(dbs(), photoCache, await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection)), new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), dbs()), await createTestUnitOfWork(dbs().connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
await createTestPlacesRepo(dbs().connection),
+4 -2
View File
@@ -80,7 +80,9 @@ import { MapsService } from '../../../src/nest/maps/maps.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
@@ -173,7 +175,7 @@ beforeAll(async () => {
new QueryHelpersService(await createTestTagsRepo(dbs().connection), await createTestPlaceRatingsRepo(dbs().connection), await createTestAssignmentParticipantsRepo(dbs().connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs().connection), await createTestUsersRepo(dbs().connection), new RuntimeEnvService(), coversFx.storage),
photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotoRegistrationService(dbsEm!.getRepository(TrekPhotos), dbsEm!.getRepository(TripPhotos), dbs()), await createTestUnitOfWork(dbs().connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
await createTestPlacesRepo(dbs().connection),
@@ -46,7 +46,11 @@ import { ADDON_IDS } from '../../../src/addons';
import { createTestAddonsService } from '../../helpers/test-addons';
import { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { ImmichService } from '../../../src/nest/memories/immich.service';
import type { SynologyService } from '../../../src/nest/memories/synology.service';
@@ -79,14 +83,15 @@ beforeAll(async () => {
// only one of the four primitives this suite's code paths reach) is spied
// directly on this instance, routed to a real `DatabaseService` built
// with one.
const real = new DatabaseService(testDb, (await sharedTestOrm(testDb)).em);
const t = await sharedTestOrm(testDb);
const real = new DatabaseService(testDb, t.em);
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
new MemoriesAccessService(dbs, t.repo(TripPhotos), t.repo(TrekPhotos), t.repo(TripAlbumLinks), t.repo(Trips)),
notificationsStub(),
await createTestAddonsService(testDb, dbs),
await createTestUnitOfWork(testDb),
@@ -50,7 +50,9 @@ import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpe
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { notificationsStub } from '../../helpers/notifications';
import { makeStorageFixture } from '../../helpers/storage-fixture';
@@ -92,7 +94,8 @@ beforeAll(async () => {
// `beforeAll` can resolve a real `EntityManager` — the four
// repository-backed methods are spied directly on this instance instead,
// routed to a real `DatabaseService` built with one.
const real = new DatabaseService(testDb, (await sharedTestOrm(testDb)).em);
const t = await sharedTestOrm(testDb);
const real = new DatabaseService(testDb, t.em);
vi.spyOn(dbs, 'canAccessTrip').mockImplementation((...a) => real.canAccessTrip(...a));
vi.spyOn(dbs, 'isOwner').mockImplementation((...a) => real.isOwner(...a));
vi.spyOn(dbs, 'rosterUserIds').mockImplementation((...a) => real.rosterUserIds(...a));
@@ -106,7 +109,7 @@ beforeAll(async () => {
new QueryHelpersService(await createTestTagsRepo(dbs.connection), await createTestPlaceRatingsRepo(dbs.connection), await createTestAssignmentParticipantsRepo(dbs.connection)),
new UnsplashService(await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), runtimeEnv, makeStorageFixture('').storage),
photoCache,
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
new JourneyDomainService(dbs, realtime, new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
await createTestPlacesRepo(dbs.connection),
@@ -23,27 +23,34 @@ vi.mock('../../../src/config', () => ({
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photos.repository';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { db as trekDb } from '../../../src/db/database';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
// Was photos.bridge, deleted with the other three that had no consumer outside
// the container. These call the repository directly now.
const trekPhotos = new TrekPhotosRepository(new DatabaseService(trekDb));
const getOrCreateTrekPhoto = (...a: Parameters<TrekPhotosRepository['getOrCreate']>) => trekPhotos.getOrCreate(...a);
const getOrCreateLocalTrekPhoto = (...a: Parameters<TrekPhotosRepository['getOrCreateLocal']>) => trekPhotos.getOrCreateLocal(...a);
let t: TestOrm;
let trekPhotos: TrekPhotoRegistrationService;
const getOrCreateTrekPhoto = (...a: Parameters<TrekPhotoRegistrationService['getOrCreate']>) => trekPhotos.getOrCreate(...a);
const getOrCreateLocalTrekPhoto = (...a: Parameters<TrekPhotoRegistrationService['getOrCreateLocal']>) => trekPhotos.getOrCreateLocal(...a);
const resolveTrekPhoto = (id: number) => trekPhotos.resolve(id);
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await createTestOrm(testDb);
trekPhotos = new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), new DatabaseService(trekDb, t.em));
});
beforeEach(() => {
testDb.prepare('DELETE FROM trek_photos').run();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});