diff --git a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts index 4c2980677..02395e1d1 100644 --- a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts +++ b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts @@ -1,4 +1,48 @@ import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class TrekPhotoCacheMetaRepository extends TrekRepository {} +/** TC1's row — `content_type`/`fetched_at`, the two columns `getFresh` needs. */ +export interface TrekPhotoCacheFreshness { + content_type: string; + fetched_at: number; +} + +export class TrekPhotoCacheMetaRepository extends TrekRepository { + /** TC1 — `SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?`. */ + async findFreshness(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['content_type', 'fetched_at'] }); + return row ? { content_type: row.content_type, fetched_at: row.fetched_at } : null; + } + + /** + * TC2/TC3/TC6 — `DELETE FROM trek_photo_cache_meta WHERE cache_key = ?`, + * the same statement text at three call sites (the TTL-expired branch, the + * object-missing branch, and `sweepExpired`'s stale pass) — one method (D4). + */ + async deleteByCacheKey(cache_key: string): Promise { + return await this.nativeDelete({ cache_key }); + } + + /** + * TC4 (`put`) — `INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, + * content_type, fetched_at) VALUES (?, ?, ?)`. `cache_key` is the table's + * only unique/primary column (a genuine nullable TEXT PK per R5's Task 0 + * finding — not a bug, and not touched here), so it is the sole conflict + * target, same upsert shape as `AppSettingsRepository.setValue`. + */ + async upsertMeta(cache_key: string, content_type: string, fetched_at: number): Promise { + await this.upsert({ cache_key, content_type, fetched_at }, { onConflictFields: ['cache_key'], onConflictAction: 'merge' }); + } + + /** TC5 — `SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?`, `sweepExpired`'s stale-row pass. */ + async listStale(cutoff: number): Promise { + const rows = await this.find({ fetched_at: { $lt: cutoff } }, { fields: ['cache_key'] }); + return rows.map((row) => row.cache_key).filter((key): key is string => key != null); + } + + /** TC7 — `SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?`, `sweepExpired`'s pass-2 row-less-object guard. */ + async existsByCacheKey(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['cache_key'] }); + return !!row; + } +} diff --git a/server/src/db/repositories/TrekPhotos.repository.ts b/server/src/db/repositories/TrekPhotos.repository.ts index ff32c18a5..8a9ab9fcb 100644 --- a/server/src/db/repositories/TrekPhotos.repository.ts +++ b/server/src/db/repositories/TrekPhotos.repository.ts @@ -1,4 +1,141 @@ +import type { TrekPhoto } from '../../types'; import type { TrekPhotos } from '../entities/TrekPhotos.entity'; +import { coalesceParam } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; -export class TrekPhotosRepository extends TrekRepository {} +/** + * `SELECT * FROM trek_photos WHERE id = ?`'s row (PH6/MA4 — the same + * statement text, one method serving both `TrekPhotoRegistrationService + * .resolve` and `MemoriesAccessService.canAccessTrekPhoto`'s entry read). + * Reuses the pre-existing `TrekPhoto` type from `../../types` (the row shape + * every consumer already imports) rather than a second, parallel row + * interface — its key set matches the entity's scalar columns exactly + * (`owner` is the hidden relation, `owner_id` its persist(false) twin). + */ +const _trekPhotoRowKeys: AssertRowKeys = true; + +export class TrekPhotosRepository extends TrekRepository { + /** PH1's SELECT half — `getOrCreate`'s existing-row lookup. */ + async findByProviderAsset(provider: string, asset_id: string, owner_id: number): Promise<{ id: number } | null> { + const row = await this.findOne({ provider, asset_id, owner: owner_id }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH2 — conditional passphrase re-encrypt on an already-registered remote photo. */ + async setPassphrase(id: number, passphrase: string): Promise { + await this.nativeUpdate({ id }, { passphrase }); + } + + /** PH3 — `INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type)`. */ + async insertRemote(data: { + provider: string; + asset_id: string; + owner_id: number; + passphrase: string | null; + media_type: string; + }): Promise { + return await this.insert({ + provider: data.provider, + asset_id: data.asset_id, + owner: data.owner_id, + passphrase: data.passphrase, + media_type: data.media_type, + }); + } + + /** PH4's SELECT half — `getOrCreateLocal`'s existing-row lookup. */ + async findLocalByPath(file_path: string): Promise<{ id: number } | null> { + const row = await this.findOne({ provider: 'local', file_path }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH5 — `INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms)`, always `provider = 'local'`. */ + async insertLocal(data: { + file_path: string; + thumbnail_path: string | null; + width: number | null; + height: number | null; + media_type: string; + duration_ms: number | null; + }): Promise { + return await this.insert({ + provider: 'local', + file_path: data.file_path, + thumbnail_path: data.thumbnail_path, + width: data.width, + height: data.height, + media_type: data.media_type, + duration_ms: data.duration_ms, + }); + } + + /** + * PH6 (`TrekPhotoRegistrationService.resolve`) / MA4 (`MemoriesAccessService + * .canAccessTrekPhoto`'s entry read) — byte-identical `SELECT * FROM + * trek_photos WHERE id = ?`, one method for both call sites (D4). + */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as TrekPhoto) : null; + } + + /** PH7 — retarget a local photo onto a provider once it has been uploaded there. */ + async retarget(id: number, provider: string, asset_id: string, owner_id: number): Promise { + await this.nativeUpdate({ id }, { provider, asset_id, owner: owner_id }); + } + + /** + * PH8 — stamp a generated local thumbnail. `COALESCE(width, ?)`/ + * `COALESCE(height, ?)` keep dimensions a re-generated thumbnail already + * knew (the caller-side guard for "don't blank what's known" stays on the + * service, same as legacy). + */ + async patchThumbnail(id: number, thumbnail_path: string, width: number, height: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + thumbnail_path, + width: coalesceParam(platform, 'width', width), + height: coalesceParam(platform, 'height', height), + }); + } + + /** + * PH9 — `COALESCE`d capture metadata write. The service computes the final + * `taken_at`/`lat`/`lng` (the all-empty early return, the pair-or-neither + * coordinate guard) before calling this; this method only issues the + * three-column `COALESCE(col, ?)` UPDATE unconditionally. + */ + async patchCaptureMetadata(id: number, taken_at: string | null, lat: number | null, lng: number | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + taken_at: coalesceParam(platform, 'taken_at', taken_at), + lat: coalesceParam(platform, 'lat', lat), + lng: coalesceParam(platform, 'lng', lng), + }); + } + + /** PH11 — `DELETE FROM trek_photos WHERE id = ? AND provider != 'local'`; local photos are never auto-reclaimed here. */ + async deleteNonLocal(id: number): Promise { + return await this.nativeDelete({ id, provider: { $ne: 'local' } }); + } + + /** + * TH1 — `SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'`. + * The caller (`ThumbnailService.sweepOrphanThumbs`) always passes the + * literal `'journey/'` prefix; `$like` binds the wildcard-appended value as + * a genuine parameter (no dialect helper needed — a plain `LIKE` with a + * hardcoded prefix, no injection surface). Rows with a NULL `file_path` + * never match `LIKE`, so every returned value is a real string. + */ + async listByFilePathPrefix(prefix: string): Promise { + const rows = await this.find({ file_path: { $like: `${prefix}%` } }, { fields: ['file_path'] }); + return rows.map((row) => row.file_path).filter((path): path is string => path != null); + } + + /** TH2 — `SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'`, same shape as TH1 on the sibling column. */ + async listByThumbnailPathPrefix(prefix: string): Promise { + const rows = await this.find({ thumbnail_path: { $like: `${prefix}%` } }, { fields: ['thumbnail_path'] }); + return rows.map((row) => row.thumbnail_path).filter((path): path is string => path != null); + } +} diff --git a/server/src/db/repositories/TripAlbumLinks.repository.ts b/server/src/db/repositories/TripAlbumLinks.repository.ts index b7cc49e27..f89e4f21c 100644 --- a/server/src/db/repositories/TripAlbumLinks.repository.ts +++ b/server/src/db/repositories/TripAlbumLinks.repository.ts @@ -1,4 +1,39 @@ import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; import { TrekRepository } from './_shared/trek-repository'; -export class TripAlbumLinksRepository extends TrekRepository {} +/** MA7/MA8's shared row shape — album_id always needed, passphrase only by the sync path (MA8). */ +export interface TripAlbumLinkSyncRow { + album_id: string; + passphrase: string | null; +} + +export class TripAlbumLinksRepository extends TrekRepository { + /** + * MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same + * user-scoped WHERE, different SELECT list in the legacy text + * (`album_id` alone vs. `album_id, passphrase`); one method serving both, + * per the inventory's own proposal, since MA7's caller simply ignores the + * extra `passphrase` column. `SELECT album_id, passphrase FROM + * trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?` — note + * this is USER-scoped, not just trip-scoped: a link belongs to the user + * who created it, not the whole trip. + */ + async findScoped(id: number, trip_id: number, user_id: number): Promise { + const row = await this.findOne({ id, trip: trip_id, user: user_id }, { fields: ['album_id', 'passphrase'] }); + return row ? { album_id: row.album_id, passphrase: row.passphrase ?? null } : null; + } + + /** + * MA9 — `UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP + * WHERE id = ?`. Deliberately unscoped by trip/user, matching the legacy + * statement: the caller (`syncImmichAlbum`/`syncSynologyAlbum`) has + * already resolved `linkId` through `findScoped` (MA7/MA8) earlier in the + * same request, so this write relies entirely on that prior resolution — + * not a defect to fix here (see the class docstring on `MemoriesAccessService`). + */ + async touchSyncTime(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) }); + } +} diff --git a/server/src/db/repositories/TripPhotos.repository.ts b/server/src/db/repositories/TripPhotos.repository.ts index 8cc7659f2..0d839e3b9 100644 --- a/server/src/db/repositories/TripPhotos.repository.ts +++ b/server/src/db/repositories/TripPhotos.repository.ts @@ -1,4 +1,54 @@ import type { TripPhotos } from '../entities/TripPhotos.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class TripPhotosRepository extends TrekRepository {} +export class TripPhotosRepository extends TrekRepository { + /** + * PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ? + * LIMIT 1`, used by `TrekPhotoRegistrationService.deleteIfOrphan` alongside + * the still-raw `journey_photos` existence check (`// PH10 — Plan 3g`). + */ + async existsForPhoto(photo_id: number): Promise { + const row = await this.findOne({ photo: photo_id }, { fields: ['id'] }); + return !!row; + } + + /** + * MA3 — the regular-trip branch of `MemoriesAccessService.canAccessUserPhoto`: + * is this provider asset shared by this exact user into this exact trip? + * `SELECT 1 FROM trip_photos tp JOIN trek_photos tkp ON tkp.id = tp.photo_id + * WHERE tp.user_id = ? AND tkp.asset_id = ? AND tkp.provider = ? AND + * tp.trip_id = ? AND tp.shared = 1 LIMIT 1`. + */ + async existsSharedForUser(trip_id: number, user_id: number, asset_id: string, provider: string): Promise { + const row = await this.qb('tp') + .select(['tp.id']) + .join('tp.photo', 'tkp') + .where({ 'tp.user': user_id, 'tp.trip': trip_id, 'tp.shared': 1, 'tkp.asset_id': asset_id, 'tkp.provider': provider }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * MA5's source data — every trip a photo is shared into (`shared = 1`), + * for `MemoriesAccessService.canAccessTrekPhoto` to check each one against + * `TripsRepository.findAccessible` (R4: reuse the existing accessibility + * builder rather than hand-translating the legacy `EXISTS(...UNION + * ALL...)` predicate into a second, independent form). Legacy statement: + * `SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1 + * AND EXISTS(...trip_members/trips...) LIMIT 1` — the `EXISTS` half is + * exactly `findAccessible`'s own predicate, so this method hands back the + * candidate trip ids and the caller does the short-circuiting `OR` over + * `findAccessible`'s results instead. + * + * Full-row `find()`, not a `fields: ['trip_id']` projection: `trip_id` is a + * `persist(false)` scalar twin of the `trip` relation, and a narrowed + * `fields` selection silently drops it from the hydrated result (the same + * bare-`persist(false)`-select trap `RoadtripDayBoundariesRepository` + * documents — verified empirically here too, not assumed) — only an + * unrestricted `find()` (or `columnRef` through a `qb()`) hydrates it. + */ + async listTripIdsSharedForPhoto(photo_id: number): Promise { + const rows = await this.find({ photo: photo_id, shared: 1 }); + return rows.map((row) => row.trip_id); + } +} diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index e88d578cd..bebad2045 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -7,7 +7,7 @@ import { DatabaseService } from '../database/database.service'; import { UnitOfWork } from '../database/unit-of-work'; import { RealtimeService } from '../realtime/realtime.service'; import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared'; -import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository'; import { getCountryFromCoords } from '../atlas/atlas-geo'; import { computeJourneyStats, type StatsInputPoint } from './journey-stats'; @@ -94,7 +94,7 @@ export class JourneyDomainService { constructor( private readonly db: DatabaseService, private readonly realtime: RealtimeService, - private readonly photos: TrekPhotosRepository, + private readonly photos: TrekPhotoRegistrationService, private readonly uow: UnitOfWork, ) {} diff --git a/server/src/nest/memories/memories-access.service.ts b/server/src/nest/memories/memories-access.service.ts index e198c532f..68faa85ae 100644 --- a/server/src/nest/memories/memories-access.service.ts +++ b/server/src/nest/memories/memories-access.service.ts @@ -1,7 +1,17 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { DatabaseService } from '../database/database.service'; import { fail, success, type ServiceResult } from './memories.helpers'; +import { toRowId } from '../common/row-id'; +import { TripPhotos } from '../../db/entities/TripPhotos.entity'; +import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository'; +import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'; +import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository'; +import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity'; +import type { TripAlbumLinksRepository } from '../../db/repositories/TripAlbumLinks.repository'; +import { Trips } from '../../db/entities/Trips.entity'; +import type { TripsRepository } from '../../db/repositories/Trips.repository'; /** * Who may see which photo, and the album-link lookups the provider syncs need. @@ -10,10 +20,25 @@ import { fail, success, type ServiceResult } from './memories.helpers'; * access checks answer for photos that live under a trip *or* a journey — a * provider asset is reachable through either, which is why neither check can * live in the trips or journey domain alone. + * + * Stays a service, not a single-table repository (Plan 3e): it composes + * access logic across `trip_photos`/`trek_photos`/`trip_album_links` + * (this plan's own tables) and `journeys`/`journey_contributors`/ + * `journey_photos` (Plan 3g's, not yet converted — every read against them + * below stays raw, marked `// — Plan 3g`). `DatabaseService` stays + * injected for exactly those raw reads and for the `canAccessTrip` primitive + * (a cross-cutting primitive, not a per-domain SQL statement this plan + * converts). */ @Injectable() export class MemoriesAccessService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + @InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository, + @InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository, + @InjectRepository(TripAlbumLinks) private readonly tripAlbumLinks: TripAlbumLinksRepository, + @InjectRepository(Trips) private readonly trips: TripsRepository, + ) {} async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise { if (requestingUserId === ownerUserId) { @@ -22,6 +47,7 @@ export class MemoriesAccessService { // Journey photos use tripId=0 — check journey_photos + journey_contributors if (tripId === '0') { + // MA1 — Plan 3g (journey_photos is not yet an owned repository table). const journeyPhoto = this.db.get<{ journey_id: number }>(` SELECT gp.journey_id FROM journey_photos gp @@ -33,6 +59,7 @@ export class MemoriesAccessService { `, assetId, provider, ownerUserId); if (!journeyPhoto) return false; + // MA2 — Plan 3g (journeys/journey_contributors are not yet owned repository tables). const access = this.db.get(` SELECT 1 FROM journeys WHERE id = ? AND user_id = ? UNION ALL @@ -42,18 +69,11 @@ export class MemoriesAccessService { return !!access; } - // Regular trip photos — join through trek_photos - const sharedAsset = this.db.get(` - SELECT 1 - FROM trip_photos tp - JOIN trek_photos tkp ON tkp.id = tp.photo_id - WHERE tp.user_id = ? - AND tkp.asset_id = ? - AND tkp.provider = ? - AND tp.trip_id = ? - AND tp.shared = 1 - LIMIT 1 - `, ownerUserId, assetId, provider, tripId); + // Regular trip photos — join through trek_photos (MA3). + const tripIdNum = toRowId(tripId); + const sharedAsset = tripIdNum != null + ? await this.tripPhotos.existsSharedForUser(tripIdNum, ownerUserId, assetId, provider) + : false; if (!sharedAsset) { return false; @@ -64,27 +84,23 @@ export class MemoriesAccessService { // ── Unified photo access check (trek_photos based) ────────────────────── async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise { - const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId); + const photo = await this.trekPhotos.findById(trekPhotoId); if (!photo) return false; // Owner always has access if (photo.owner_id === requestingUserId) return true; // Check trip_photos — is this photo shared in a trip the user has access to? - const tripAccess = this.db.get(` - 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 - `, trekPhotoId, requestingUserId, requestingUserId); - if (tripAccess) return true; + // MA5: the legacy `EXISTS(...trip_members/trips UNION ALL...)` predicate + // is exactly `TripsRepository.findAccessible`'s own — reuse that builder + // (R4) instead of hand-translating it into a second, independent form. + const sharedTripIds = await this.tripPhotos.listTripIdsSharedForPhoto(trekPhotoId); + for (const tripId of sharedTripIds) { + if (await this.trips.findAccessible(tripId, requestingUserId)) return true; + } // Check journey_photos — is this photo in a journey the user can access? + // MA6 — Plan 3g (journey_photos/journeys/journey_contributors are not yet owned repository tables). const journeyAccess = this.db.get(` SELECT 1 FROM journey_photos gp WHERE gp.photo_id = ? @@ -112,10 +128,13 @@ export class MemoriesAccessService { if (!access) return fail('Trip not found or access denied', 404); try { - const row = this.db.get<{ album_id: string }>( - 'SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?', - linkId, tripId, userId, - ); + // MA7: `toRowId` on `tripId`/`linkId` — both already validated as a + // reachable trip by `canAccessTrip` above; a non-canonical spelling of + // either (rule 15's accepted narrowing) answers the same "not found" + // the legacy raw-bind affinity would have for a row it couldn't match. + const id = toRowId(linkId); + const tripIdNum = toRowId(tripId); + const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null; return row ? success(row.album_id) : fail('Album link not found', 404); } catch { @@ -128,10 +147,10 @@ export class MemoriesAccessService { if (!access) return fail('Trip not found or access denied', 404); try { - const row = this.db.get<{ album_id: string; passphrase: string | null }>( - 'SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?', - linkId, tripId, userId, - ); + // MA8 — same `toRowId` treatment as MA7 above. + const id = toRowId(linkId); + const tripIdNum = toRowId(tripId); + const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null; if (!row) return fail('Album link not found', 404); @@ -143,6 +162,13 @@ export class MemoriesAccessService { } async updateSyncTimeForAlbumLink(linkId: string): Promise { - this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId); + // MA9: unscoped by trip/user, matching the legacy statement — the caller + // has already resolved `linkId` through `getAlbumIdFromLink`/ + // `getAlbumLinkForSync` (MA7/MA8) earlier in the same request. A + // non-canonical id (rule 15) is a no-op here, same as a legacy UPDATE + // that matched zero rows. + const id = toRowId(linkId); + if (id == null) return; + await this.tripAlbumLinks.touchSyncTime(id); } } diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts index 066abe471..6e7ff6eca 100644 --- a/server/src/nest/memories/memories.module.ts +++ b/server/src/nest/memories/memories.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; import { MemoriesService } from './memories.service'; import { MemoriesAccessService } from './memories-access.service'; import { ImmichService } from './immich.service'; @@ -25,6 +26,11 @@ import { ImmichPhotoProvider } from './providers/immich.provider'; import { SynologyPhotoProvider } from './providers/synology.provider'; import { NotificationsModule } from '../notifications/notifications.module'; import { StorageModule } from '../storage/storage.module'; +import { TripPhotos } from '../../db/entities/TripPhotos.entity'; +import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'; +import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity'; +import { Trips } from '../../db/entities/Trips.entity'; +import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity'; /** * Memories (photo-providers) domain — mounted at /api/integrations/memories. @@ -45,9 +51,19 @@ import { StorageModule } from '../storage/storage.module'; * (#584): adding a photo backend means adding an adapter to this one list, not * finding every `switch (photo.provider)`. Registered here rather than in the * adapters themselves so the set is readable in one place. + * + * `MikroOrmModule.forFeature` registers `TripPhotosRepository`/ + * `TrekPhotosRepository`/`TripAlbumLinksRepository`/`TripsRepository` + * (owned elsewhere, reached the same way every cross-domain module in this + * program reaches another domain's entity) for `MemoriesAccessService`'s + * `@InjectRepository` constructor params, and `TrekPhotoCacheMetaRepository` + * for `TrekPhotoCacheService`'s (Plan 3e Task 6). */ @Module({ - imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule], + imports: [ + NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule, + MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta]), + ], controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController], providers: [ MemoriesService, diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index b92acc2c8..31b31269a 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import exifr from 'exifr'; import { PhotoResolverService } from './photo-resolver.service'; import { StorageService } from '../storage/storage.service'; -import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository'; /** * Ask the provider when and where a photo was taken, and record it (#1614). @@ -21,7 +21,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository'; export class PhotoCaptureBackfillService { constructor( private readonly resolver: PhotoResolverService, - private readonly photos: TrekPhotosRepository, + private readonly photos: TrekPhotoRegistrationService, private readonly storage: StorageService, ) {} diff --git a/server/src/nest/memories/photo-resolver.service.ts b/server/src/nest/memories/photo-resolver.service.ts index f96d125bb..70f292580 100644 --- a/server/src/nest/memories/photo-resolver.service.ts +++ b/server/src/nest/memories/photo-resolver.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Response } from 'express'; import type { TrekPhoto } from '../../types'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; -import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository'; import { ThumbnailService } from './thumbnail.service'; import { TrekPhotoCacheService } from './trek-photo-cache.service'; import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers'; @@ -23,7 +23,7 @@ import { StorageService } from '../storage/storage.service'; @Injectable() export class PhotoResolverService { constructor( - private readonly photos: TrekPhotosRepository, + private readonly photos: TrekPhotoRegistrationService, private readonly thumbnails: ThumbnailService, private readonly cache: TrekPhotoCacheService, private readonly providers: PhotoProviderRegistry, diff --git a/server/src/nest/memories/thumbnail.service.ts b/server/src/nest/memories/thumbnail.service.ts index b0cce8df4..05d72d573 100644 --- a/server/src/nest/memories/thumbnail.service.ts +++ b/server/src/nest/memories/thumbnail.service.ts @@ -2,10 +2,12 @@ import { Jimp } from 'jimp' import path from 'node:path' import crypto from 'node:crypto' import { Injectable } from '@nestjs/common' +import { InjectRepository } from '@mikro-orm/nestjs' import { ADDON_IDS } from '../../addons' import { AddonsService } from '../addons/addons.service' -import { DatabaseService } from '../database/database.service' import { StorageService } from '../storage/storage.service' +import { TrekPhotos } from '../../db/entities/TrekPhotos.entity' +import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository' const THUMB_MAX = 800 const THUMB_QUALITY = 80 @@ -31,7 +33,7 @@ export class ThumbnailService { constructor( private readonly addons: AddonsService, private readonly storage: StorageService, - private readonly db: DatabaseService, + @InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository, ) {} async ensureLocalThumbnail( @@ -91,15 +93,13 @@ export class ThumbnailService { if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0 const live = new Set() - for (const row of this.db.all<{ file_path: string }>( - "SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'", - )) { - live.add(journeyThumbName(row.file_path)) + // TH1 + for (const filePath of await this.trekPhotos.listByFilePathPrefix('journey/')) { + live.add(journeyThumbName(filePath)) } - for (const row of this.db.all<{ thumbnail_path: string }>( - "SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'", - )) { - live.add(row.thumbnail_path.slice('journey/'.length)) + // TH2 + for (const thumbnailPath of await this.trekPhotos.listByThumbnailPathPrefix('journey/thumbs/')) { + live.add(thumbnailPath.slice('journey/'.length)) } let removed = 0 diff --git a/server/src/nest/memories/trek-photo-cache.service.ts b/server/src/nest/memories/trek-photo-cache.service.ts index 6f3470738..fa627c08f 100644 --- a/server/src/nest/memories/trek-photo-cache.service.ts +++ b/server/src/nest/memories/trek-photo-cache.service.ts @@ -1,10 +1,12 @@ import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { Response } from 'express'; -import { DatabaseService } from '../database/database.service'; import { StorageService } from '../storage/storage.service'; import { StorageNotFoundError } from '../storage/storage.types'; +import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity'; +import type { TrekPhotoCacheMetaRepository } from '../../db/repositories/TrekPhotoCacheMeta.repository'; export const CACHE_TTL = 60 * 60 * 1000; // 1 hour @@ -30,7 +32,7 @@ function objectName(key: string): string { @Injectable() export class TrekPhotoCacheService { constructor( - private readonly db: DatabaseService, + @InjectRepository(TrekPhotoCacheMeta) private readonly cacheMeta: TrekPhotoCacheMetaRepository, private readonly storage: StorageService, ) {} @@ -39,19 +41,18 @@ export class TrekPhotoCacheService { } async getFresh(key: string): Promise<{ contentType: string } | null> { - const row = this.db.get<{ content_type: string; fetched_at: number }>( - 'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?', key, - ); + // TC1 + const row = await this.cacheMeta.findFreshness(key); if (!row) return null; if (Date.now() - row.fetched_at >= CACHE_TTL) { - this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key); + await this.cacheMeta.deleteByCacheKey(key); // TC2 return null; } if (!(await this.storage.exists('photos-trek', objectName(key)))) { - this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key); + await this.cacheMeta.deleteByCacheKey(key); // TC3 return null; } @@ -61,10 +62,8 @@ export class TrekPhotoCacheService { async put(key: string, bytes: Buffer, contentType: string): Promise { await this.storage.put('photos-trek', objectName(key), Readable.from(bytes)); - this.db.run( - 'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)', - key, contentType, Date.now(), - ); + // TC4 + await this.cacheMeta.upsertMeta(key, contentType, Date.now()); } async serveFresh(res: Response, key: string): Promise { @@ -99,13 +98,11 @@ export class TrekPhotoCacheService { async sweepExpired(): Promise { const cutoff = Date.now() - CACHE_TTL * 2; - const stale = this.db.all<{ cache_key: string }>( - 'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?', cutoff, - ); + const stale = await this.cacheMeta.listStale(cutoff); // TC5 - for (const row of stale) { - this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key); - await this.storage.delete('photos-trek', objectName(row.cache_key)); + for (const cacheKey of stale) { + await this.cacheMeta.deleteByCacheKey(cacheKey); // TC6 + await this.storage.delete('photos-trek', objectName(cacheKey)); } // Pass 2 (fix #4, spec rev 3.2): getFresh's expiry path deletes the meta @@ -116,8 +113,8 @@ export class TrekPhotoCacheService { for await (const stat of this.storage.list('photos-trek')) { if (stat.key.includes('/') || !stat.key.endsWith('.bin') || stat.mtimeMs >= cutoff) continue; const key = stat.key.slice(0, -'.bin'.length); - const row = this.db.get('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?', key); - if (!row) { + const exists = await this.cacheMeta.existsByCacheKey(key); // TC7 + if (!exists) { await this.storage.delete('photos-trek', stat.key).catch(() => { /* race */ }); } } diff --git a/server/src/nest/memories/unified-memories.service.ts b/server/src/nest/memories/unified-memories.service.ts index 45894361a..4da14b8b0 100644 --- a/server/src/nest/memories/unified-memories.service.ts +++ b/server/src/nest/memories/unified-memories.service.ts @@ -4,7 +4,7 @@ import { broadcast } from '../../websocket'; import { encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { AddonsService } from '../addons/addons.service'; import { DatabaseService } from '../database/database.service'; -import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository'; import { ImmichService } from './immich.service'; import { SynologyService } from './synology.service'; import { MemoriesAccessService } from './memories-access.service'; @@ -29,7 +29,7 @@ import { UnitOfWork } from '../database/unit-of-work'; export class UnifiedMemoriesService { constructor( private readonly db: DatabaseService, - private readonly photos: TrekPhotosRepository, + private readonly photos: TrekPhotoRegistrationService, private readonly immich: ImmichService, private readonly synology: SynologyService, private readonly access: MemoriesAccessService, diff --git a/server/src/nest/photos/trek-photos.module.ts b/server/src/nest/photos/trek-photos.module.ts index f0551fd14..8f31559fb 100644 --- a/server/src/nest/photos/trek-photos.module.ts +++ b/server/src/nest/photos/trek-photos.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common'; -import { TrekPhotosRepository } from './trek-photos.repository'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { TrekPhotoRegistrationService } from './trek-photos.repository'; +import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'; +import { TripPhotos } from '../../db/entities/TripPhotos.entity'; /** * The trek_photos store on its own, so both halves can have it without @@ -10,9 +13,14 @@ import { TrekPhotosRepository } from './trek-photos.repository'; * bytes. That is a genuine mutual need, and a module this small breaks it * without a forwardRef: PhotosModule -> MemoriesModule -> TrekPhotosModule is * a straight line. + * + * `MikroOrmModule.forFeature` registers `TrekPhotosRepository`/ + * `TripPhotosRepository` (the ORM ones) for `TrekPhotoRegistrationService`'s + * `@InjectRepository` constructor params (Plan 3e Task 6). */ @Module({ - providers: [TrekPhotosRepository], - exports: [TrekPhotosRepository], + imports: [MikroOrmModule.forFeature([TrekPhotos, TripPhotos])], + providers: [TrekPhotoRegistrationService], + exports: [TrekPhotoRegistrationService], }) export class TrekPhotosModule {} diff --git a/server/src/nest/photos/trek-photos.repository.ts b/server/src/nest/photos/trek-photos.repository.ts index 76df4df3a..188ae9381 100644 --- a/server/src/nest/photos/trek-photos.repository.ts +++ b/server/src/nest/photos/trek-photos.repository.ts @@ -1,7 +1,12 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; import { encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { DatabaseService } from '../database/database.service'; import type { TrekPhoto } from '../../types'; +import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'; +import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository'; +import { TripPhotos } from '../../db/entities/TripPhotos.entity'; +import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository'; /** * The `trek_photos` table: register a photo, look one up, retarget it, drop it @@ -16,11 +21,24 @@ import type { TrekPhoto } from '../../types'; * * It also draws the photos/ vs memories/ line: storage lives here, provider * dispatch stays with memories. + * + * Renamed from `TrekPhotosRepository` (Plan 3e R13): despite the name, it was + * never a MikroORM repository — a `DatabaseService`-backed service that ran + * raw SQL directly — and the old name collided with the generated ORM + * repository this class now injects (`../../db/repositories/TrekPhotos + * .repository.ts`, also `TrekPhotosRepository`, per the `EntityRepositoryType` + * convention every entity in this program follows). Same shape as the 3d + * §18.3 `ReservationsReadRepository` → `ReservationsReadService` precedent. */ @Injectable() -export class TrekPhotosRepository { - constructor(private readonly db: DatabaseService) {} +export class TrekPhotoRegistrationService { + constructor( + @InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository, + @InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository, + private readonly db: DatabaseService, + ) {} + /** PH1 — find-or-register a remote provider asset, re-encrypting a changed album passphrase on an existing row. */ async getOrCreate( provider: string, assetId: string, @@ -28,24 +46,24 @@ export class TrekPhotosRepository { passphrase?: string, mediaType: string = 'image', ): Promise { - const existing = this.db.get<{ id: number }>( - 'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?', - provider, assetId, ownerId, - ); + const existing = await this.trekPhotos.findByProviderAsset(provider, assetId, ownerId); if (existing) { if (passphrase) { - this.db.run('UPDATE trek_photos SET passphrase = ? WHERE id = ?', encrypt_api_key(passphrase), existing.id); + await this.trekPhotos.setPassphrase(existing.id, encrypt_api_key(passphrase)); } return existing.id; } - const res = this.db.run( - 'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type) VALUES (?, ?, ?, ?, ?)', - provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null, mediaType, - ); - return Number(res.lastInsertRowid); + return await this.trekPhotos.insertRemote({ + provider, + asset_id: assetId, + owner_id: ownerId, + passphrase: passphrase ? encrypt_api_key(passphrase) : null, + media_type: mediaType, + }); } + /** PH4/PH5 — find-or-register a locally uploaded photo, keyed on its stored path. */ async getOrCreateLocal( filePath: string, thumbnailPath?: string | null, @@ -54,43 +72,40 @@ export class TrekPhotosRepository { mediaType: string = 'image', durationMs?: number | null, ): Promise { - const existing = this.db.get<{ id: number }>( - "SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath, - ); + const existing = await this.trekPhotos.findLocalByPath(filePath); if (existing) return existing.id; - const res = this.db.run( - 'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)', - 'local', filePath, thumbnailPath || null, width || null, height || null, mediaType, durationMs ?? null, - ); - return Number(res.lastInsertRowid); + return await this.trekPhotos.insertLocal({ + file_path: filePath, + thumbnail_path: thumbnailPath || null, + width: width || null, + height: height || null, + media_type: mediaType, + duration_ms: durationMs ?? null, + }); } + /** PH6 — `SELECT * FROM trek_photos WHERE id = ?`, the same statement `MemoriesAccessService.canAccessTrekPhoto` (MA4) reads through `TrekPhotosRepository.findById`. */ async resolve(photoId: number): Promise { - return this.db.get('SELECT * FROM trek_photos WHERE id = ?', photoId) || null; + return await this.trekPhotos.findById(photoId); } - /** Retarget an existing row — used when a local photo is uploaded to Immich. */ + /** PH7 — retarget an existing row, used when a local photo is uploaded to Immich. */ async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise { - this.db.run( - 'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?', - provider, assetId, ownerId, trekPhotoId, - ); + await this.trekPhotos.retarget(trekPhotoId, provider, assetId, ownerId); } /** - * Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions - * that were already known — a re-generated thumbnail must not blank them. + * PH8 — stamp a generated local thumbnail onto the row. COALESCE keeps + * dimensions that were already known — a re-generated thumbnail must not + * blank them. */ async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise { - this.db.run( - 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?', - thumbnailPath, width, height, photoId, - ); + await this.trekPhotos.patchThumbnail(photoId, thumbnailPath, width, height); } /** - * Where and when the picture was taken (#1614). + * PH9 — where and when the picture was taken (#1614). * * COALESCE throughout, for the same reason recordLocalThumbnail does it: the * three sources disagree in how much they know. A provider search answers with @@ -98,7 +113,10 @@ export class TrekPhotosRepository { * only gives them up once its EXIF has been read. Whichever arrives first wins, * and a later, emptier answer must not blank what is already there. * - * A photo with neither is the normal case, not a failure. + * A photo with neither is the normal case, not a failure. The all-empty + * early return and the pair-or-neither coordinate guard both stay here — + * the repository's `patchCaptureMetadata` issues the COALESCE write + * unconditionally once called. */ async recordCaptureMetadata( photoId: number, @@ -109,28 +127,26 @@ export class TrekPhotosRepository { // Coordinates are stored as a pair or not at all — a lone latitude is not a // place, and half a pair would put the photo on the null island. const hasPair = Number.isFinite(lat) && Number.isFinite(lng); - this.db.run( - `UPDATE trek_photos - SET taken_at = COALESCE(taken_at, ?), - lat = COALESCE(lat, ?), - lng = COALESCE(lng, ?) - WHERE id = ?`, - takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId, - ); + await this.trekPhotos.patchCaptureMetadata(photoId, takenAt, hasPair ? lat : null, hasPair ? lng : null); } /** - * Drop the row once no trip and no journey references it. Local photos are - * kept: their bytes are ours, and the file would outlive the row. + * PH10/PH11 — drop the row once no trip and no journey references it. + * Local photos are kept: their bytes are ours, and the file would outlive + * the row. + * + * PH10's original statement was one `UNION ALL` existence check across + * `trip_photos` and `journey_photos`; split into its two halves here + * (`TripPhotosRepository.existsForPhoto` for the 3e-owned table, a still + * -raw `journey_photos` read for the not-yet-converted Plan 3g table) — + * logically identical (neither half needs to run if the other already + * found a row), never a transaction (both are reads). */ async deleteIfOrphan(photoId: number): Promise { - const stillUsed = this.db.get(` - SELECT 1 FROM trip_photos WHERE photo_id = ? - UNION ALL - SELECT 1 FROM journey_photos WHERE photo_id = ? - LIMIT 1 - `, photoId, photoId); - if (stillUsed) return; - this.db.run("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'", photoId); + if (await this.tripPhotos.existsForPhoto(photoId)) return; + // PH10 — Plan 3g (journey_photos is not yet an owned repository table). + const stillUsedByJourney = this.db.get('SELECT 1 FROM journey_photos WHERE photo_id = ?', photoId); + if (stillUsedByJourney) return; + await this.trekPhotos.deleteNonLocal(photoId); } } diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index de0f46c10..038a654f3 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -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 { 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), diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index d99131490..ab24b7052 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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 { // 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 { // (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), diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index e7d34ee1f..e27475475 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -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); diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 81c839fcc..474a14d4c 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -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) => 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) => 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)', () => { diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index da16e7e54..b2331b68f 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -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), diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts index d30980fdc..290e3fad0 100644 --- a/server/tests/unit/nest/journey-book.service.test.ts +++ b/server/tests/unit/nest/journey-book.service.test.ts @@ -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); }); diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 42e90d1f2..49b5e210b 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -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(() => { diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index fb4539391..51912e4be 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -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)), ); }); diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 069087a4d..be6316d75 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -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(() => { diff --git a/server/tests/unit/nest/memories-access.service.test.ts b/server/tests/unit/nest/memories-access.service.test.ts index 5cafceecb..cc7cf34d1 100644 --- a/server/tests/unit/nest/memories-access.service.test.ts +++ b/server/tests/unit/nest/memories-access.service.test.ts @@ -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) => 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(); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.service.test.ts b/server/tests/unit/nest/photo-capture-backfill.service.test.ts index d1970adfb..41c0614a0 100644 --- a/server/tests/unit/nest/photo-capture-backfill.service.test.ts +++ b/server/tests/unit/nest/photo-capture-backfill.service.test.ts @@ -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) { 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(); diff --git a/server/tests/unit/nest/photo-resolver.service.test.ts b/server/tests/unit/nest/photo-resolver.service.test.ts index 8861b3296..3b6701f8c 100644 --- a/server/tests/unit/nest/photo-resolver.service.test.ts +++ b/server/tests/unit/nest/photo-resolver.service.test.ts @@ -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): 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 () => { diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index 2468005da..923a099b0 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -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 ({ 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', ) // 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 { 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/.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'); diff --git a/server/tests/unit/nest/trek-photo-cache.service.test.ts b/server/tests/unit/nest/trek-photo-cache.service.test.ts index 527169d2c..ebcc68def 100644 --- a/server/tests/unit/nest/trek-photo-cache.service.test.ts +++ b/server/tests/unit/nest/trek-photo-cache.service.test.ts @@ -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(null); svc.setInFlight('shared-key', promise); expect(other.getInFlight('shared-key')).toBe(promise); diff --git a/server/tests/unit/nest/trek-photos.repository.test.ts b/server/tests/unit/nest/trek-photos.repository.test.ts index dba42beb5..6e872ad4d 100644 --- a/server/tests/unit/nest/trek-photos.repository.test.ts +++ b/server/tests/unit/nest/trek-photos.repository.test.ts @@ -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 { 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(); + }); +}); diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 9b342140a..43bcd4acb 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -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), diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index ce63257f3..694185d21 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -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), diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index 3099754b4..5ef4b967d 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -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), diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index fca52f80a..45bb890f1 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -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), diff --git a/server/tests/unit/services/trekPhotoMedia.test.ts b/server/tests/unit/services/trekPhotoMedia.test.ts index 7a6e948f9..db29de634 100644 --- a/server/tests/unit/services/trekPhotoMedia.test.ts +++ b/server/tests/unit/services/trekPhotoMedia.test.ts @@ -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) => trekPhotos.getOrCreate(...a); -const getOrCreateLocalTrekPhoto = (...a: Parameters) => trekPhotos.getOrCreateLocal(...a); +let t: TestOrm; +let trekPhotos: TrekPhotoRegistrationService; +const getOrCreateTrekPhoto = (...a: Parameters) => trekPhotos.getOrCreate(...a); +const getOrCreateLocalTrekPhoto = (...a: Parameters) => 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(); });