mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
test(server): seed the collections MCP and in-app notification specs through the factories
Converts the raw prepare() fixtures and reads in the collections MCP spec and the in-app notification preference spec to the ORM factories and readers. The collection, member and saved place helpers and the boolean notification helper keep their signatures and simply resolve to the new row's id, so each case reads as it did; the four copies of the boolean notification INSERT collapse into one helper over makeNotification.
This commit is contained in:
@@ -31,6 +31,15 @@ const { notifSend } = vi.hoisted(() => ({ notifSend: vi.fn().mockResolvedValue(u
|
||||
import { createUser, createTrip, createPlace, createCategory } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { addCollectionMember } from '../../helpers/factories/collections';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity';
|
||||
import { CollectionMembers } from '../../../src/db/entities/CollectionMembers.entity';
|
||||
import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity';
|
||||
import { Collections } from '../../../src/db/entities/Collections.entity';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
|
||||
function clearCollections() {
|
||||
testDb.exec(`
|
||||
@@ -52,7 +61,8 @@ function clearCollections() {
|
||||
beforeAll(async () => {
|
||||
// The collections addon is seeded DISABLED by default and every tool rides
|
||||
// the `when:` addon gate (post-fold quirk fix) — enable it for the suite.
|
||||
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.COLLECTIONS);
|
||||
orm = await createTestOrm(testDb);
|
||||
await updateRows(orm, Addons, { id: ADDON_IDS.COLLECTIONS }, { enabled: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -62,7 +72,8 @@ beforeEach(() => {
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -76,28 +87,36 @@ function errorText(result: Awaited<ReturnType<McpHarness['client']['callTool']>>
|
||||
return text?.text ?? '';
|
||||
}
|
||||
|
||||
let orm: TestOrm;
|
||||
|
||||
/** Seed a collection directly (owner_id row only — no service round trip). */
|
||||
function seedCollection(ownerId: number, name = 'List'): number {
|
||||
return Number(testDb.prepare('INSERT INTO collections (owner_id, name, sort_order) VALUES (?, ?, 0)').run(ownerId, name).lastInsertRowid);
|
||||
function seedCollection(ownerId: number, name = 'List'): Promise<number> {
|
||||
return insertRow(orm, Collections, { owner: ownerId, name, sort_order: 0 });
|
||||
}
|
||||
|
||||
function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin' = 'editor', status: 'accepted' | 'pending' = 'accepted') {
|
||||
testDb.prepare('INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, ?, ?)').run(colId, userId, status, role);
|
||||
async function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin' = 'editor', status: 'accepted' | 'pending' = 'accepted') {
|
||||
await addCollectionMember(orm, colId, userId, { status, role });
|
||||
}
|
||||
|
||||
function seedPlace(colId: number, ownerId: number, name: string, extra: Record<string, unknown> = {}): number {
|
||||
return Number(testDb.prepare(
|
||||
`INSERT INTO collection_places (collection_id, owner_id, saved_by, name, lat, lng, status, google_place_id, source_trip_id, source_place_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
colId, ownerId, ownerId, name,
|
||||
extra.lat ?? null, extra.lng ?? null, extra.status ?? 'idea',
|
||||
extra.google_place_id ?? null, extra.source_trip_id ?? null, extra.source_place_id ?? null,
|
||||
).lastInsertRowid);
|
||||
interface SeedPlaceExtra {
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
status?: string;
|
||||
google_place_id?: string | null;
|
||||
source_trip_id?: number | null;
|
||||
source_place_id?: number | null;
|
||||
}
|
||||
|
||||
function statusOf(placeId: number): string | undefined {
|
||||
return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string } | undefined)?.status;
|
||||
function seedPlace(colId: number, ownerId: number, name: string, extra: SeedPlaceExtra = {}): Promise<number> {
|
||||
return insertRow(orm, CollectionPlaces, {
|
||||
collection: colId, owner: ownerId, savedByRef: ownerId, name,
|
||||
lat: extra.lat ?? null, lng: extra.lng ?? null, status: extra.status ?? 'idea',
|
||||
google_place_id: extra.google_place_id ?? null, source_trip_id: extra.source_trip_id ?? null, source_place_id: extra.source_place_id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function statusOf(placeId: number): Promise<string | undefined> {
|
||||
return (await findRow(orm, CollectionPlaces, { id: placeId }))?.status;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,9 +127,9 @@ describe('Tool: list_collections', () => {
|
||||
it('lists owned collections plus incoming invites', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: inviter } = createUser(testDb);
|
||||
seedCollection(user.id, 'Mine');
|
||||
const invitedTo = seedCollection(inviter.id, 'Theirs');
|
||||
addMember(invitedTo, user.id, 'editor', 'pending');
|
||||
await seedCollection(user.id, 'Mine');
|
||||
const invitedTo = await seedCollection(inviter.id, 'Theirs');
|
||||
await addMember(invitedTo, user.id, 'editor', 'pending');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'list_collections', arguments: {} })) as {
|
||||
collections: { name: string }[]; incomingInvites: { collection_id: number }[];
|
||||
@@ -124,8 +143,8 @@ describe('Tool: list_collections', () => {
|
||||
describe('Tool: get_collection', () => {
|
||||
it('returns the detail with places, labels and members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Detail');
|
||||
seedPlace(col, user.id, 'Louvre');
|
||||
const col = await seedCollection(user.id, 'Detail');
|
||||
await seedPlace(col, user.id, 'Louvre');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'get_collection', arguments: { collectionId: col } })) as {
|
||||
collection: { id: number; labels: unknown[] }; places: { name: string }[];
|
||||
@@ -138,7 +157,7 @@ describe('Tool: get_collection', () => {
|
||||
it('surfaces the 404 error text for an inaccessible collection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Hidden');
|
||||
const col = await seedCollection(owner.id, 'Hidden');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_collection', arguments: { collectionId: col } });
|
||||
expect(result.isError).toBe(true);
|
||||
@@ -153,8 +172,8 @@ describe('Tool: available_collection_users', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const { user: invitable } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Members');
|
||||
addMember(col, member.id);
|
||||
const col = await seedCollection(owner.id, 'Members');
|
||||
await addMember(col, member.id);
|
||||
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'available_collection_users', arguments: { collectionId: col } })) as { users: { id: number }[] };
|
||||
@@ -190,13 +209,13 @@ describe('Tool: find_place_in_collections', () => {
|
||||
it('names every visible list holding the place, with its per-list status and edit right', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const mine = seedCollection(user.id, 'Paris');
|
||||
const shared = seedCollection(other.id, 'Their museums');
|
||||
const hidden = seedCollection(other.id, 'Not shared');
|
||||
addMember(shared, user.id, 'viewer');
|
||||
seedPlace(mine, user.id, 'Louvre', { google_place_id: 'g-louvre', status: 'want' });
|
||||
seedPlace(shared, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
seedPlace(hidden, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
const mine = await seedCollection(user.id, 'Paris');
|
||||
const shared = await seedCollection(other.id, 'Their museums');
|
||||
const hidden = await seedCollection(other.id, 'Not shared');
|
||||
await addMember(shared, user.id, 'viewer');
|
||||
await seedPlace(mine, user.id, 'Louvre', { google_place_id: 'g-louvre', status: 'want' });
|
||||
await seedPlace(shared, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
await seedPlace(hidden, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
@@ -214,8 +233,8 @@ describe('Tool: find_place_in_collections', () => {
|
||||
|
||||
it('matches by coordinates within the dedup tolerance and not outside it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Rome');
|
||||
seedPlace(col, user.id, 'Pantheon', { lat: 41.8986, lng: 12.4769 });
|
||||
const col = await seedCollection(user.id, 'Rome');
|
||||
await seedPlace(col, user.id, 'Pantheon', { lat: 41.8986, lng: 12.4769 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const near = parseToolResult(await h.client.callTool({
|
||||
@@ -236,8 +255,8 @@ describe('Tool: find_place_in_collections', () => {
|
||||
|
||||
it('reports nothing for a name-only query, since a repeated name is not a match signal', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Coffee');
|
||||
seedPlace(col, user.id, 'Starbucks', { lat: 47.6062, lng: -122.3321 });
|
||||
const col = await seedCollection(user.id, 'Coffee');
|
||||
await seedPlace(col, user.id, 'Starbucks', { lat: 47.6062, lng: -122.3321 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
@@ -267,19 +286,19 @@ describe('Tool: create_collection / update_collection / delete_collection / reor
|
||||
const second = parseToolResult(await h.client.callTool({ name: 'create_collection', arguments: { name: 'France' } })) as { collection: { id: number } };
|
||||
const reordered = parseToolResult(await h.client.callTool({ name: 'reorder_collections', arguments: { orderedIds: [second.collection.id, created.collection.id] } }));
|
||||
expect(reordered).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(second.collection.id)).toEqual({ sort_order: 0 });
|
||||
expect((await findRow(orm, Collections, { id: second.collection.id }))?.sort_order).toBe(0);
|
||||
|
||||
const deleted = parseToolResult(await h.client.callTool({ name: 'delete_collection', arguments: { collectionId: created.collection.id } }));
|
||||
expect(deleted).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(created.collection.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, Collections, { id: created.collection.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('delete_collection is owner-only (bespoke 403 text) and update surfaces the viewer 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: viewer } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Guarded');
|
||||
addMember(col, viewer.id, 'viewer');
|
||||
const col = await seedCollection(owner.id, 'Guarded');
|
||||
await addMember(col, viewer.id, 'viewer');
|
||||
await withHarness(viewer.id, async (h) => {
|
||||
const del = await h.client.callTool({ name: 'delete_collection', arguments: { collectionId: col } });
|
||||
expect(del.isError).toBe(true);
|
||||
@@ -308,7 +327,7 @@ describe('Tool: create_collection / update_collection / delete_collection / reor
|
||||
describe('Tool: save_place_to_collection', () => {
|
||||
it('saves a place and returns the duplicate marker on a re-save', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Dedup');
|
||||
const col = await seedCollection(user.id, 'Dedup');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const saved = parseToolResult(await h.client.callTool({ name: 'save_place_to_collection', arguments: { collection_id: col, name: 'Eiffel Tower' } })) as { place?: { id: number } };
|
||||
expect(saved.place).toBeDefined();
|
||||
@@ -325,7 +344,7 @@ describe('Tool: save_place_to_collection', () => {
|
||||
describe('Tool: save_place_to_collection and a website without a scheme (#2483)', () => {
|
||||
it('MCP-COLL-2483-01: stores https for a bare host and still refuses a script link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Bretagne');
|
||||
const col = await seedCollection(user.id, 'Bretagne');
|
||||
const site = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët';
|
||||
await withHarness(user.id, async (h) => {
|
||||
const saved = parseToolResult(await h.client.callTool({
|
||||
@@ -337,7 +356,7 @@ describe('Tool: save_place_to_collection and a website without a scheme (#2483)'
|
||||
const refused = await h.client.callTool({ name: 'save_place_to_collection', arguments: { collection_id: col, name: 'Hostile', website: 'javascript:alert(1)' } });
|
||||
expect(refused.isError).toBe(true);
|
||||
});
|
||||
expect(testDb.prepare('SELECT website FROM collection_places WHERE collection_id = ?').all(col)).toEqual([{ website: `https://${site}` }]);
|
||||
expect((await findRows(orm, CollectionPlaces, { collection: col })).map((r) => ({ website: r.website }))).toEqual([{ website: `https://${site}` }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,8 +367,8 @@ describe('Tool: save_trip_places_to_collection', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const p1 = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
||||
const p2 = createPlace(testDb, trip.id, { name: 'Pantheon' });
|
||||
const col = seedCollection(user.id, 'From trip');
|
||||
seedPlace(col, user.id, 'Pantheon'); // pre-existing → duplicate
|
||||
const col = await seedCollection(user.id, 'From trip');
|
||||
await seedPlace(col, user.id, 'Pantheon'); // pre-existing → duplicate
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'save_trip_places_to_collection',
|
||||
@@ -364,8 +383,8 @@ describe('Tool: save_trip_places_to_collection', () => {
|
||||
describe('Tool: update_collection_place / set_collection_place_status / rate_collection_place', () => {
|
||||
it('updates fields, sets status, and stores/clears a rating', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'P');
|
||||
const pid = seedPlace(col, user.id, 'Gate');
|
||||
const col = await seedCollection(user.id, 'P');
|
||||
const pid = await seedPlace(col, user.id, 'Gate');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const upd = parseToolResult(await h.client.callTool({ name: 'update_collection_place', arguments: { placeId: pid, name: 'Brandenburg Gate' } })) as { place: { name: string } };
|
||||
expect(upd.place.name).toBe('Brandenburg Gate');
|
||||
@@ -400,13 +419,13 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const louvre = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
||||
const orsay = createPlace(testDb, trip.id, { name: "Musee d'Orsay", lat: 48.86, lng: 2.3266 });
|
||||
const paris = seedCollection(user.id, 'Paris');
|
||||
const museums = seedCollection(user.id, 'Museums');
|
||||
const paris = await seedCollection(user.id, 'Paris');
|
||||
const museums = await seedCollection(user.id, 'Museums');
|
||||
// Two different match signals: coordinates in one list, the source link the
|
||||
// saved-from-trip path writes in the other.
|
||||
const byCoords = seedPlace(paris, user.id, 'Louvre', { lat: 48.8606, lng: 2.3376 });
|
||||
const bySource = seedPlace(museums, user.id, 'Louvre', { source_trip_id: trip.id, source_place_id: louvre.id });
|
||||
const untouched = seedPlace(paris, user.id, 'Eiffel Tower', { lat: 48.8584, lng: 2.2945 });
|
||||
const byCoords = await seedPlace(paris, user.id, 'Louvre', { lat: 48.8606, lng: 2.3376 });
|
||||
const bySource = await seedPlace(museums, user.id, 'Louvre', { source_trip_id: trip.id, source_place_id: louvre.id });
|
||||
const untouched = await seedPlace(paris, user.id, 'Eiffel Tower', { lat: 48.8584, lng: 2.2945 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
@@ -417,9 +436,9 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
expect(data).toEqual({ updated: 2, places: 1 });
|
||||
});
|
||||
|
||||
expect(statusOf(byCoords)).toBe('visited');
|
||||
expect(statusOf(bySource)).toBe('visited');
|
||||
expect(statusOf(untouched)).toBe('idea');
|
||||
expect(await statusOf(byCoords)).toBe('visited');
|
||||
expect(await statusOf(bySource)).toBe('visited');
|
||||
expect(await statusOf(untouched)).toBe('idea');
|
||||
});
|
||||
|
||||
it('skips a list the user may only read instead of refusing the batch', async () => {
|
||||
@@ -428,11 +447,11 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const prado = createPlace(testDb, trip.id, { name: 'Prado', lat: 40.4138, lng: -3.6921 });
|
||||
const own = seedCollection(user.id, 'Madrid');
|
||||
const readOnly = seedCollection(owner.id, 'Their list');
|
||||
addMember(readOnly, user.id, 'viewer');
|
||||
const mine = seedPlace(own, user.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
const theirs = seedPlace(readOnly, owner.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
const own = await seedCollection(user.id, 'Madrid');
|
||||
const readOnly = await seedCollection(owner.id, 'Their list');
|
||||
await addMember(readOnly, user.id, 'viewer');
|
||||
const mine = await seedPlace(own, user.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
const theirs = await seedPlace(readOnly, owner.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
@@ -442,8 +461,8 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
expect(data).toEqual({ updated: 1, places: 1 });
|
||||
});
|
||||
|
||||
expect(statusOf(mine)).toBe('visited');
|
||||
expect(statusOf(theirs)).toBe('idea');
|
||||
expect(await statusOf(mine)).toBe('visited');
|
||||
expect(await statusOf(theirs)).toBe('idea');
|
||||
});
|
||||
|
||||
it('surfaces the 404 text for a trip the user cannot access, leaving statuses alone', async () => {
|
||||
@@ -452,8 +471,8 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, stranger.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Alhambra', lat: 37.176, lng: -3.5881 });
|
||||
const col = seedCollection(user.id, 'Spain');
|
||||
const saved = seedPlace(col, user.id, 'Alhambra', { lat: 37.176, lng: -3.5881 });
|
||||
const col = await seedCollection(user.id, 'Spain');
|
||||
const saved = await seedPlace(col, user.id, 'Alhambra', { lat: 37.176, lng: -3.5881 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
@@ -463,7 +482,7 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toBe('Trip not found');
|
||||
});
|
||||
expect(statusOf(saved)).toBe('idea');
|
||||
expect(await statusOf(saved)).toBe('idea');
|
||||
});
|
||||
|
||||
it('refuses an empty place_ids, and falls back to idea on an unknown status like the REST contract', async () => {
|
||||
@@ -471,8 +490,8 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia', lat: 41.4036, lng: 2.1744 });
|
||||
const col = seedCollection(user.id, 'Barcelona');
|
||||
const saved = seedPlace(col, user.id, 'Sagrada Familia', { lat: 41.4036, lng: 2.1744, status: 'want' });
|
||||
const col = await seedCollection(user.id, 'Barcelona');
|
||||
const saved = await seedPlace(col, user.id, 'Sagrada Familia', { lat: 41.4036, lng: 2.1744, status: 'want' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const empty = await h.client.callTool({
|
||||
@@ -480,7 +499,7 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
arguments: { trip_id: trip.id, place_ids: [], status: 'visited' },
|
||||
});
|
||||
expect(empty.isError).toBe(true);
|
||||
expect(statusOf(saved)).toBe('want');
|
||||
expect(await statusOf(saved)).toBe('want');
|
||||
|
||||
// collectionStatusSchema carries .catch('idea'), so REST coerces rather
|
||||
// than rejects; the tool shares the contract and behaves the same.
|
||||
@@ -488,7 +507,7 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [place.id], status: 'teleported' },
|
||||
});
|
||||
expect(statusOf(saved)).toBe('idea');
|
||||
expect(await statusOf(saved)).toBe('idea');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -496,8 +515,8 @@ describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
describe('Tool: delete_collection_place', () => {
|
||||
it('deletes a place; an unknown id surfaces the 404 text', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'D');
|
||||
const pid = seedPlace(col, user.id, 'Gone');
|
||||
const col = await seedCollection(user.id, 'D');
|
||||
const pid = await seedPlace(col, user.id, 'Gone');
|
||||
await withHarness(user.id, async (h) => {
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'delete_collection_place', arguments: { placeId: pid } }))).toEqual({ success: true });
|
||||
const missing = await h.client.callTool({ name: 'delete_collection_place', arguments: { placeId: pid } });
|
||||
@@ -512,12 +531,12 @@ describe('Tool: copy_collection_places_to_trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const col = seedCollection(user.id, 'Plan');
|
||||
const pid = seedPlace(col, user.id, 'Trevi Fountain');
|
||||
const col = await seedCollection(user.id, 'Plan');
|
||||
const pid = await seedPlace(col, user.id, 'Trevi Fountain');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'copy_collection_places_to_trip', arguments: { trip_id: trip.id, place_ids: [pid] } })) as { copied: number };
|
||||
expect(data.copied).toBe(1);
|
||||
expect(testDb.prepare("SELECT COUNT(*) n FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(trip.id)).toEqual({ n: 1 });
|
||||
expect(await countRows(orm, Places, { trip: trip.id, name: 'Trevi Fountain' })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -526,8 +545,8 @@ describe('Tool: copy_collection_places_to_trip', () => {
|
||||
const { user: other } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const col = seedCollection(user.id, 'C');
|
||||
const pid = seedPlace(col, user.id, 'X');
|
||||
const col = await seedCollection(user.id, 'C');
|
||||
const pid = await seedPlace(col, user.id, 'X');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_collection_places_to_trip', arguments: { trip_id: trip.id, place_ids: [pid] } });
|
||||
expect(result.isError).toBe(true);
|
||||
@@ -543,8 +562,8 @@ describe('Tool: copy_collection_places_to_trip', () => {
|
||||
describe('Label tools', () => {
|
||||
it('create → assign → update → unassign → delete a label', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'DE');
|
||||
const pid = seedPlace(col, user.id, 'Gate');
|
||||
const col = await seedCollection(user.id, 'DE');
|
||||
const pid = await seedPlace(col, user.id, 'Gate');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = parseToolResult(await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'Berlin', color: '#ff0000' } })) as { label: { id: number; name: string } };
|
||||
expect(created.label.name).toBe('Berlin');
|
||||
@@ -559,13 +578,13 @@ describe('Label tools', () => {
|
||||
expect(removed.changed).toBe(1);
|
||||
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'delete_collection_label', arguments: { labelId: created.label.id } }))).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_labels WHERE collection_id = ?').get(col)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, CollectionLabels, { collection: col })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('duplicate label name surfaces the 409 text', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'DE');
|
||||
const col = await seedCollection(user.id, 'DE');
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'Berlin' } });
|
||||
const dup = await h.client.callTool({ name: 'create_collection_label', arguments: { collection_id: col, name: 'berlin' } });
|
||||
@@ -583,7 +602,7 @@ describe('Sharing tools', () => {
|
||||
it('invite → accept → set role → remove member round trip', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: target } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Shared');
|
||||
const col = await seedCollection(owner.id, 'Shared');
|
||||
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const invited = parseToolResult(await h.client.callTool({ name: 'invite_to_collection', arguments: { collection_id: col, user_id: target.id } }));
|
||||
@@ -595,16 +614,16 @@ describe('Sharing tools', () => {
|
||||
});
|
||||
await withHarness(owner.id, async (h) => {
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'set_collection_member_role', arguments: { collectionId: col, userId: target.id, role: 'admin' } }))).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT role FROM collection_members WHERE collection_id = ? AND user_id = ?').get(col, target.id)).toEqual({ role: 'admin' });
|
||||
expect((await findRow(orm, CollectionMembers, { collection: col, user: target.id }))?.role).toBe('admin');
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'remove_collection_member', arguments: { collectionId: col, userId: target.id } }))).toEqual({ success: true });
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, CollectionMembers, { collection: col })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('invite errors come back as isError text (self-invite / not owner)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'OwnerOnly');
|
||||
const col = await seedCollection(owner.id, 'OwnerOnly');
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const self = await h.client.callTool({ name: 'invite_to_collection', arguments: { collection_id: col, user_id: owner.id } });
|
||||
expect(self.isError).toBe(true);
|
||||
@@ -620,7 +639,7 @@ describe('Sharing tools', () => {
|
||||
it('accept without a pending invite → error text; decline and cancel clear the pending row', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: target } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Pending');
|
||||
const col = await seedCollection(owner.id, 'Pending');
|
||||
|
||||
await withHarness(target.id, async (h) => {
|
||||
const noInvite = await h.client.callTool({ name: 'accept_collection_invite', arguments: { collectionId: col } });
|
||||
@@ -628,24 +647,24 @@ describe('Sharing tools', () => {
|
||||
expect(errorText(noInvite)).toBe('No pending invite');
|
||||
});
|
||||
|
||||
addMember(col, target.id, 'editor', 'pending');
|
||||
await addMember(col, target.id, 'editor', 'pending');
|
||||
await withHarness(target.id, async (h) => {
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'decline_collection_invite', arguments: { collectionId: col } }))).toEqual({ success: true });
|
||||
});
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, CollectionMembers, { collection: col })).toBe(0);
|
||||
|
||||
addMember(col, target.id, 'editor', 'pending');
|
||||
await addMember(col, target.id, 'editor', 'pending');
|
||||
await withHarness(owner.id, async (h) => {
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'cancel_collection_invite', arguments: { collectionId: col, userId: target.id } }))).toEqual({ success: true });
|
||||
});
|
||||
expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, CollectionMembers, { collection: col })).toBe(0);
|
||||
});
|
||||
|
||||
it('leave_collection: member leaves; the owner gets the bespoke 400 text', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const col = seedCollection(owner.id, 'Leavable');
|
||||
addMember(col, member.id);
|
||||
const col = await seedCollection(owner.id, 'Leavable');
|
||||
await addMember(col, member.id);
|
||||
await withHarness(member.id, async (h) => {
|
||||
expect(parseToolResult(await h.client.callTool({ name: 'leave_collection', arguments: { collectionId: col } }))).toEqual({ success: true });
|
||||
});
|
||||
@@ -713,14 +732,14 @@ describe('Collection tools — scope gating', () => {
|
||||
describe('Collection tools — collections addon gating', () => {
|
||||
it('registers nothing when the collections addon is disabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run(ADDON_IDS.COLLECTIONS);
|
||||
await updateRows(orm, Addons, { id: ADDON_IDS.COLLECTIONS }, { enabled: false });
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const names = (await h.client.listTools()).tools.map((t) => t.name);
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).not.toContain(tool);
|
||||
});
|
||||
} finally {
|
||||
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run(ADDON_IDS.COLLECTIONS);
|
||||
await updateRows(orm, Addons, { id: ADDON_IDS.COLLECTIONS }, { enabled: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,12 @@ import { registerAction } from '../../../src/nest/notifications/in-app-actions';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { makeNotification } from '../../helpers/factories/notifications';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { Notifications } from '../../../src/db/entities/Notifications.entity';
|
||||
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
|
||||
|
||||
// Built in beforeAll: the service now takes a UnitOfWork, which is async to build.
|
||||
let notifications: NotificationsService;
|
||||
@@ -63,7 +69,14 @@ beforeEach(() => {
|
||||
broadcastMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
let orm: TestOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(testDb);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -90,8 +103,8 @@ describe('createNotification — preference filtering', () => {
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
const row = await findRow(orm, Notifications, { recipient: recipient.id });
|
||||
expect(row).not.toBeNull();
|
||||
// Also verify the admin who disabled all prefs still gets messages without event_type
|
||||
disableNotificationPref(testDb, admin.id, 'trip_invite', 'inapp');
|
||||
// admin still gets this since no event_type check
|
||||
@@ -115,9 +128,9 @@ describe('createNotification — preference filtering', () => {
|
||||
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
|
||||
|
||||
// Use a trip to target both members
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
|
||||
const tripId = (await makeTrip(orm, sender.id, { title: 'Test Trip' })).id;
|
||||
await insertRow(orm, TripMembers, { trip: tripId, user: recipient1.id });
|
||||
await insertRow(orm, TripMembers, { trip: tripId, user: recipient2.id });
|
||||
|
||||
const ids = await createNotification({
|
||||
type: 'simple',
|
||||
@@ -131,10 +144,10 @@ describe('createNotification — preference filtering', () => {
|
||||
|
||||
// sender excluded, recipient1 included, recipient2 skipped (disabled pref)
|
||||
expect(ids.length).toBe(1);
|
||||
const r1 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient1.id);
|
||||
const r2 = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient2.id);
|
||||
expect(r1).toBeDefined();
|
||||
expect(r2).toBeUndefined();
|
||||
const r1 = await findRow(orm, Notifications, { recipient: recipient1.id });
|
||||
const r2 = await findRow(orm, Notifications, { recipient: recipient2.id });
|
||||
expect(r1).not.toBeNull();
|
||||
expect(r2).toBeNull();
|
||||
});
|
||||
|
||||
it('INOTIF-003 — notification with event_type delivers to recipients with no stored preferences', async () => {
|
||||
@@ -153,8 +166,8 @@ describe('createNotification — preference filtering', () => {
|
||||
});
|
||||
|
||||
expect(ids.length).toBe(1);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(recipient.id);
|
||||
expect(row).toBeDefined();
|
||||
const row = await findRow(orm, Notifications, { recipient: recipient.id });
|
||||
expect(row).not.toBeNull();
|
||||
});
|
||||
|
||||
it('INOTIF-003b — createNotificationForRecipient inserts a single notification and broadcasts via WS', async () => {
|
||||
@@ -178,8 +191,8 @@ describe('createNotification — preference filtering', () => {
|
||||
);
|
||||
|
||||
expect(id).toBeTypeOf('number');
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
const row = await findRow(orm, Notifications, { id });
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.recipient_id).toBe(recipient.id);
|
||||
expect(row!.navigate_target).toBe('/trips/99');
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(1);
|
||||
@@ -207,10 +220,10 @@ describe('createNotification — preference filtering', () => {
|
||||
|
||||
// Only admin1 should receive it
|
||||
expect(ids.length).toBe(1);
|
||||
const admin1Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin1.id);
|
||||
const admin2Row = testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(admin2.id);
|
||||
expect(admin1Row).toBeDefined();
|
||||
expect(admin2Row).toBeUndefined();
|
||||
const admin1Row = await findRow(orm, Notifications, { recipient: admin1.id });
|
||||
const admin2Row = await findRow(orm, Notifications, { recipient: admin2.id });
|
||||
expect(admin1Row).not.toBeNull();
|
||||
expect(admin2Row).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,59 +231,58 @@ describe('createNotification — preference filtering', () => {
|
||||
// respondToBoolean
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, ?, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, senderId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
/** A yes/no notification for the user whose two buttons run the named actions. */
|
||||
async function insertBoolean(recipientId: number, senderId: number | null, positive: string, negative = positive): Promise<number> {
|
||||
const row = await makeNotification(orm, recipientId, {
|
||||
type: 'boolean', sender: senderId,
|
||||
title_key: 'notif.test.title', title_params: '{}', text_key: 'notif.test.text', text_params: '{}',
|
||||
positive_text_key: 'notif.action.accept', negative_text_key: 'notif.action.decline',
|
||||
positive_callback: JSON.stringify({ action: positive, payload: {} }),
|
||||
negative_callback: JSON.stringify({ action: negative, payload: {} }),
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params
|
||||
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
function insertBooleanNotification(recipientId: number, senderId: number | null = null): Promise<number> {
|
||||
return insertBoolean(recipientId, senderId, 'test_approve', 'test_deny');
|
||||
}
|
||||
|
||||
async function insertSimpleNotification(recipientId: number): Promise<number> {
|
||||
const row = await makeNotification(orm, recipientId, {
|
||||
sender: null, title_key: 'notif.test.title', title_params: '{}', text_key: 'notif.test.text', text_params: '{}',
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
describe('respondToBoolean', () => {
|
||||
it('INOTIF-005 — positive response sets response=positive, marks read, broadcasts update', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.notification).toBeDefined();
|
||||
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('positive');
|
||||
expect(row.is_read).toBe(1);
|
||||
const row = await findRow(orm, Notifications, { id });
|
||||
expect(row?.response).toBe('positive');
|
||||
expect(row?.is_read).toBe(1);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(user.id, expect.objectContaining({ type: 'notification:updated' }));
|
||||
});
|
||||
|
||||
it('INOTIF-006 — negative response sets response=negative', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(id) as any;
|
||||
expect(row.response).toBe('negative');
|
||||
const row = await findRow(orm, Notifications, { id });
|
||||
expect(row?.response).toBe('negative');
|
||||
});
|
||||
|
||||
it('INOTIF-007 — double-response prevention returns error on second call', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
await respondToBoolean(id, user.id, 'positive');
|
||||
const result = await respondToBoolean(id, user.id, 'negative');
|
||||
@@ -281,7 +293,7 @@ describe('respondToBoolean', () => {
|
||||
|
||||
it('INOTIF-008 — response on a simple notification returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
const id = await insertSimpleNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
@@ -299,7 +311,7 @@ describe('respondToBoolean', () => {
|
||||
it('INOTIF-010 — response on notification belonging to another user returns error', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const id = insertBooleanNotification(owner.id);
|
||||
const id = await insertBooleanNotification(owner.id);
|
||||
|
||||
const result = await respondToBoolean(id, other.id, 'positive');
|
||||
|
||||
@@ -319,16 +331,7 @@ describe('respondToBoolean', () => {
|
||||
calls.push('run');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
const id = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"slow_approve","payload":{}}', '{"action":"slow_approve","payload":{}}'
|
||||
)
|
||||
`).run(user.id, user.id).lastInsertRowid as number;
|
||||
const id = await insertBoolean(user.id, null, 'slow_approve');
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
respondToBoolean(id, user.id, 'positive'),
|
||||
@@ -347,22 +350,13 @@ describe('respondToBoolean', () => {
|
||||
registerAction('flaky_approve', async () => {
|
||||
if (shouldFail) throw new Error('downstream unavailable');
|
||||
});
|
||||
const id = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"flaky_approve","payload":{}}', '{"action":"flaky_approve","payload":{}}'
|
||||
)
|
||||
`).run(user.id, user.id).lastInsertRowid as number;
|
||||
const id = await insertBoolean(user.id, null, 'flaky_approve');
|
||||
|
||||
const failed = await respondToBoolean(id, user.id, 'positive');
|
||||
expect(failed).toEqual({ success: false, error: 'downstream unavailable' });
|
||||
const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(id) as { response: string | null; is_read: number };
|
||||
expect(row.response).toBeNull();
|
||||
expect(row.is_read).toBe(0);
|
||||
const row = await findRow(orm, Notifications, { id });
|
||||
expect(row?.response).toBeNull();
|
||||
expect(row?.is_read).toBe(0);
|
||||
|
||||
shouldFail = false;
|
||||
const retried = await respondToBoolean(id, user.id, 'positive');
|
||||
@@ -371,7 +365,7 @@ describe('respondToBoolean', () => {
|
||||
|
||||
it('INOTIF-013 — respond result + broadcast carry an ISO-UTC created_at (toUtcIso, #1149 parity across paths)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
const result = await respondToBoolean(id, user.id, 'positive');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user