mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
refactor(photos): trek photos, memories access and cache sweeps through repositories
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user