mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
743b724cbc
Rework the private-packing flag into a full sharing model. Every item is now Common (the group pool — where all existing items live, so nothing breaks), Personal (private to its owner) or Shared with specific people (it shows up on those travelers' own lists, marked "by <bringer>"). is_private discriminates restricted from common; a new packing_item_recipients table holds who a shared item covers, and packing_item_contributors records "I can bring that too" pledges on Common items. The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing control (owner sets the tier + the people it covers), and Common items can be co-brought or cloned onto your personal list. Visibility is enforced server-side in listItems and the WebSocket broadcasts are scoped to exactly who can see an item across every tier transition. All 22 locales stay in parity.
149 lines
8.1 KiB
TypeScript
149 lines
8.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { dbMock } = vi.hoisted(() => {
|
|
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
|
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
|
});
|
|
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
|
|
|
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
|
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
|
|
|
const { pk } = vi.hoisted(() => ({
|
|
pk: {
|
|
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), deleteItem: vi.fn(),
|
|
bulkImport: vi.fn(), listBags: vi.fn(), createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(),
|
|
listTemplates: vi.fn(), applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
|
|
updateCategoryAssignees: vi.fn(), reorderItems: vi.fn(),
|
|
setItemSharing: vi.fn(), addContributor: vi.fn(), removeContributor: vi.fn(), cloneItem: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('../../../src/services/packingService', () => pk);
|
|
|
|
const { send } = vi.hoisted(() => ({ send: vi.fn(() => Promise.resolve()) }));
|
|
vi.mock('../../../src/services/notificationService', () => ({ send }));
|
|
|
|
import { PackingService } from '../../../src/nest/packing/packing.service';
|
|
|
|
function svc() {
|
|
return new PackingService();
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe('PackingService (wrapper delegation + helpers)', () => {
|
|
it('canEdit delegates to checkPermission with packing_edit', () => {
|
|
svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
|
expect(checkPermission).toHaveBeenCalledWith('packing_edit', 'user', 2, 1, true);
|
|
});
|
|
|
|
it('broadcast forwards to the websocket helper', () => {
|
|
svc().broadcast('5', 'packing:created', { item: 1 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock');
|
|
});
|
|
|
|
describe('broadcastItem (private-item scoping, #858)', () => {
|
|
it('broadcasts a shared item to the whole room (no onlyUserId)', () => {
|
|
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 0, owner_id: 7 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined);
|
|
});
|
|
|
|
it('scopes a private item to its owner', () => {
|
|
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: 7 }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 7);
|
|
});
|
|
|
|
it('falls back to a room broadcast when the private item has no owner', () => {
|
|
svc().broadcastItem('5', 'packing:created', { item: 1 }, { is_private: 1, owner_id: null }, 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', undefined);
|
|
});
|
|
});
|
|
|
|
it('getItemPrivacy reads the privacy fields for an item', () => {
|
|
dbMock._stmt.get.mockReturnValueOnce({ is_private: 1, owner_id: 3 });
|
|
expect(svc().getItemPrivacy('5', '9')).toEqual({ is_private: 1, owner_id: 3 });
|
|
expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('is_private, owner_id'));
|
|
});
|
|
|
|
it('forwards every item/bag/template/assignee call to the legacy service', () => {
|
|
const s = svc();
|
|
s.verifyTripAccess('5', 1); expect(pk.verifyTripAccess).toHaveBeenCalledWith('5', 1);
|
|
s.listItems('5'); expect(pk.listItems).toHaveBeenCalledWith('5', undefined);
|
|
s.createItem('5', { name: 'a' }); expect(pk.createItem).toHaveBeenCalledWith('5', { name: 'a' }, undefined);
|
|
s.updateItem('5', '2', { name: 'b' } as never, ['name']); expect(pk.updateItem).toHaveBeenCalledWith('5', '2', { name: 'b' }, ['name'], undefined, undefined);
|
|
s.deleteItem('5', '2'); expect(pk.deleteItem).toHaveBeenCalledWith('5', '2');
|
|
s.bulkImport('5', [{ name: 'x' }] as never); expect(pk.bulkImport).toHaveBeenCalledWith('5', [{ name: 'x' }], undefined);
|
|
s.reorderItems('5', [3, 1] as never); expect(pk.reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
|
s.listBags('5'); expect(pk.listBags).toHaveBeenCalledWith('5');
|
|
s.createBag('5', { name: 'Bag' }); expect(pk.createBag).toHaveBeenCalledWith('5', { name: 'Bag' });
|
|
s.updateBag('5', '2', { name: 'B' } as never, ['name']); expect(pk.updateBag).toHaveBeenCalledWith('5', '2', { name: 'B' }, ['name']);
|
|
s.deleteBag('5', '2'); expect(pk.deleteBag).toHaveBeenCalledWith('5', '2');
|
|
s.setBagMembers('5', '2', [1, 2]); expect(pk.setBagMembers).toHaveBeenCalledWith('5', '2', [1, 2]);
|
|
s.listTemplates(); expect(pk.listTemplates).toHaveBeenCalled();
|
|
s.applyTemplate('5', 't1'); expect(pk.applyTemplate).toHaveBeenCalledWith('5', 't1');
|
|
s.saveAsTemplate('5', 1, 'Tpl'); expect(pk.saveAsTemplate).toHaveBeenCalledWith('5', 1, 'Tpl');
|
|
s.getCategoryAssignees('5'); expect(pk.getCategoryAssignees).toHaveBeenCalledWith('5');
|
|
s.updateCategoryAssignees('5', 'Clothes', [2]); expect(pk.updateCategoryAssignees).toHaveBeenCalledWith('5', 'Clothes', [2]);
|
|
s.setItemSharing('5', '2', 1, 'shared', [3]); expect(pk.setItemSharing).toHaveBeenCalledWith('5', '2', 1, 'shared', [3]);
|
|
s.addContributor('5', '2', 3); expect(pk.addContributor).toHaveBeenCalledWith('5', '2', 3);
|
|
s.removeContributor('5', '2', 3); expect(pk.removeContributor).toHaveBeenCalledWith('5', '2', 3);
|
|
s.cloneItem('5', '2', 7); expect(pk.cloneItem).toHaveBeenCalledWith('5', '2', 7);
|
|
});
|
|
|
|
describe('viewersOf + broadcastToViewers (#858 three-tier)', () => {
|
|
it('viewersOf: Common → null (whole room); restricted → owner + recipients', () => {
|
|
expect(svc().viewersOf({ is_private: 0, owner_id: 1 })).toBeNull();
|
|
expect(svc().viewersOf(null)).toBeNull();
|
|
expect(svc().viewersOf({ is_private: 1, owner_id: 1, recipients: [{ user_id: 2 }, { user_id: 3 }] })).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('broadcastToViewers delivers to each viewer (deduped) via onlyUserId', () => {
|
|
svc().broadcastToViewers('5', 'packing:created', { item: 1 }, [1, 2, 2], 'sock');
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 1);
|
|
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock', 2);
|
|
expect(broadcast).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('notifyTagged', () => {
|
|
it('does nothing when no users are tagged', () => {
|
|
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', []);
|
|
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', 'nope');
|
|
expect(dbMock.prepare).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fires the notification when users are tagged (fire-and-forget, no throw)', () => {
|
|
expect(() => svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3])).not.toThrow();
|
|
});
|
|
|
|
it('queries the trip title and dispatches the notification with the resolved title', async () => {
|
|
dbMock._stmt.get.mockReturnValue({ title: 'Iceland 2026' });
|
|
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3]);
|
|
// Flush the dynamic import().then microtask chain.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(dbMock.prepare).toHaveBeenCalledWith('SELECT title FROM trips WHERE id = ?');
|
|
expect(send).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
event: 'packing_tagged',
|
|
actorId: 1,
|
|
scope: 'trip',
|
|
targetId: 5,
|
|
params: expect.objectContaining({ trip: 'Iceland 2026', actor: 'a@b.c', category: 'Clothes', tripId: '5' }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('falls back to "Untitled" when the trip row is missing (?? / default branch)', async () => {
|
|
dbMock._stmt.get.mockReturnValue(undefined);
|
|
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2]);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(send).toHaveBeenCalledWith(
|
|
expect.objectContaining({ params: expect.objectContaining({ trip: 'Untitled' }) }),
|
|
);
|
|
});
|
|
});
|
|
});
|