refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 02:21:06 +02:00
parent 6b755314d8
commit 37992de7ab
48 changed files with 1250 additions and 1219 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ beforeAll(async () => {
addonsStub,
promptGuards,
);
promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub());
promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(promptDbs().connection));
packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards);
budgetMcp = new BudgetMcp(
new BudgetService(promptDbs(), new PermissionsService(promptDbs(), await createTestUnitOfWork(promptDbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(promptDbs().connection)),
@@ -19,17 +19,17 @@ const writeAudit = vi.fn();
function controller(over: Partial<PackingService> = {}) {
const packing = {
listPackingTemplates: vi.fn(() => [{ id: 1 }]),
getPackingTemplate: vi.fn(() => ({ template: { id: 1 } })),
createPackingTemplate: vi.fn(() => ({ template: { id: 9 } })),
updatePackingTemplate: vi.fn(() => ({ template: { id: 1 } })),
deletePackingTemplate: vi.fn(() => ({ name: 'Beach' })),
createTemplateCategory: vi.fn(() => ({ category: { id: 2 } })),
updateTemplateCategory: vi.fn(() => ({ category: { id: 2 } })),
deleteTemplateCategory: vi.fn(() => ({ ok: true })),
createTemplateItem: vi.fn(() => ({ item: { id: 3 } })),
updateTemplateItem: vi.fn(() => ({ item: { id: 3 } })),
deleteTemplateItem: vi.fn(() => ({ ok: true })),
listPackingTemplates: vi.fn(async () => [{ id: 1 }]),
getPackingTemplate: vi.fn(async () => ({ template: { id: 1 } })),
createPackingTemplate: vi.fn(async () => ({ template: { id: 9 } })),
updatePackingTemplate: vi.fn(async () => ({ template: { id: 1 } })),
deletePackingTemplate: vi.fn(async () => ({ name: 'Beach' })),
createTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })),
updateTemplateCategory: vi.fn(async () => ({ category: { id: 2 } })),
deleteTemplateCategory: vi.fn(async () => ({ ok: true })),
createTemplateItem: vi.fn(async () => ({ item: { id: 3 } })),
updateTemplateItem: vi.fn(async () => ({ item: { id: 3 } })),
deleteTemplateItem: vi.fn(async () => ({ ok: true })),
...over,
} as unknown as PackingService;
return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing };
@@ -45,9 +45,9 @@ function offContract<K extends keyof PackingService>(name: K, impl: () => unknow
return { [name]: vi.fn(impl) } as unknown as Partial<PackingService>;
}
const thrown = (run: () => unknown) => {
const thrown = async (run: () => unknown) => {
try {
run();
await run();
return null;
} catch (e) {
return e instanceof HttpException ? { status: e.getStatus(), body: e.getResponse() } : e;
@@ -59,18 +59,18 @@ describe('AdminPackingTemplatesController', () => {
vi.clearAllMocks();
});
it('PACKTPL-001 list wraps the service result in the { templates } envelope', () => {
expect(controller().c.list()).toEqual({ templates: [{ id: 1 }] });
it('PACKTPL-001 list wraps the service result in the { templates } envelope', async () => {
expect(await controller().c.list()).toEqual({ templates: [{ id: 1 }] });
});
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => {
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) });
expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', async () => {
const { c } = controller({ getPackingTemplate: vi.fn(async () => ({ error: 'not found', status: 404 })) });
expect(await thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
});
it('PACKTPL-003 an error without a status defaults to 400', () => {
const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' })));
expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
it('PACKTPL-003 an error without a status defaults to 400', async () => {
const { c } = controller(offContract('createTemplateCategory', async () => ({ error: 'name required' })));
expect(await thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
});
it('PACKTPL-004 create audits with the new template id', async () => {
@@ -90,26 +90,26 @@ describe('AdminPackingTemplatesController', () => {
);
});
it('PACKTPL-006 the nested category and item routes forward their ids in order', () => {
it('PACKTPL-006 the nested category and item routes forward their ids in order', async () => {
const { c, packing } = controller();
c.createCategory('1', { name: 'Clothes' });
await c.createCategory('1', { name: 'Clothes' });
expect(packing.createTemplateCategory).toHaveBeenCalledWith('1', 'Clothes');
c.updateCategory('1', '2', { name: 'Warm' });
await c.updateCategory('1', '2', { name: 'Warm' });
expect(packing.updateTemplateCategory).toHaveBeenCalledWith('1', '2', { name: 'Warm' });
expect(c.deleteCategory('1', '2')).toEqual({ success: true });
c.createItem('1', '2', { name: 'Socks' });
expect(await c.deleteCategory('1', '2')).toEqual({ success: true });
await c.createItem('1', '2', { name: 'Socks' });
expect(packing.createTemplateItem).toHaveBeenCalledWith('1', '2', 'Socks');
c.updateItem('1', '3', { name: 'Wool socks' });
await c.updateItem('1', '3', { name: 'Wool socks' });
expect(packing.updateTemplateItem).toHaveBeenCalledWith('1', '3', { name: 'Wool socks' });
expect(c.deleteItem('1', '3')).toEqual({ success: true });
expect(await c.deleteItem('1', '3')).toEqual({ success: true });
});
it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', () => {
it('PACKTPL-007 the delete routes answer { success: true } rather than the service payload', async () => {
// The client branches on `success`; leaking the raw service result here would be a
// silent shape change for every caller.
const { c } = controller();
expect(c.deleteCategory('1', '2')).toEqual({ success: true });
expect(c.deleteItem('1', '3')).toEqual({ success: true });
expect(await c.deleteCategory('1', '2')).toEqual({ success: true });
expect(await c.deleteItem('1', '3')).toEqual({ success: true });
});
it('PACKTPL-008 the class is listed in its module controllers', () => {
+22 -22
View File
@@ -91,27 +91,27 @@ beforeEach(() => {
afterAll(() => testDb.close());
describe('getImmichCredentials', () => {
it('IMMICH-001: returns null when the user row is missing', () => {
expect(svc.getImmichCredentials(999)).toBeNull();
it('IMMICH-001: returns null when the user row is missing', async () => {
expect(await svc.getImmichCredentials(999)).toBeNull();
});
it('IMMICH-002: returns null without a URL', () => {
it('IMMICH-002: returns null without a URL', async () => {
seedUser(2, null, 'key');
expect(svc.getImmichCredentials(2)).toBeNull();
expect(await svc.getImmichCredentials(2)).toBeNull();
});
it('IMMICH-003: returns null without an API key', () => {
it('IMMICH-003: returns null without an API key', async () => {
seedUser(3, 'https://immich.test', null);
expect(svc.getImmichCredentials(3)).toBeNull();
expect(await svc.getImmichCredentials(3)).toBeNull();
});
it('IMMICH-004: returns null when the stored key cannot be decrypted', () => {
it('IMMICH-004: returns null when the stored key cannot be decrypted', async () => {
decryptMock.mockReturnValue(null);
expect(svc.getImmichCredentials(USER)).toBeNull();
expect(await svc.getImmichCredentials(USER)).toBeNull();
});
it('IMMICH-005: returns the decrypted pair otherwise', () => {
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
it('IMMICH-005: returns the decrypted pair otherwise', async () => {
expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://immich.test', immich_api_key: 'key-1' });
});
});
@@ -124,20 +124,20 @@ describe('isValidAssetId', () => {
});
describe('getConnectionSettings / setImmichAutoUpload', () => {
it('IMMICH-007: reports connected with the URL when configured', () => {
expect(svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
it('IMMICH-007: reports connected with the URL when configured', async () => {
expect(await svc.getConnectionSettings(USER)).toEqual({ immich_url: 'https://immich.test', connected: true, auto_upload: false });
});
it('IMMICH-008: reports an empty URL and not connected when it is not', () => {
it('IMMICH-008: reports an empty URL and not connected when it is not', async () => {
seedUser(4, null, null);
expect(svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
expect(await svc.getConnectionSettings(4)).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('IMMICH-009: surfaces the auto-upload flag both ways', () => {
svc.setImmichAutoUpload(USER, true);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(true);
svc.setImmichAutoUpload(USER, false);
expect(svc.getConnectionSettings(USER).auto_upload).toBe(false);
it('IMMICH-009: surfaces the auto-upload flag both ways', async () => {
await svc.setImmichAutoUpload(USER, true);
expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(true);
await svc.setImmichAutoUpload(USER, false);
expect((await svc.getConnectionSettings(USER)).auto_upload).toBe(false);
});
});
@@ -148,14 +148,14 @@ describe('saveImmichSettings', () => {
const result = await svc.saveImmichSettings(USER, 'http://169.254.169.254', 'k', null);
expect(result).toEqual({ success: false, error: 'Invalid Immich URL: blocked host' });
expect(svc.getImmichCredentials(USER)!.immich_url).toBe('https://immich.test');
expect((await svc.getImmichCredentials(USER))!.immich_url).toBe('https://immich.test');
});
it('IMMICH-011: stores a trimmed URL and reports plain success', async () => {
const result = await svc.saveImmichSettings(USER, ' https://new.test ', 'k2', null);
expect(result).toEqual({ success: true });
expect(svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
expect(await svc.getImmichCredentials(USER)).toEqual({ immich_url: 'https://new.test', immich_api_key: 'k2' });
});
it('IMMICH-012: warns and audits when the URL resolves to a private IP', async () => {
@@ -173,7 +173,7 @@ describe('saveImmichSettings', () => {
expect(result).toEqual({ success: true });
expect(checkSsrf).not.toHaveBeenCalled();
expect(svc.getImmichCredentials(USER)).toBeNull();
expect(await svc.getImmichCredentials(USER)).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -86,11 +86,11 @@ describe('JourneyController', () => {
expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', () => {
it('provider-photos: batch, single 400/403, success', async () => {
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
expect(ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(thrown(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(await ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(await thrownAsync(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
@@ -136,21 +136,21 @@ describe('JourneyController', () => {
expect(up2).toHaveBeenCalledWith(3, 1, [{ path: 'journey/v2.mp4', thumbnail: undefined, mediaType: 'video', durationMs: null }]);
});
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', () => {
it('provider-photos forwards per-asset media_types for gallery and entries (#823)', async () => {
const add = vi.fn().mockReturnValue({ id: 1 });
ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
await ctl(svc({ addProviderPhotoToGallery: add } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'] });
expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video');
expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image');
const addOne = vi.fn().mockReturnValue({ id: 2 });
ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
await ctl(svc({ addProviderPhotoToGallery: addOne } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' });
expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video');
// Entry path mirrors the gallery path.
const eAdd = vi.fn().mockReturnValue({ id: 3 });
ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
await ctl(svc({ addProviderPhoto: eAdd } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c' });
expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video');
const eOne = vi.fn().mockReturnValue({ id: 4 });
ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
await ctl(svc({ addProviderPhoto: eOne } as Partial<JourneyService>)).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' });
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
@@ -264,12 +264,12 @@ describe('JourneyController', () => {
expect(await ctl(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
});
it('provider-photos batch passes the passphrase through when present', () => {
it('provider-photos batch passes the passphrase through when present', async () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
await ctl(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
expect(await ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
@@ -291,14 +291,14 @@ describe('JourneyController', () => {
expect(storageStub.delete).not.toHaveBeenCalled();
});
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = ctl(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(await batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(await thrownAsync(() => ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(await ctl(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
});
it('DELETE gallery photo: 404, then removes the object when present', async () => {
@@ -57,12 +57,14 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { mapDbError, pipeAsset } from '../../../src/nest/memories/memories.helpers';
import { mapDbError, pipeAsset, type ServiceResult } from '../../../src/nest/memories/memories.helpers';
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
const access = new MemoriesAccessService(new DatabaseService(testDb));
const getAlbumIdFromLink = access.getAlbumIdFromLink.bind(access);
// 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);
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
@@ -82,7 +84,7 @@ afterAll(() => {
// ── mapDbError ────────────────────────────────────────────────────────────────
describe('mapDbError', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
it('MEM-HELPERS-001: returns 409 for unique constraint error', async () => {
const err = new Error('UNIQUE constraint failed: users.email');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
@@ -90,14 +92,14 @@ describe('mapDbError', () => {
expect(result.error.message).toBe('Resource already exists');
});
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
it('MEM-HELPERS-002: returns 409 for generic constraint error', async () => {
const err = new Error('constraint violation');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
expect(result.error.status).toBe(409);
});
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', async () => {
const err = new Error('Something went wrong');
const result = mapDbError(err, 'fallback');
expect(result.success).toBe(false);
@@ -105,7 +107,7 @@ describe('mapDbError', () => {
expect(result.error.message).toBe('Something went wrong');
});
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
it('MEM-HELPERS-004: returns 500 for generic DB error', async () => {
const err = new Error('disk I/O error');
const result = mapDbError(err, 'fallback');
expect(result.error.status).toBe(500);
@@ -114,24 +116,34 @@ describe('mapDbError', () => {
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
/**
* The failure arm of a ServiceResult. The assertions below used to read
* `result.error` through a `.bind` alias that typed the whole thing `any`; the
* typed forwarder narrows properly instead of casting.
*/
function failureOf(result: ServiceResult<unknown>): { message: string; status: number } {
if (!('error' in result)) throw new Error('expected a failing ServiceResult');
return result.error;
}
describe('getAlbumIdFromLink', () => {
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
const result = getAlbumIdFromLink('9999', 'link-1', 1);
it('MEM-HELPERS-005: returns 404 when trip access is denied', async () => {
const result = await getAlbumIdFromLink('9999', 'link-1', 1);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(failureOf(result).status).toBe(404);
});
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
it('MEM-HELPERS-006: returns 404 when album link is not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
const result = await getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
expect(result.success).toBe(false);
expect(result.error.status).toBe(404);
expect(result.error.message).toBe('Album link not found');
expect(failureOf(result).status).toBe(404);
expect(failureOf(result).message).toBe('Album link not found');
});
it('MEM-HELPERS-007: returns album_id when link exists', () => {
it('MEM-HELPERS-007: returns album_id when link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -141,7 +153,7 @@ describe('getAlbumIdFromLink', () => {
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const linkId = ins.lastInsertRowid;
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
const result = await getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
expect(result.success).toBe(true);
expect((result as any).data).toBe('album-123');
});
@@ -308,50 +320,50 @@ function makeJourney(userId: number): number {
}
describe('canAccessUserPhoto', () => {
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', () => {
expect(access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
it('MEM-ACCESS-001: the owner always passes, with no lookup needed', async () => {
expect(await access.canAccessUserPhoto(7, 7, '1', 'asset-1', 'immich')).toBe(true);
});
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', () => {
it('MEM-ACCESS-002: a trip member sees an asset shared into that trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(true);
});
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', () => {
it('MEM-ACCESS-003: an unshared asset stays private even inside the same trip', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'asset-private', 'immich', 0);
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'asset-private', 'immich')).toBe(false);
});
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', () => {
it('MEM-ACCESS-004: a stranger is refused even for a shared asset', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const trip = createTrip(testDb, owner.id);
shareInTrip(trip.id, owner.id, 'asset-shared');
expect(access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(stranger.id, owner.id, String(trip.id), 'asset-shared', 'immich')).toBe(false);
});
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', () => {
it('MEM-ACCESS-005: the provider is part of the match — same asset id, other provider, no access', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const trip = createTrip(testDb, owner.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
shareInTrip(trip.id, owner.id, 'same-id', 'immich');
expect(access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
expect(await access.canAccessUserPhoto(member.id, owner.id, String(trip.id), 'same-id', 'synologyphotos')).toBe(false);
});
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', () => {
it('MEM-ACCESS-006: tripId "0" routes through journeys — a contributor passes', async () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
@@ -361,10 +373,10 @@ describe('canAccessUserPhoto', () => {
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
expect(await access.canAccessUserPhoto(contributor.id, owner.id, '0', 'j-asset', 'immich')).toBe(true);
});
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', () => {
it('MEM-ACCESS-007: tripId "0" refuses someone with no journey link', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb, { username: 'stranger' });
const journeyId = makeJourney(owner.id);
@@ -373,31 +385,31 @@ describe('canAccessUserPhoto', () => {
).run(owner.id).lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(stranger.id, owner.id, '0', 'j-asset-2', 'immich')).toBe(false);
});
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', () => {
it('MEM-ACCESS-008: tripId "0" refuses an asset that is in no journey at all', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb, { username: 'other' });
expect(access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
expect(await access.canAccessUserPhoto(other.id, owner.id, '0', 'not-in-any-journey', 'immich')).toBe(false);
});
});
describe('canAccessTrekPhoto', () => {
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', () => {
expect(access.canAccessTrekPhoto(1, 999999)).toBe(false);
it('MEM-ACCESS-010: an unknown photo id is refused, not treated as public', async () => {
expect(await access.canAccessTrekPhoto(1, 999999)).toBe(false);
});
it('MEM-ACCESS-011: the owner passes', () => {
it('MEM-ACCESS-011: the owner passes', async () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'own', ?)"
).run(user.id).lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(true);
});
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', () => {
it('MEM-ACCESS-012: a trip member passes for a shared photo, a stranger does not', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb, { username: 'member' });
const { user: stranger } = createUser(testDb, { username: 'stranger' });
@@ -405,20 +417,20 @@ describe('canAccessTrekPhoto', () => {
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
const photoId = shareInTrip(trip.id, owner.id, 'trek-shared');
expect(access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(member.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(stranger.id, photoId)).toBe(false);
});
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', () => {
it('MEM-ACCESS-013: the trip owner passes without a trip_members row', async () => {
const { user: owner } = createUser(testDb);
const { user: uploader } = createUser(testDb, { username: 'uploader' });
const trip = createTrip(testDb, owner.id);
const photoId = shareInTrip(trip.id, uploader.id, 'owner-path');
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
});
it('MEM-ACCESS-014: a journey contributor passes', () => {
it('MEM-ACCESS-014: a journey contributor passes', async () => {
const { user: owner } = createUser(testDb);
const { user: contributor } = createUser(testDb, { username: 'contrib' });
const journeyId = makeJourney(owner.id);
@@ -428,10 +440,10 @@ describe('canAccessTrekPhoto', () => {
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
testDb.prepare("INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'editor', 0)").run(journeyId, contributor.id);
expect(access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(contributor.id, photoId)).toBe(true);
});
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', () => {
it('MEM-ACCESS-015: an ownerless local upload is reachable only through its journey', async () => {
const { user: owner } = createUser(testDb);
const { user: outsider } = createUser(testDb, { username: 'outsider' });
const journeyId = makeJourney(owner.id);
@@ -440,16 +452,16 @@ describe('canAccessTrekPhoto', () => {
).run().lastInsertRowid);
testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, created_at) VALUES (?, ?, 0)').run(journeyId, photoId);
expect(access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(owner.id, photoId)).toBe(true);
expect(await access.canAccessTrekPhoto(outsider.id, photoId)).toBe(false);
});
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', () => {
it('MEM-ACCESS-016: an ownerless local upload in no journey is reachable by nobody', async () => {
const { user } = createUser(testDb);
const photoId = Number(testDb.prepare(
"INSERT INTO trek_photos (provider, file_path) VALUES ('local', 'orphan.jpg')"
).run().lastInsertRowid);
expect(access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
expect(await access.canAccessTrekPhoto(user.id, photoId)).toBe(false);
});
});
@@ -195,19 +195,19 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif
});
describe('DELETE /trips/:tripId/album-links/:linkId', () => {
it('removes the link on success', () => {
it('removes the link on success', async () => {
const removeAlbumLink = vi.fn().mockReturnValue({ data: {} });
const svc = makeService({ removeAlbumLink });
const res = makeRes();
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
expect(removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
expect(res.json).toHaveBeenCalledWith({ success: true });
});
it('maps the error envelope', () => {
it('maps the error envelope', async () => {
const svc = makeService({ removeAlbumLink: vi.fn().mockReturnValue({ error: { status: 404, message: 'Link not found' } }) });
const res = makeRes();
new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
await new UnifiedMemoriesController(svc).removeAlbumLink(user, '5', 'l1', res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Link not found' });
});
@@ -98,13 +98,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
await svc.setTripPhotoSharing('5', 7, 9, false);
expect(unified.setTripPhotoSharing).toHaveBeenCalledWith('5', 7, 9, false);
svc.removeTripPhoto('5', 7, 9);
await svc.removeTripPhoto('5', 7, 9);
expect(unified.removeTripPhoto).toHaveBeenCalledWith('5', 7, 9);
await svc.listTripAlbumLinks('5', 7);
expect(unified.listTripAlbumLinks).toHaveBeenCalledWith('5', 7);
svc.removeAlbumLink('5', 'l1', 7);
await svc.removeAlbumLink('5', 'l1', 7);
expect(unified.removeAlbumLink).toHaveBeenCalledWith('5', 'l1', 7);
});
@@ -117,13 +117,13 @@ describe('MemoriesService (delegation wrapper over services/memories/*)', () =>
});
it('immich methods delegate', async () => {
svc.immichGetConnectionSettings(7);
await svc.immichGetConnectionSettings(7);
expect(immich.getConnectionSettings).toHaveBeenCalledWith(7);
await svc.immichSaveSettings(7, 'u', 'k', '1.2.3.4');
expect(immich.saveImmichSettings).toHaveBeenCalledWith(7, 'u', 'k', '1.2.3.4');
svc.immichSetAutoUpload(7, true);
await svc.immichSetAutoUpload(7, true);
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(7, true);
await svc.immichGetConnectionStatus(7);
+104 -104
View File
@@ -51,9 +51,9 @@ function makeService(overrides: Partial<PackingService> = {}): PackingService {
return svc;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
fn();
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
@@ -66,9 +66,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
// trip-access.guard.test.ts with the check itself.
describe('PackingController (parity with the legacy /api/trips/:tripId/packing route)', () => {
it('GET / returns items for an accessible trip', () => {
it('GET / returns items for an accessible trip', async () => {
const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<PackingService>);
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
});
describe('POST / (create)', () => {
@@ -78,97 +78,97 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// bypass parameter pipes, so that path is covered by the e2e suite and
// the schema spec in @trek/shared.
it('creates an item (owned by the creator) and broadcasts a Common item to the room', () => {
it('creates an item (owned by the creator) and broadcasts a Common item to the room', async () => {
// Common item (is_private falsy) → viewersOf null → whole-room broadcast.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 });
const broadcast = vi.fn();
const svc = makeService({ createItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } });
expect(await new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks', is_private: 0 } });
// Stamps the creator as owner (#858) and routes the broadcast to viewers.
expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ name: 'Socks' }), user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks', is_private: 0 } }, 'sock');
});
it('pings the room that bag weights moved after a create (#2191)', () => {
it('pings the room that bag weights moved after a create (#2191)', async () => {
// The totals are summed server-side, so nothing else tells the other
// members' screens to re-read them.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks', is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ createItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Socks', weight_grams: 250 }, 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
});
it('pings after a delete, and after a bag delete that orphans its items (#2191)', () => {
it('pings after a delete, and after a bag delete that orphans its items (#2191)', async () => {
const deleteItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ deleteItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).remove(user, '5', '9', 'sock');
await new PackingController(svc).remove(user, '5', '9', 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
// bag_id is ON DELETE SET NULL: the bag's items land in the unassigned pile.
const deleteBag = vi.fn().mockReturnValue(true);
const bagPing = vi.fn();
const bagSvc = makeService({ deleteBag, broadcastBagTotals: bagPing } as Partial<PackingService>);
new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock');
await new PackingController(bagSvc).deleteBag(user, '5', '7', 'sock');
expect(bagPing).toHaveBeenCalledWith('5');
});
it('does NOT ping when the update cannot move a weight (#2191)', () => {
it('does NOT ping when the update cannot move a weight (#2191)', async () => {
// Checking an item off is the most frequent packing write there is, and
// every ping costs each connected client a listBags round trip.
const updateItem = vi.fn().mockReturnValue({ id: 9, is_private: 0 });
const broadcastBagTotals = vi.fn();
const svc = makeService({ updateItem, broadcastBagTotals } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { checked: true }, 'sock');
expect(broadcastBagTotals).not.toHaveBeenCalled();
new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock');
await new PackingController(svc).update(user, '5', '9', { quantity: 3 }, 'sock');
expect(broadcastBagTotals).toHaveBeenCalledWith('5');
});
it('routes a Shared item create only to the owner + recipients (#858)', () => {
it('routes a Shared item create only to the owner + recipients (#858)', async () => {
const item = { id: 9, name: 'Power bank', is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const createItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ createItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Power bank', visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1, 2], 'sock');
});
it('forwards weight_grams, bag_id and quantity to the service (#2154)', () => {
it('forwards weight_grams, bag_id and quantity to the service (#2154)', async () => {
// The create route used to hand only the six legacy fields through, so
// the values a caller sent vanished into a 201.
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Tent', is_private: 0 });
const svc = makeService({ createItem, broadcast: vi.fn() } as Partial<PackingService>);
new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock');
await new PackingController(svc).create(user, '5', { name: 'Tent', weight_grams: 250, bag_id: 3, quantity: 3 }, 'sock');
expect(createItem).toHaveBeenCalledWith('5', expect.objectContaining({ weight_grams: 250, bag_id: 3, quantity: 3 }), user.id);
});
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => {
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => {
// Body validation error, so 400 — the 404 'Bag not found' stays with the
// /bags/:bagId path routes.
const broadcast = vi.fn();
const svc = makeService({ createItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).create(user, '5', { name: 'Tent', bag_id: 999 }))).toEqual({
status: 400, body: { error: 'Bag not found' },
});
expect(broadcast).not.toHaveBeenCalled();
});
});
it('GET / lists items for the trip, scoped to the viewer (#858)', () => {
it('GET / lists items for the trip, scoped to the viewer (#858)', async () => {
const listItems = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
const svc = makeService({ listItems } as Partial<PackingService>);
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
expect(await new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }, { id: 2 }] });
expect(listItems).toHaveBeenCalledWith('5', user.id);
});
describe('POST /import', () => {
it('400 when items is an empty array (bespoke check, schema permits [])', () => {
it('400 when items is an empty array (bespoke check, schema permits [])', async () => {
const svc = makeService();
expect(thrown(() => new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).importItems(user, '5', { items: [] }))).toEqual({
status: 400, body: { error: 'items must be a non-empty array' },
});
});
@@ -178,11 +178,11 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// path is covered by the e2e suite and the schema spec in @trek/shared.
it('imports (owned by the importer) and broadcasts per item', () => {
it('imports (owned by the importer) and broadcasts per item', async () => {
const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
const broadcastItem = vi.fn();
const svc = makeService({ bulkImport, broadcastItem } as Partial<PackingService>);
const res = new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock');
const res = await new PackingController(svc).importItems(user, '5', { items: [{ name: 'a' }, { name: 'b' }] }, 'sock');
expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }], count: 2 });
expect(bulkImport).toHaveBeenCalledWith('5', [{ name: 'a' }, { name: 'b' }], user.id);
expect(broadcastItem).toHaveBeenCalledTimes(2);
@@ -190,18 +190,18 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('PUT /:id (update)', () => {
it('404 when the item is missing', () => {
it('404 when the item is missing', async () => {
const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Item not found' },
});
});
it('updates, forwards changed keys + acting user, and broadcasts (stays public)', () => {
it('updates, forwards changed keys + acting user, and broadcasts (stays public)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X' });
const broadcast = vi.fn();
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
// acting user id is forwarded so privatizing an unowned item can stamp the
// owner (#858); checked is normalized to the 0/1 the SQL binds.
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: 1 }), ['name', 'checked'], undefined, user.id);
@@ -209,56 +209,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X' } }, 'sock');
});
it('keeps a private update scoped to the owner (#858)', () => {
it('keeps a private update scoped to the owner (#858)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
const broadcast = vi.fn();
const broadcastItem = vi.fn();
// Was already private before the change → owner-only update, no room broadcast.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock');
await new PackingController(svc).update(user, '5', '9', { name: 'X' }, 'sock');
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X', is_private: 1, owner_id: 1 } }, { id: 9, name: 'X', is_private: 1, owner_id: 1 }, 'sock');
expect(broadcast).not.toHaveBeenCalled();
});
it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', () => {
it('drops a freshly-privatized item from the room and re-adds it for the owner (#858)', async () => {
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X', is_private: 1, owner_id: 1 });
const broadcast = vi.fn();
const broadcastItem = vi.fn();
// Was public before → public→private transition.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 0, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, broadcastItem, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock');
await new PackingController(svc).update(user, '5', '9', { is_private: true }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:created', expect.anything(), expect.anything(), 'sock');
});
it('re-shares a freshly-public item to the whole room (#858)', () => {
it('re-shares a freshly-public item to the whole room (#858)', async () => {
const updated = { id: 9, name: 'X', is_private: 0, owner_id: 1 };
const updateItem = vi.fn().mockReturnValue(updated);
const broadcast = vi.fn();
// Was private before → private→public transition: create for those missing it, then update for all.
const getItemPrivacy = vi.fn().mockReturnValue({ is_private: 1, owner_id: 1 });
const svc = makeService({ updateItem, broadcast, getItemPrivacy } as Partial<PackingService>);
new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock');
await new PackingController(svc).update(user, '5', '9', { is_private: false }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: updated }, 'sock');
});
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', () => {
it('400 "Bag not found" for a bag off the trip (#2154), broadcasting nothing', async () => {
const broadcast = vi.fn();
const svc = makeService({ updateItem: vi.fn().mockReturnValue({ invalidBag: true }), broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { bag_id: 999 }))).toEqual({
status: 400, body: { error: 'Bag not found' },
});
expect(broadcast).not.toHaveBeenCalled();
});
it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', () => {
it('forwards the X-Base-Updated-At token and 409s on a conflict (#1135)', async () => {
const updateItem = vi.fn().mockReturnValue({ conflict: true, server: { id: 9, name: 'Theirs' } });
const broadcast = vi.fn();
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).update(user, '5', '9', { name: 'Mine' }, 'sock', '2026-01-01 00:00:00'))).toEqual({
status: 409, body: { error: 'conflict', server: { id: 9, name: 'Theirs' } },
});
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'Mine' }), ['name'], '2026-01-01 00:00:00', user.id);
@@ -267,38 +267,38 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('PUT /reorder', () => {
it('reorders the items and reports success', () => {
it('reorders the items and reports success', async () => {
const reorderItems = vi.fn();
const svc = makeService({ reorderItems } as Partial<PackingService>);
expect(new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(await new PackingController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]);
});
});
describe('DELETE /:id (remove)', () => {
it('404 when the item is missing', () => {
it('404 when the item is missing', async () => {
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).remove(user, '5', '9'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).remove(user, '5', '9'))).toEqual({
status: 404, body: { error: 'Item not found' },
});
});
it('deletes a Common item and broadcasts to the room', () => {
it('deletes a Common item and broadcasts to the room', async () => {
const deleted = { id: 9, is_private: 0, owner_id: 1 };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcast = vi.fn();
const svc = makeService({ deleteItem, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(await new PackingController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
});
it('scopes the delete of a Shared item to the owner + recipients (#858)', () => {
it('scopes the delete of a Shared item to the owner + recipients (#858)', async () => {
const deleted = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const deleteItem = vi.fn().mockReturnValue(deleted);
const broadcastToViewers = vi.fn();
const svc = makeService({ deleteItem, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).remove(user, '5', '9', 'sock');
await new PackingController(svc).remove(user, '5', '9', 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, [1, 2], 'sock');
});
});
@@ -308,56 +308,56 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// (packingSetSharingRequestSchema requires the enum) — direct method calls
// bypass parameter pipes, so that path is covered by the e2e suite and the
// schema spec in @trek/shared.
it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', () => {
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } });
expect(thrown(() => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } });
it('PUT /:id/sharing 404 missing, 403 non-owner, else drops + re-emits', async () => {
expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 404, body: { error: 'Item not found' } });
expect(await thrown(async () => new PackingController(makeService({ setItemSharing: vi.fn().mockReturnValue({ forbidden: true }) } as Partial<PackingService>)).setSharing(user, '5', '9', { visibility: 'personal' }))).toEqual({ status: 403, body: { error: 'Only the owner can change sharing' } });
const updated = { id: 9, is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }] };
const setItemSharing = vi.fn().mockReturnValue(updated);
const broadcast = vi.fn();
const broadcastToViewers = vi.fn();
const svc = makeService({ setItemSharing, broadcast, broadcastToViewers } as Partial<PackingService>);
new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock');
await new PackingController(svc).setSharing(user, '5', '9', { visibility: 'shared', recipient_ids: [2] }, 'sock');
expect(setItemSharing).toHaveBeenCalledWith('5', '9', user.id, 'shared', [2]);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:deleted', { itemId: 9 }, 'sock');
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item: updated }, [1, 2], 'sock');
});
it('POST /:id/clone 404 missing, else creates a personal copy for the caller', () => {
expect(thrown(() => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } });
it('POST /:id/clone 404 missing, else creates a personal copy for the caller', async () => {
expect(await thrown(async () => new PackingController(makeService({ cloneItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).clone(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found' } });
const item = { id: 12, is_private: 1, owner_id: 1 };
const cloneItem = vi.fn().mockReturnValue(item);
const broadcastToViewers = vi.fn();
const svc = makeService({ cloneItem, broadcastToViewers } as Partial<PackingService>);
expect(new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item });
expect(await new PackingController(svc).clone(user, '5', '9', 'sock')).toEqual({ item });
expect(cloneItem).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcastToViewers).toHaveBeenCalledWith('5', 'packing:created', { item }, [1], 'sock');
});
it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', () => {
expect(thrown(() => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } });
it('POST /:id/contributors 404 missing, else adds the caller + broadcasts', async () => {
expect(await thrown(async () => new PackingController(makeService({ addContributor: vi.fn().mockReturnValue(null) } as Partial<PackingService>)).addContributor(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Item not found or not a shared list item' } });
const item = { id: 9, is_private: 0, contributors: [{ user_id: 1 }] };
const addContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ addContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).addContributor(user, '5', '9', 'sock');
await new PackingController(svc).addContributor(user, '5', '9', 'sock');
expect(addContributor).toHaveBeenCalledWith('5', '9', user.id);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', () => {
it('DELETE /:id/contributors/:userId removes the contributor + broadcasts', async () => {
const item = { id: 9, is_private: 0, contributors: [] };
const removeContributor = vi.fn().mockReturnValue(item);
const broadcast = vi.fn();
const svc = makeService({ removeContributor, broadcast } as Partial<PackingService>);
new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock');
await new PackingController(svc).removeContributor(user, '5', '9', '2', 'sock');
expect(removeContributor).toHaveBeenCalledWith('5', '9', 2);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item }, 'sock');
});
});
describe('bags', () => {
it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', () => {
it('GET /bags lists bags for the trip, with the unassigned weight alongside (#2191)', async () => {
// One service call, so the SUM…GROUP BY runs once per request — this route
// now fires on every item write for every connected client.
const listBagsWithWeights = vi.fn().mockReturnValue({
@@ -365,16 +365,16 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
unassigned_weight_grams: 150,
});
const svc = makeService({ listBagsWithWeights } as Partial<PackingService>);
expect(new PackingController(svc).listBags(user, '5')).toEqual({
expect(await new PackingController(svc).listBags(user, '5')).toEqual({
bags: [{ id: 3, name: 'Carry-on', total_weight_grams: 800 }],
unassigned_weight_grams: 150,
});
expect(listBagsWithWeights).toHaveBeenCalledTimes(1);
});
it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', () => {
it('400 on bag create with blank name (bespoke check — the schema cannot see whitespace)', async () => {
const svc = makeService();
expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
status: 400, body: { error: 'Name is required' },
});
});
@@ -383,66 +383,66 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// (packingCreateBagRequestSchema requires a non-empty name) — direct method
// calls bypass parameter pipes, so that path is covered by the e2e suite.
it('creates a bag and broadcasts', () => {
it('creates a bag and broadcasts', async () => {
const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Carry-on' });
const broadcast = vi.fn();
const svc = makeService({ createBag, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({
expect(await new PackingController(svc).createBag(user, '5', { name: 'Carry-on', color: '#fff' }, 'sock')).toEqual({
bag: { id: 3, name: 'Carry-on' },
});
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-created', { bag: { id: 3, name: 'Carry-on' } }, 'sock');
});
it('forwards weight_limit_grams on bag create (#2154)', () => {
it('forwards weight_limit_grams on bag create (#2154)', async () => {
const createBag = vi.fn().mockReturnValue({ id: 3, name: 'Backpack', weight_limit_grams: 8000 });
const svc = makeService({ createBag, broadcast: vi.fn() } as Partial<PackingService>);
new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 });
await new PackingController(svc).createBag(user, '5', { name: 'Backpack', weight_limit_grams: 8000 });
expect(createBag).toHaveBeenCalledWith('5', { name: 'Backpack', color: undefined, weight_limit_grams: 8000 });
});
it('404 on bag update when missing', () => {
it('404 on bag update when missing', async () => {
const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('updates a bag, forwards changed keys and broadcasts', () => {
it('updates a bag, forwards changed keys and broadcasts', async () => {
const updateBag = vi.fn().mockReturnValue({ id: 3, name: 'X' });
const broadcast = vi.fn();
const svc = makeService({ updateBag, broadcast } as Partial<PackingService>);
new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock');
await new PackingController(svc).updateBag(user, '5', '3', { name: 'X', color: '#000' }, 'sock');
expect(updateBag).toHaveBeenCalledWith('5', '3', expect.objectContaining({ name: 'X', color: '#000' }), ['name', 'color']);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-updated', { bag: { id: 3, name: 'X' } }, 'sock');
});
it('404 on bag delete when missing', () => {
it('404 on bag delete when missing', async () => {
const svc = makeService({ deleteBag: vi.fn().mockReturnValue(false) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({
expect(await thrown(async () => await new PackingController(svc).deleteBag(user, '5', '3'))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('deletes a bag and broadcasts', () => {
it('deletes a bag and broadcasts', async () => {
const deleteBag = vi.fn().mockReturnValue(true);
const broadcast = vi.fn();
const svc = makeService({ deleteBag, broadcast } as Partial<PackingService>);
expect(new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true });
expect(await new PackingController(svc).deleteBag(user, '5', '3', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-deleted', { bagId: 3 }, 'sock');
});
it('404 on set-members when the bag is missing', () => {
it('404 on set-members when the bag is missing', async () => {
const svc = makeService({ setBagMembers: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }))).toEqual({
status: 404, body: { error: 'Bag not found' },
});
});
it('sets bag members and broadcasts', () => {
it('sets bag members and broadcasts', async () => {
const setBagMembers = vi.fn().mockReturnValue([{ user_id: 1 }, { user_id: 2 }]);
const broadcast = vi.fn();
const svc = makeService({ setBagMembers, broadcast } as Partial<PackingService>);
const res = new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock');
const res = await new PackingController(svc).setBagMembers(user, '5', '3', { user_ids: [1, 2] }, 'sock');
expect(res).toEqual({ members: [{ user_id: 1 }, { user_id: 2 }] });
expect(setBagMembers).toHaveBeenCalledWith('5', '3', [1, 2]);
expect(broadcast).toHaveBeenCalledWith('5', 'packing:bag-members-updated', { bagId: 3, members: [{ user_id: 1 }, { user_id: 2 }] }, 'sock');
@@ -454,27 +454,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('templates', () => {
it('GET /templates returns the template list for an accessible trip', () => {
it('GET /templates returns the template list for an accessible trip', async () => {
const listTemplates = vi.fn().mockReturnValue([{ id: 1, name: 'Beach', item_count: 4 }]);
const svc = makeService({ listTemplates } as Partial<PackingService>);
expect(new PackingController(svc).listTemplates(user, '5')).toEqual({
expect(await new PackingController(svc).listTemplates(user, '5')).toEqual({
templates: [{ id: 1, name: 'Beach', item_count: 4 }],
});
});
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
it('404 when applying a missing/empty template (POST stays 200 otherwise)', async () => {
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({
expect(await thrown(async () => await new PackingController(svc).applyTemplate(user, '5', 't1', {}))).toEqual({
status: 404, body: { error: 'Template not found or empty' },
});
});
it('applies a template, broadcasts the added items and reports the count', () => {
it('applies a template, broadcasts the added items and reports the count', async () => {
const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
const applyTemplate = vi.fn().mockReturnValue(items);
const broadcastItem = vi.fn();
const svc = makeService({ applyTemplate, broadcastItem } as Partial<PackingService>);
const res = new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock');
const res = await new PackingController(svc).applyTemplate(user, '5', 't1', {}, 'sock');
expect(res).toEqual({ items, count: 3 });
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id);
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock');
@@ -482,27 +482,27 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// #1565: the template must follow the tab the user is on, and a personal apply
// must not be broadcast to the rest of the trip.
it('applies a template into the personal list and keeps the broadcast to its owner', () => {
it('applies a template into the personal list and keeps the broadcast to its owner', async () => {
const items = [{ id: 1, is_private: 1, owner_id: user.id }];
const applyTemplate = vi.fn().mockReturnValue(items);
const broadcastItem = vi.fn();
const svc = makeService({ applyTemplate, broadcastItem } as Partial<PackingService>);
new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock');
await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'personal' }, 'sock');
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'personal', user.id);
expect(broadcastItem).toHaveBeenCalledWith('5', 'packing:template-applied', { items }, items[0], 'sock');
});
it('falls back to the common pool for an unknown visibility', () => {
it('falls back to the common pool for an unknown visibility', async () => {
const applyTemplate = vi.fn().mockReturnValue([{ id: 1 }]);
const svc = makeService({ applyTemplate } as Partial<PackingService>);
new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never);
await new PackingController(svc).applyTemplate(user, '5', 't1', { visibility: 'bogus' } as never);
expect(applyTemplate).toHaveBeenCalledWith('5', 't1', 'common', user.id);
});
it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', () => {
it('400 when an admin saves a template with no name (whitespace — the schema cannot see it)', async () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: ' ' }))).toEqual({
status: 400, body: { error: 'Template name is required' },
});
expect(saveAsTemplate).not.toHaveBeenCalled();
@@ -513,26 +513,26 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
// method calls bypass parameter pipes, so that path is covered by the e2e
// suite and the schema spec in @trek/shared.
it('403 when a non-admin tries to save a template', () => {
it('403 when a non-admin tries to save a template', async () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(user, '5', { name: 'My template' }))).toEqual({
status: 403, body: { error: 'Admin access required' },
});
expect(saveAsTemplate).not.toHaveBeenCalled();
});
it('400 when an admin saves a template with no items', () => {
it('400 when an admin saves a template with no items', async () => {
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({
expect(await thrown(async () => await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' }))).toEqual({
status: 400, body: { error: 'No items to save' },
});
});
it('saves a template for an admin', () => {
it('saves a template for an admin', async () => {
const saveAsTemplate = vi.fn().mockReturnValue({ id: 7, name: 'My template' });
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({
expect(await new PackingController(svc).saveAsTemplate(admin, '5', { name: 'My template' })).toEqual({
template: { id: 7, name: 'My template' },
});
expect(saveAsTemplate).toHaveBeenCalledWith('5', admin.id, 'My template');
@@ -540,30 +540,30 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('category assignees', () => {
it('GET /category-assignees returns the assignee list for an accessible trip', () => {
it('GET /category-assignees returns the assignee list for an accessible trip', async () => {
const getCategoryAssignees = vi.fn().mockReturnValue([{ category: 'Clothes', user_id: 2 }]);
const svc = makeService({ getCategoryAssignees } as Partial<PackingService>);
expect(new PackingController(svc).categoryAssignees(user, '5')).toEqual({
expect(await new PackingController(svc).categoryAssignees(user, '5')).toEqual({
assignees: [{ category: 'Clothes', user_id: 2 }],
});
expect(getCategoryAssignees).toHaveBeenCalledWith('5');
});
it('decodes the URI-encoded category name before forwarding', () => {
it('decodes the URI-encoded category name before forwarding', async () => {
const updateCategoryAssignees = vi.fn().mockReturnValue([]);
const broadcast = vi.fn();
const notifyTagged = vi.fn();
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] });
await new PackingController(svc).updateCategoryAssignees(user, '5', 'Toys%20%26%20Games', { user_ids: [2] });
expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'Toys & Games', [2]);
});
it('updates assignees, broadcasts and fires the tag notification', () => {
it('updates assignees, broadcasts and fires the tag notification', async () => {
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
const broadcast = vi.fn();
const notifyTagged = vi.fn();
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
const res = new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock');
const res = await new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', { user_ids: [2] }, 'sock');
expect(res).toEqual({ assignees: [{ user_id: 2 }] });
expect(broadcast).toHaveBeenCalledWith('5', 'packing:assignees', { category: 'Clothes', assignees: [{ user_id: 2 }] }, 'sock');
expect(notifyTagged).toHaveBeenCalledWith('5', user, 'Clothes', [2]);
File diff suppressed because it is too large Load Diff
+53 -52
View File
@@ -73,6 +73,7 @@ beforeAll(async () => {
permissionsStub,
new QueryHelpersService(new DatabaseService(testDb)),
photoCacheStub,
await createTestUnitOfWork(testDb),
);
});
@@ -91,10 +92,10 @@ function shareRow(tripId: number | string) {
}
/** Owner + trip + a share link with all five flags on unless overridden. */
function seedSharedTrip(flags: Record<string, boolean> = {}) {
async function seedSharedTrip(flags: Record<string, boolean> = {}) {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, {
const { token } = await svc.createOrUpdate(String(trip.id), user.id, {
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
...flags,
});
@@ -104,15 +105,15 @@ function seedSharedTrip(flags: Record<string, boolean> = {}) {
// ── verifyTripAccess / canManage ──────────────────────────────────────────────
describe('verifyTripAccess and canManage', () => {
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => {
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id);
expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
expect((await svc.verifyTripAccess(String(trip.id), owner.id))?.id).toBe(trip.id);
expect(await svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
expect(await svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
});
it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', async () => {
@@ -130,11 +131,11 @@ describe('verifyTripAccess and canManage', () => {
// ── createOrUpdate ────────────────────────────────────────────────────────────
describe('createOrUpdate', () => {
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => {
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const before = Date.now();
const result = svc.createOrUpdate(String(trip.id), user.id, {});
const result = await svc.createOrUpdate(String(trip.id), user.id, {});
expect(result.created).toBe(true);
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
const row = shareRow(trip.id);
@@ -148,10 +149,10 @@ describe('createOrUpdate', () => {
expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000);
});
it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => {
it('SHARE-SVC-004: explicit flags override the defaults in both directions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {
await svc.createOrUpdate(String(trip.id), user.id, {
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true,
});
const row = shareRow(trip.id);
@@ -159,24 +160,24 @@ describe('createOrUpdate', () => {
.toEqual([0, 0, 1, 1, 1]);
});
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => {
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = svc.createOrUpdate(String(trip.id), user.id, {});
const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
const first = await svc.createOrUpdate(String(trip.id), user.id, {});
const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
expect(second).toEqual({ token: first.token, created: false });
expect(shareRow(trip.id).share_budget).toBe(1);
});
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', () => {
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
// Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it
// onto the 90-day clock.
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
const before = Date.now();
svc.createOrUpdate(String(trip.id), user.id, {});
await svc.createOrUpdate(String(trip.id), user.id, {});
const row = shareRow(trip.id);
// Omitted share_packing fell back to its default (off) — destructuring
// defaults apply on every call, not only on create.
@@ -191,17 +192,17 @@ describe('createOrUpdate', () => {
// ── get / remove ──────────────────────────────────────────────────────────────
describe('get and remove', () => {
it('SHARE-SVC-007: get returns null when no link exists', () => {
it('SHARE-SVC-007: get returns null when no link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.get(String(trip.id))).toBeNull();
expect(await svc.get(String(trip.id))).toBeNull();
});
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => {
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
const info = svc.get(String(trip.id));
const { token } = await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
const info = await svc.get(String(trip.id));
expect(info).toEqual({
token,
created_at: expect.any(String),
@@ -213,13 +214,13 @@ describe('get and remove', () => {
});
});
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => {
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {});
svc.remove(String(trip.id));
await svc.createOrUpdate(String(trip.id), user.id, {});
await svc.remove(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
expect(() => svc.remove(String(trip.id))).not.toThrow();
await expect(svc.remove(String(trip.id))).resolves.toBeUndefined();
});
});
@@ -231,7 +232,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(await svc.getSharedTripData(token)).toBeNull();
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
@@ -239,7 +240,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-012: returns null when the trip row is gone', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
// The token normally cascades away with its trip; orphan it deliberately
// to pin the defensive `if (!trip) return null` branch.
testDb.exec('PRAGMA foreign_keys = OFF');
@@ -249,7 +250,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
@@ -275,7 +276,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-014: COALESCEs assignment times over place times', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
@@ -290,7 +291,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const p1 = createPlace(testDb, trip.id, { name: 'First' });
const p2 = createPlace(testDb, trip.id, { name: 'Second' });
@@ -305,7 +306,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id);
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id);
@@ -319,7 +320,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id);
const packing = (await (await svc.getSharedTripData(token))!).packing;
@@ -327,19 +328,19 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id);
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id);
const collab = (await (await svc.getSharedTripData(token))!).collab;
expect(collab).toHaveLength(1);
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
await svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
expect((await (await svc.getSharedTripData(token))!).collab).toEqual([]);
});
it('SHARE-SVC-019: withholds each section when its flag is off', async () => {
const { trip, token } = seedSharedTrip({
const { trip, token } = await seedSharedTrip({
share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false,
});
const day = createDay(testDb, trip.id);
@@ -361,7 +362,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => {
const { user, trip, token } = seedSharedTrip();
const { user, trip, token } = await seedSharedTrip();
expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('EUR');
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
expect((await (await svc.getSharedTripData(token))!).baseCurrency).toBe('USD');
@@ -370,7 +371,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
createPlace(testDb, trip.id, { name: 'Bare' });
@@ -385,7 +386,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user, token } = seedSharedTrip();
const { user, token } = await seedSharedTrip();
expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
expect((await (await svc.getSharedTripData(token))!).cartoApiKey).toBe('instance-key');
@@ -394,7 +395,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state)
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id);
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
@@ -408,7 +409,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`);
@@ -418,7 +419,7 @@ describe('getSharedTripData', () => {
});
it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const stay = (): number => testDb.prepare(`
@@ -440,7 +441,7 @@ describe('getSharedTripData', () => {
describe('getSharedTripData redaction (#2320)', () => {
it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare(`INSERT INTO reservations
(trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review)
VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`)
@@ -459,7 +460,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const metadata = JSON.stringify({
airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK',
seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123',
@@ -489,7 +490,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-034: a booking carries its ordered stops', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status)
VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid;
const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng)
@@ -506,7 +507,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) {
testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url);
@@ -524,7 +525,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue',
@@ -547,7 +548,7 @@ describe('getSharedTripData redaction (#2320)', () => {
});
it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
@@ -560,7 +561,7 @@ describe('getSharedTripData redaction (#2320)', () => {
expect(JSON.stringify(data)).not.toContain('HOTEL-777');
});
it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => {
it('SHARE-SVC-038: metadata that is not an object comes back as nothing', async () => {
expect(publicReservationMetadata(null)).toBeNull();
expect(publicReservationMetadata('not json')).toBeNull();
expect(publicReservationMetadata('[1,2]')).toBeNull();
@@ -575,14 +576,14 @@ describe('getSharedTripData redaction (#2320)', () => {
describe('getSharedPlacePhotoKey', () => {
it('SHARE-SVC-022: returns null for an unknown or expired token', async () => {
expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull();
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => {
const { trip, token } = seedSharedTrip({ share_map: false });
const { trip, token } = await seedSharedTrip({ share_map: false });
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
@@ -590,13 +591,13 @@ describe('getSharedPlacePhotoKey', () => {
});
it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', async () => {
const { token } = seedSharedTrip();
const { token } = await seedSharedTrip();
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
expect(serveKey).not.toHaveBeenCalled();
});
it('SHARE-SVC-025: resolves via serveKey when the encoded placeId matches the stored URL', async () => {
const { trip, token } = seedSharedTrip();
const { trip, token } = await seedSharedTrip();
const place = createPlace(testDb, trip.id);
// Wikimedia pseudo-IDs contain characters that must round-trip encoded.
const placeId = 'coords:48.8,2.3';
@@ -43,8 +43,8 @@ afterAll(() => 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.
let seq = 0;
function makePhoto(): number {
return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
async function makePhoto(): Promise<number> {
return await repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function read(id: number) {
@@ -54,41 +54,41 @@ function read(id: number) {
}
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
it('TREKPHOTO-001: stores the capture time and coordinates', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
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 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
// The album listing knows the date but not the place.
repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
await repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-003: fills only the half that was still missing', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
it('TREKPHOTO-003: fills only the half that was still missing', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
await repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
});
it('TREKPHOTO-005: an answer with nothing in it touches no row', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
repo.recordCaptureMetadata(id, {});
it('TREKPHOTO-005: an answer with nothing in it touches no row', async () => {
const id = await makePhoto();
await repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
await repo.recordCaptureMetadata(id, {});
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
@@ -116,7 +116,7 @@ beforeAll(async () => {
const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) =>
new TripReadModelService(
database, roster, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()),
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
+1 -1
View File
@@ -152,7 +152,7 @@ beforeAll(async () => {
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
readModelSvc = new TripReadModelService(
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub()),
new PackingService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService(), await createTestUnitOfWork(dbs().connection)),
placesSvc,
@@ -51,32 +51,39 @@ 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';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork } from '../../helpers/test-uow';
// The album-sync paths are the providers' half and have their own suites; these
// cases never reach them, so stubs keep the graph small.
const dbs = new DatabaseService(testDb);
const svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
notificationsStub(),
new AddonsService(dbs),
);
let svc: UnifiedMemoriesService;
// Legacy free-function names bound to the service, so the moved cases read as before.
const listTripPhotos = svc.listTripPhotos.bind(svc);
const listTripAlbumLinks = svc.listTripAlbumLinks.bind(svc);
const addTripPhotos = svc.addTripPhotos.bind(svc);
const setTripPhotoSharing = svc.setTripPhotoSharing.bind(svc);
const removeTripPhoto = svc.removeTripPhoto.bind(svc);
const createTripAlbumLink = svc.createTripAlbumLink.bind(svc);
const removeAlbumLink = svc.removeAlbumLink.bind(svc);
// Legacy free-function names forwarding to the service, so the moved cases read
// as before. Typed forwarders rather than `.bind` aliases: a bound alias is
// typed `any`, which hides a missing `await` from tsc and from all three lint
// rules now that these methods are async.
type Svc = UnifiedMemoriesService;
const listTripPhotos = (...a: Parameters<Svc['listTripPhotos']>) => svc.listTripPhotos(...a);
const listTripAlbumLinks = (...a: Parameters<Svc['listTripAlbumLinks']>) => svc.listTripAlbumLinks(...a);
const addTripPhotos = (...a: Parameters<Svc['addTripPhotos']>) => svc.addTripPhotos(...a);
const setTripPhotoSharing = (...a: Parameters<Svc['setTripPhotoSharing']>) => svc.setTripPhotoSharing(...a);
const removeTripPhoto = (...a: Parameters<Svc['removeTripPhoto']>) => svc.removeTripPhoto(...a);
const createTripAlbumLink = (...a: Parameters<Svc['createTripAlbumLink']>) => svc.createTripAlbumLink(...a);
const removeAlbumLink = (...a: Parameters<Svc['removeAlbumLink']>) => svc.removeAlbumLink(...a);
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new UnifiedMemoriesService(
dbs,
new TrekPhotosRepository(dbs),
{} as ImmichService,
{} as SynologyService,
new MemoriesAccessService(dbs),
notificationsStub(),
new AddonsService(dbs),
await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
@@ -200,7 +207,7 @@ describe('addTripPhotos', () => {
describe('setTripPhotoSharing', () => {
it('MEM-UNIFIED-008: returns 404 when user cannot access trip', async () => {
const result = await setTripPhotoSharing('9999', 1, 'immich', 'asset-1', true);
const result = await setTripPhotoSharing('9999', 1, 1, true);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -209,8 +216,8 @@ describe('setTripPhotoSharing', () => {
// ── removeTripPhoto ───────────────────────────────────────────────────────────
describe('removeTripPhoto', () => {
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', () => {
const result = removeTripPhoto('9999', 1, 'immich', 'asset-1');
it('MEM-UNIFIED-009: returns 404 when user cannot access trip', async () => {
const result = await removeTripPhoto('9999', 1, 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -242,8 +249,8 @@ describe('createTripAlbumLink', () => {
// ── removeAlbumLink ───────────────────────────────────────────────────────────
describe('removeAlbumLink', () => {
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', () => {
const result = removeAlbumLink('9999', '1', 1);
it('MEM-UNIFIED-012: returns 404 when user cannot access trip', async () => {
const result = await removeAlbumLink('9999', '1', 1);
expect(result.success).toBe(false);
expect((result as any).error.status).toBe(404);
});
@@ -32,7 +32,7 @@ type Item = { id: number; name?: string; is_private?: number; owner_id?: number
*/
function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: Item | null } = {}) {
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub());
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub(), {} as never);
const data = {
listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]),
// The bodies here are the boring fixture; the item writes are typed as Item so a
@@ -68,7 +68,7 @@ const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/go
let packing: PackingService;
let places: PlacesService;
beforeAll(async () => {
packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub());
packing = new PackingService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
places = new PlacesService(
dbs,
new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)),
@@ -145,25 +145,25 @@ describe('PlacesService.update — optimistic concurrency', () => {
});
describe('updateItem (packing) — optimistic concurrency', () => {
it('migration added updated_at and createItem stamps it', () => {
it('migration added updated_at and createItem stamps it', async () => {
const cols = testDb.prepare("PRAGMA table_info('packing_items')").all() as { name: string }[];
expect(cols.map(c => c.name)).toContain('updated_at');
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null };
const item = await packing.createItem(trip.id, { name: 'Socks' }) as { id: number; updated_at: string | null };
expect(item.updated_at).toBeTruthy();
});
it('returns a conflict when the packing token is stale', () => {
it('returns a conflict when the packing token is stale', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string };
const item = await packing.createItem(trip.id, { name: 'Socks' }, user.id) as { id: number; updated_at: string };
const stale = packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id);
const stale = await packing.updateItem(trip.id, item.id, { name: 'Mine' }, ['name'], '1999-01-01 00:00:00', user.id);
expect(isUpdateConflict(stale)).toBe(true);
const fresh = packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id);
const fresh = await packing.updateItem(trip.id, item.id, { name: 'Edited' }, ['name'], item.updated_at, user.id);
expect(isUpdateConflict(fresh)).toBe(false);
expect((fresh as { name: string }).name).toBe('Edited');
});
@@ -54,22 +54,22 @@ describe('trek_photos media_type', () => {
expect(cols).toContain('duration_ms');
});
it('a local photo defaults to image', () => {
const id = getOrCreateLocalTrekPhoto('journey/a.jpg');
expect(resolveTrekPhoto(id)!.media_type).toBe('image');
it('a local photo defaults to image', async () => {
const id = await getOrCreateLocalTrekPhoto('journey/a.jpg');
expect((await resolveTrekPhoto(id))!.media_type).toBe('image');
});
it('a local video stores media_type=video + duration', () => {
const id = getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200);
const row = resolveTrekPhoto(id)!;
it('a local video stores media_type=video + duration', async () => {
const id = await getOrCreateLocalTrekPhoto('journey/clip.mp4', 'journey/poster.jpg', null, null, 'video', 4200);
const row = (await resolveTrekPhoto(id))!;
expect(row.media_type).toBe('video');
expect(row.duration_ms).toBe(4200);
expect(row.thumbnail_path).toBe('journey/poster.jpg');
});
it('a provider photo can be registered as video', () => {
it('a provider photo can be registered as video', async () => {
const { user } = createUser(testDb);
const id = getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video');
expect(resolveTrekPhoto(id)!.media_type).toBe('video');
const id = await getOrCreateTrekPhoto('immich', 'asset-1', user.id, undefined, 'video');
expect((await resolveTrekPhoto(id))!.media_type).toBe('video');
});
});