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

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