mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user