mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
v3.1.1 bug fixes (#1228)
* fix(shared-view): render each leg of multi-leg flights correctly The read-only shared view showed the overall trip start/end airports and the first leg's flight number on every leg of a multi-leg flight. The Day Plan already expands legs (each carries __leg), but the renderer ignored it and read flat top-level metadata; the Bookings tab had the same bug. - Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time - Bookings tab: list each leg via getFlightLegs() - unique React keys for multi-leg rows Closes #1219 * feat(pdf): add legs to pdf export * fix(demo): skip first-run admin seed in demo mode When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app, username "admin") right after the generic seeds run. The first-run admin bootstrap was grabbing username "admin" first, so the demo seeder hit the UNIQUE(username) constraint and aborted before the demo user was ever created - which surfaced as a 500 "Demo user not found" on demo-login. Skip the generic admin bootstrap when demo mode owns the admin account. * fix(docker): ship the encryption-key migration script in the image The production image only copied server/dist, so the documented rotation command `node --import tsx scripts/migrate-encryption.ts` failed inside the container with a module-not-found error - the raw .ts was never present. The script runs via tsx straight from source and only pulls node builtins plus better-sqlite3 (both prod deps), so copying the single file into /app/server/scripts is enough to make the rotation work again. * fix(vacay): keep the mode toolbar above the mobile bottom nav The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden behind it - and could scroll out of reach entirely. Pin it above the nav with the shared --bottom-nav-h variable (0px on desktop, so nothing changes there) and reserve matching space below the calendar grid so it never gets swallowed. * fix(dashboard): show the correct reservation date regardless of timezone The upcoming-reservations widget built the date with new Date(reservation_time) .toISOString(), which reinterprets the stored naive local time as UTC and can roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation showing the next day). Read the date and time straight from the stored string parts via splitReservationDateTime, and format the time with the shared formatTime helper so it also honours the user's 12h/24h preference. * fix(atlas): cursor-following tooltips and removing countries from search Two related Atlas fixes: - Country tooltips were bound with sticky:false, which anchors them at the feature's bounds centre. For countries with overseas territories (e.g. France) that centre sits far out in the ocean, so the tooltip popped up nowhere near the area being hovered. Make them sticky so they track the cursor. - Selecting an already-visited country from the search bar always opened the "Mark / Bucket" dialog, with no way to remove it. Tiny countries like Vatican City or Singapore are hard to hit on the map, so search was the only way in. Mirror the map-click behaviour: a manually-marked country opens the Remove confirmation, a trip/place-backed one opens its detail. * fix(oidc): keep dots in generated usernames The OIDC username sanitizer stripped dots because they were missing from the allowed character class, so a name claim like "first.last" became "firstlast". Dots are valid usernames (the profile validator already allows ^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer. * fix(collab): show poll option labels in the UI The poll API formatted each option as { label, voters }, but the React poll component renders opt.text - so every option button came out blank. Emit text alongside label (kept for any other consumer) so options render again. * feat(backup): make the upload size limit configurable The restore upload was capped at a hard-coded 500 MB, so instances whose backup archive (uploads/ included) grew past that got a 413 "File too large" with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500, invalid values warn and fall back), documented in .env.example. * feat(costs): create an expense from a booking, fix editing total-only items Replace the inline price + budget-category fields in the Transport and Reservation booking modals with a "Create expense" flow: the modal saves the booking, then opens the full Costs editor prefilled (name + category mapped from the booking type) and linked to the reservation. A booking with a linked expense shows it inline with edit / remove. Also fix the Costs editor so an expense with a recorded total but no payers (transport-derived or pre-rework items) opens with its amount, lets you set the currency, and saves - it previously showed 0 everywhere and could not be saved. Legacy / localized categories now map to the fixed keys, and changing a booking's type keeps its linked expense category in sync (unless it was manually set). - shared: reservation_id on budget create, typeToCostCategory helper, i18n keys - server: createBudgetItem stores reservation_id; keep total_price for payerless items; a booking update no longer wipes its linked expense and syncs the category on type change - client: shared BookingCostsSection, exported ExpenseModal with prefill and an editable total, page-level save-then-open wiring * test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync The service now leaves a linked expense alone when no budget entry is on the payload (only an explicit total_price 0 deletes it) and syncs the category on a booking type change. Update the unit tests accordingly - the old "price cleared" case passed entry: undefined, which is now a no-op and left a mocked return queued that leaked into the next test. * fix(planner): keep a reservation on its day when edited (#1237) Editing a booking forced its day_id to the globally selected day, which is null when editing from the Book tab - so the booking lost its day and vanished from the Plan. Preserve the reservation own day_id on edit instead. * fix(planner): derive a booking day from its date when none is set (#1237) The client always sends day_id on a reservation update, so the server only derived it from reservation_time when the field was absent. A non-transport booking saved without a selected day (Book tab) therefore got day_id null and vanished from the Plan, even though its date matched a day. Derive the day from reservation_time whenever day_id is null, mirroring create. * fix(planner): let a booking's day follow its date when edited (#1237) Preserving the old day_id on edit left a re-dated booking on its previous start day while end_day_id followed the new date, so it spanned both. Stop sending day_id from the edit modal entirely - the server derives both ends from the booking's date (and keeps the current day when there is no date), so a re-dated booking moves cleanly to the matching day. * fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225) The optimistic mark/unmark updates bumped the country total but never the per-continent counts, so the continent column froze until a full reload. Move the country to continent map into @trek/shared (single source for server and client) and adjust the matching continent count at every optimistic site: the country confirm flow plus the choose / region mark and region unmark handlers. * feat(admin): let admins set a default currency for new users Adds a currency picker to Admin > User Defaults. Stored as the default_currency user-default, so users who have not picked their own currency inherit it in Costs. * fix(atlas): give every sub-national region a distinct code (#1217) geoBoundaries fills shapeISO with the bare country code for some countries (every Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when it is a real "XX-..." subdivision code and otherwise synthesizes a unique per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/ Chile/Oman now carry distinct region codes (countries with real codes, e.g. Germany, are unchanged). * fix(dashboard): never crash on a malformed reservation date A reservation with an invalid date blanked the whole My Trips page: the old Upcoming widget did new Date(value).toISOString(), which throws "Invalid time value" (fixed in #1222 by reading the string parts). Also guard splitDate so a bad date renders a dash instead of "Invalid Date" or throwing. * fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss * fix(airtrail): add back missing tests * fix(costs): rework the cost panel UX wise and apply prettier on the shared package * chore(prettier) prettier this file * fix(airtrail): don't use cabin class as seat on import When an AirTrail flight has a cabin class but no seat number, the mapper fell back to the class for metadata.seat, so reservations showed e.g. "economy" as the seat. Use only the seat number; leave the seat blank otherwise. The class is still surfaced separately in the import picker. Closes #1246 * fix(airtrail): import scheduled flight times instead of actual AirTrail exposes both scheduled (departureScheduled/arrivalScheduled) and actual (departure/arrival) times. TREK read the actual times, so a delayed or early flight imported the wrong time for planning. Read the scheduled times on import and on poll-sync (both go through mapFlightToReservation); when a flight has no scheduled time, leave the clock blank (date preserved) rather than fabricating 00:00 or falling back to actual. The change-detection hash now tracks the scheduled values, so existing linked reservations re-sync once on the next poll. The opt-in writeback mirrors the read, pushing TREK edits to the scheduled fields so they round-trip. * fix(planner): hydrate per-assignment times when editing a place from the pool Times live per day-assignment, not on the pool place, so reopening a place from the Places panel / inspector showed empty Start/End fields (#1247). The editor now resolves a place's lone assignment when no day is in context and hydrates the fields from it; ambiguous (0 or 2+ days) edits hide the fields instead of showing non-persisting inputs. * fix(mcp): make write tools return client-valid, hydrated entities Audit of all write tools under server/src/mcp/tools (issue #1244 anchor). S1 (broken): - create_budget_item / create_budget_item_with_members now default the split to all trip members when member_ids omitted, so the entry passes the client save-gate instead of being member-less (#1244). - create_transport / update_transport backfill lat/lng/timezone for code-only flight endpoints (NOT NULL columns) and return a clean error for unresolvable endpoints instead of crashing. S2 (under-hydration): set_budget_item_members, create_journey, create_journey_entry, create_packing_bag, bulk_import_packing and update_vacay_plan now return the hydrated shape the matching read/REST route returns; bulk_import widened to accept bag/weight_grams/checked. S3 (parity): check_in_end added to accommodation tools; atlas mark_region_visited echoes the client shape; update_journey_entry/ update_journey_preferences, set_bag_members, set_packing_category_assignees, apply_packing_template return hydrated payloads; set_vacay_color echoes the color. Auth: save_packing_template now requires admin, matching the REST gate. Also refactors server/src/config.ts (JWT-secret handling). Adds getBudgetItem hydrated getter, exports EndpointInput, and MCP regression tests (incl. new tools-transports and tools-journey suites). * fix(mcp): fix ICS/maps/accommodation bugs, add settlement & template tools Bugs: - export_trip_ics: include flights that store times per-endpoint (local_date/local_time) instead of a top-level reservation_time - resolve_maps_url: follow redirects for cid=/share links and fall back to parsing the page body, all SSRF-guarded - link_hotel_accommodation: normalize accommodation_id (TEXT column) to an integer in the reservation read paths so it no longer returns "14.0" Gaps: - packing: save_packing_template returns the new template id; add list_packing_templates (read) and delete_packing_template (admin) - budget: update_budget_item accepts payers/member_ids; clarify create/ update/members descriptions to ask which members share the expense and who paid - budget: add settlement tools — get_settlement_summary, list_settlements, create/update/delete_settlement (budget_edit, mirrors REST + WS events) * chore: bump nodemailer * chore: bump multer --------- Co-authored-by: Maurice <mauriceboe@icloud.com>
This commit is contained in:
@@ -31,6 +31,7 @@ const { svc } = vi.hoisted(() => ({
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||
setItemPayers: vi.fn(), listSettlements: vi.fn(), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => svc);
|
||||
@@ -104,4 +105,18 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission', async () => {
|
||||
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
});
|
||||
|
||||
it('404 on settlement update when it does not exist', async () => {
|
||||
svc.updateSettlement.mockReturnValue(null);
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,44 @@ describe('Update reservation', () => {
|
||||
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
||||
});
|
||||
|
||||
it('RESV-004b — PUT with day_id null derives day_id from reservation_time so it stays in the Plan (#1237)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-09-02' });
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Event', type: 'event' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', day_id: null, reservation_time: '2025-09-02' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation.day_id).toBe(day2.id);
|
||||
});
|
||||
|
||||
it('RESV-004c — re-dating a booking moves it to the matching day (start + end) (#1237)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-10-01' });
|
||||
const day3 = createDay(testDb, trip.id, { date: '2025-10-03' });
|
||||
|
||||
// Booking sits on day 1 (start + end).
|
||||
const created = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', day_id: day1.id, reservation_time: '2025-10-01T09:00', reservation_end_time: '2025-10-01T10:00' });
|
||||
const rid = created.body.reservation.id;
|
||||
|
||||
// Re-date to day 3 WITHOUT sending day_id (the modal omits it) — both ends follow.
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${rid}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', reservation_time: '2025-10-03T00:00', reservation_end_time: '2025-10-03T14:00' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation.day_id).toBe(day3.id);
|
||||
expect(res.body.reservation.end_day_id).toBe(day3.id);
|
||||
});
|
||||
|
||||
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -382,7 +420,7 @@ describe('Reservation budget entry integration', () => {
|
||||
expect(items[0].total_price).toBe(150);
|
||||
});
|
||||
|
||||
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
|
||||
it('RESV-014 — PUT without create_budget_entry keeps the existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -398,24 +436,98 @@ describe('Reservation budget entry integration', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Verify budget item exists
|
||||
const before = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
// Update without create_budget_entry — should delete the linked budget item
|
||||
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
|
||||
// linked expense (expenses are managed from the Costs section now).
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi Updated' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeDefined();
|
||||
});
|
||||
|
||||
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Taxi',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 50, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Explicit clear (total_price 0) still removes the linked item.
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Change the type other -> hotel (no create_budget_entry).
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
expect(item.category).toBe('accommodation');
|
||||
});
|
||||
|
||||
it('RESV-014d — a manually-picked expense category survives a booking type change', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Simulate a manual category pick in the Costs editor.
|
||||
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
expect(item.category).toBe('fees');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reservation accommodation delete', () => {
|
||||
|
||||
@@ -128,10 +128,12 @@ describe('Tool: mark_region_visited', () => {
|
||||
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
// Echoed in the client-facing shape ({ code, name, ... }), not raw DB columns.
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.region.region_code).toBe('US-CA');
|
||||
expect(data.region.region_name).toBe('California');
|
||||
expect(data.region.code).toBe('US-CA');
|
||||
expect(data.region.name).toBe('California');
|
||||
expect(data.region.country_code).toBe('US');
|
||||
expect(data.region.manuallyMarked).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -70,7 +70,7 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_budget_item_members', () => {
|
||||
it('sets members and broadcasts budget:members-updated', async () => {
|
||||
it('sets members and returns a hydrated item with members/payers', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
||||
@@ -80,11 +80,25 @@ describe('Tool: set_budget_item_members', () => {
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
// Regression: returns a hydrated item, not the raw row from updateMembers.
|
||||
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
||||
expect(Array.isArray(data.item.payers)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error for an item not in the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: 99999, userIds: [user.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -131,6 +145,58 @@ describe('Tool: set_budget_item_members', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_budget_item_with_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_budget_item_with_members', () => {
|
||||
it('assigns the given members and returns a hydrated item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item_with_members',
|
||||
arguments: { tripId: trip.id, name: 'Villa', total_price: 800, userIds: [user.id, member.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
|
||||
expect(data.item.persons).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: omitting userIds previously produced an empty-member (unsaveable) entity.
|
||||
it('defaults to all trip members when userIds omitted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item_with_members',
|
||||
arguments: { tripId: trip.id, name: 'Shared cab', total_price: 50 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.members.map((m: any) => m.user_id).sort()).toEqual([user.id, member.id].sort());
|
||||
expect(data.item.members.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item_with_members',
|
||||
arguments: { tripId: trip.id, name: 'X', total_price: 1 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_budget_member_paid
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +234,115 @@ describe('Tool: toggle_budget_member_paid', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlements (settle-up payments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Settlement tools', () => {
|
||||
function tripWithTwo() {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, other.id);
|
||||
return { user, other, trip };
|
||||
}
|
||||
|
||||
it('create_settlement records a payment, broadcasts, and is listed', async () => {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = await h.client.callTool({
|
||||
name: 'create_settlement',
|
||||
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 42.5 },
|
||||
});
|
||||
const cData = parseToolResult(created) as any;
|
||||
expect(cData.settlement.from_user_id).toBe(other.id);
|
||||
expect(cData.settlement.to_user_id).toBe(user.id);
|
||||
expect(cData.settlement.amount).toBe(42.5);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-created', expect.any(Object));
|
||||
|
||||
const listed = await h.client.callTool({ name: 'list_settlements', arguments: { tripId: trip.id } });
|
||||
const lData = parseToolResult(listed) as any;
|
||||
expect(lData.settlements).toHaveLength(1);
|
||||
expect(lData.settlements[0].id).toBe(cData.settlement.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('update_settlement changes the amount; delete_settlement removes it', async () => {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = parseToolResult(await h.client.callTool({
|
||||
name: 'create_settlement',
|
||||
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: user.id, amount: 10 },
|
||||
})) as any;
|
||||
const id = created.settlement.id;
|
||||
|
||||
const updated = parseToolResult(await h.client.callTool({
|
||||
name: 'update_settlement',
|
||||
arguments: { tripId: trip.id, settlementId: id, from_user_id: other.id, to_user_id: user.id, amount: 25 },
|
||||
})) as any;
|
||||
expect(updated.settlement.amount).toBe(25);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-updated', expect.any(Object));
|
||||
|
||||
const deleted = parseToolResult(await h.client.callTool({
|
||||
name: 'delete_settlement',
|
||||
arguments: { tripId: trip.id, settlementId: id },
|
||||
})) as any;
|
||||
expect(deleted.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-deleted', expect.any(Object));
|
||||
|
||||
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_settlements WHERE trip_id = ?').get(trip.id) as any;
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('update_settlement returns an error when the settlement is missing', async () => {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_settlement',
|
||||
arguments: { tripId: trip.id, settlementId: 99999, from_user_id: other.id, to_user_id: user.id, amount: 5 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('create_settlement is denied for a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_settlement',
|
||||
arguments: { tripId: trip.id, from_user_id: other.id, to_user_id: other.id, amount: 5 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('get_settlement_summary returns balances and flows', async () => {
|
||||
// Avoid a real exchange-rate network call: force getRates() to fail closed.
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
try {
|
||||
const { user, other, trip } = tripWithTwo();
|
||||
// user paid 100 for an item split between both → other owes user 50.
|
||||
const item = createBudgetItem(testDb, trip.id, { total_price: 100 });
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)')
|
||||
.run(item.id, user.id, item.id, other.id);
|
||||
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)')
|
||||
.run(item.id, user.id, 100);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.summary).toBeDefined();
|
||||
expect(Array.isArray(data.summary.balances)).toBe(true);
|
||||
expect(Array.isArray(data.summary.flows)).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-person resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -101,6 +101,89 @@ describe('Tool: create_budget_item', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for #1244: a naive create must seed members so the client save-gate
|
||||
// (participants.size > 0) passes — the entry must be saveable, not member-less.
|
||||
it('defaults members to the trip owner when member_ids omitted (solo trip)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'Dinner', total_price: 40 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
||||
expect(data.item.persons).toBe(1);
|
||||
// saveable invariant: client requires participants.size > 0
|
||||
expect(data.item.members.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults members to all trip members when member_ids omitted (multi-member)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'Group taxi', total_price: 60 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const ids = data.item.members.map((m: any) => m.user_id).sort();
|
||||
expect(ids).toEqual([user.id, member.id].sort());
|
||||
expect(data.item.persons).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('respects an explicit member_ids subset', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'My snack', total_price: 5, member_ids: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit empty member_ids as a planning-only entry (no split)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'Estimate', total_price: 100, member_ids: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.members).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips currency, expense_date, and payers', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_budget_item',
|
||||
arguments: {
|
||||
tripId: trip.id, name: 'Museum', total_price: 30, currency: 'EUR',
|
||||
expense_date: '2026-07-01', payers: [{ user_id: user.id, amount: 30 }],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.currency).toBe('EUR');
|
||||
expect(data.item.expense_date).toBe('2026-07-01');
|
||||
expect(data.item.payers.map((p: any) => p.user_id)).toEqual([user.id]);
|
||||
// total_price derives from payer sum
|
||||
expect(data.item.total_price).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
|
||||
@@ -168,12 +168,14 @@ describe('Tool: create_accommodation', () => {
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_in_end: '20:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'CONF123',
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(data.accommodation.check_in_end).toBe('20:00');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit tests for MCP journey write tools focused on response hydration:
|
||||
* create_journey returns the full journey (entries/contributors/trips/stats/my_role),
|
||||
* and create_journey_entry returns the enriched entry (parsed tags, photos array).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return { ...original, isAddonEnabled: vi.fn().mockReturnValue(true) };
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
describe('Tool: create_journey', () => {
|
||||
it('returns the fully-hydrated journey, not a bare row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_journey',
|
||||
arguments: { title: 'Eurotrip', subtitle: '2026' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.journey.title).toBe('Eurotrip');
|
||||
// hydrated shape from getJourneyFull
|
||||
expect(Array.isArray(data.journey.entries)).toBe(true);
|
||||
expect(Array.isArray(data.journey.contributors)).toBe(true);
|
||||
expect(Array.isArray(data.journey.trips)).toBe(true);
|
||||
expect(data.journey.stats).toBeDefined();
|
||||
expect(data.journey.my_role).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: create_journey_entry', () => {
|
||||
it('returns the enriched entry with parsed tags and a photos array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = (parseToolResult(await h.client.callTool({
|
||||
name: 'create_journey', arguments: { title: 'J' },
|
||||
})) as any).journey;
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_journey_entry',
|
||||
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1', story: 'Arrived' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.entry.title).toBe('Day 1');
|
||||
// listEntries enrichment: tags parsed to an array, photos present
|
||||
expect(Array.isArray(data.entry.tags)).toBe(true);
|
||||
expect(Array.isArray(data.entry.photos)).toBe(true);
|
||||
expect(data.entry).toHaveProperty('source_trip_name');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: update_journey_entry', () => {
|
||||
it('returns the enriched entry (parsed tags, photos array)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = (parseToolResult(await h.client.callTool({
|
||||
name: 'create_journey', arguments: { title: 'J' },
|
||||
})) as any).journey;
|
||||
const entry = (parseToolResult(await h.client.callTool({
|
||||
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1' },
|
||||
})) as any).entry;
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_journey_entry',
|
||||
arguments: { entryId: entry.id, title: 'Day 1 (edited)' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.entry.title).toBe('Day 1 (edited)');
|
||||
expect(Array.isArray(data.entry.tags)).toBe(true);
|
||||
expect(Array.isArray(data.entry.photos)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: update_journey_preferences', () => {
|
||||
it('returns the updated preference, not { success }', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const journey = (parseToolResult(await h.client.callTool({
|
||||
name: 'create_journey', arguments: { title: 'J' },
|
||||
})) as any).journey;
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_journey_preferences',
|
||||
arguments: { journeyId: journey.id, hide_skeletons: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.hide_skeletons).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
|
||||
import { createUser, createAdmin, createTrip, createPackingItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -148,6 +148,8 @@ describe('Tool: create_packing_bag', () => {
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('Checked bag');
|
||||
// hydrated to match listBags/schema, which always carry a members array
|
||||
expect(data.bag.members).toEqual([]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -267,8 +269,9 @@ describe('Tool: set_bag_members', () => {
|
||||
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
|
||||
// Returns the hydrated members list (REST parity), not { success }.
|
||||
expect(data.members.map((m: any) => m.user_id)).toEqual([user.id]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.objectContaining({ members: expect.any(Array) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,7 +287,7 @@ describe('Tool: set_bag_members', () => {
|
||||
arguments: { tripId: trip.id, bagId, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.members).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -322,8 +325,9 @@ describe('Tool: set_packing_category_assignees', () => {
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
|
||||
// Returns the hydrated assignees list (REST parity), not { success }.
|
||||
expect(data.assignees.map((a: any) => a.user_id)).toEqual([user.id]);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.objectContaining({ category: 'Clothing', assignees: expect.any(Array) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +341,7 @@ describe('Tool: set_packing_category_assignees', () => {
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.assignees).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -378,8 +382,8 @@ describe('Tool: apply_packing_template', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: save_packing_template', () => {
|
||||
it('saves the current packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
it('saves the current packing list as a template for an admin', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
@@ -388,7 +392,36 @@ describe('Tool: save_packing_template', () => {
|
||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// Save now returns the new template (with its id) instead of a bare success flag.
|
||||
expect(data.template).toBeDefined();
|
||||
expect(Number.isInteger(data.template.id)).toBe(true);
|
||||
expect(data.template.name).toBe('Weekend Trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when the packing list is empty', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Empty' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a non-admin editor (parity with the REST admin gate)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as any)[0].text).toBe('Admin access required');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,12 +439,96 @@ describe('Tool: save_packing_template', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_packing_templates / delete_packing_template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_packing_templates', () => {
|
||||
it('lists saved templates with their ids and item counts', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const saved = parseToolResult(await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Beach' },
|
||||
})) as any;
|
||||
|
||||
const listed = parseToolResult(await h.client.callTool({
|
||||
name: 'list_packing_templates',
|
||||
arguments: { tripId: trip.id },
|
||||
})) as any;
|
||||
expect(listed.templates.some((t: any) => t.id === saved.template.id && t.name === 'Beach')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('is available to a non-admin trip member (read-only)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_packing_templates',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.templates)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: delete_packing_template', () => {
|
||||
it('removes a template for an admin', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const saved = parseToolResult(await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Ski' },
|
||||
})) as any;
|
||||
const id = saved.template.id;
|
||||
|
||||
const deleted = parseToolResult(await h.client.callTool({
|
||||
name: 'delete_packing_template',
|
||||
arguments: { templateId: id },
|
||||
})) as any;
|
||||
expect(deleted.success).toBe(true);
|
||||
const remaining = testDb.prepare('SELECT count(*) as cnt FROM packing_templates WHERE id = ?').get(id) as any;
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a non-admin (parity with the REST admin gate)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_packing_template',
|
||||
arguments: { templateId: 1 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as any)[0].text).toBe('Admin access required');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error for a missing template', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_packing_template',
|
||||
arguments: { templateId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulk_import_packing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: bulk_import_packing', () => {
|
||||
it('imports multiple packing items and count matches', async () => {
|
||||
it('imports multiple packing items, returns them, and broadcasts per item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const items = [
|
||||
@@ -425,9 +542,33 @@ describe('Tool: bulk_import_packing', () => {
|
||||
arguments: { tripId: trip.id, items },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// New contract: returns the created items (REST parity), broadcasts packing:created per item.
|
||||
expect(data.count).toBe(items.length);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
||||
expect(Array.isArray(data.items)).toBe(true);
|
||||
expect(data.items).toHaveLength(items.length);
|
||||
expect(data.items[0].name).toBe('Passport');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:created', expect.objectContaining({ item: expect.any(Object) }));
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(items.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('honors the widened fields (bag, weight_grams, checked)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
items: [{ name: 'Tent', category: 'Camping', bag: 'Backpack', weight_grams: 2500, checked: true }],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(1);
|
||||
const item = data.items[0];
|
||||
expect(item.weight_grams).toBe(2500);
|
||||
expect(item.checked).toBe(1);
|
||||
expect(item.bag_id).toBeTruthy(); // "Backpack" bag was created and assigned
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -350,6 +350,10 @@ describe('Tool: link_hotel_accommodation', () => {
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.accommodation_id).not.toBeNull();
|
||||
expect(data.accommodation_id).not.toBeNull();
|
||||
// accommodation_id must be a clean integer, not a stringified float ("14.0").
|
||||
expect(typeof data.reservation.accommodation_id).toBe('number');
|
||||
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
|
||||
expect(Number.isInteger(data.accommodation_id)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Unit tests for MCP transport tools: create_transport, update_transport, delete_transport.
|
||||
* Focus: flight endpoints supplied with only an IATA `code` are backfilled with
|
||||
* lat/lng/timezone from the airport database (the columns are NOT NULL), and
|
||||
* endpoints that can't be resolved produce a clean error instead of a SQL crash.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
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 { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
const flightEndpoints = [
|
||||
{ role: 'from', sequence: 0, name: 'Zurich', code: 'ZRH' },
|
||||
{ role: 'to', sequence: 1, name: 'Paris CDG', code: 'CDG' },
|
||||
];
|
||||
|
||||
describe('Tool: create_transport', () => {
|
||||
it('backfills lat/lng/timezone for code-only flight endpoints', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const eps = data.reservation.endpoints;
|
||||
expect(eps).toHaveLength(2);
|
||||
const from = eps.find((e: any) => e.role === 'from');
|
||||
expect(typeof from.lat).toBe('number');
|
||||
expect(typeof from.lng).toBe('number');
|
||||
expect(from.timezone).toBe('Europe/Zurich');
|
||||
// persisted NOT NULL columns are populated
|
||||
const rows = testDb.prepare('SELECT lat, lng FROM reservation_endpoints WHERE reservation_id = ?').all(data.reservation.id) as any[];
|
||||
expect(rows.every(r => r.lat != null && r.lng != null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps manually-supplied coordinates and the caller timezone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: {
|
||||
tripId: trip.id, type: 'train', title: 'Scenic train',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Station A', lat: 46.0, lng: 7.0, timezone: 'Europe/Zurich' },
|
||||
{ role: 'to', sequence: 1, name: 'Station B', lat: 46.5, lng: 7.5 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
|
||||
expect(from.lat).toBe(46.0);
|
||||
expect(from.timezone).toBe('Europe/Zurich');
|
||||
});
|
||||
});
|
||||
|
||||
it('errors on an unresolvable airport code instead of crashing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: {
|
||||
tripId: trip.id, type: 'flight', title: 'Bad flight',
|
||||
endpoints: [{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' }],
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as any)[0].text).toContain('ZZZ');
|
||||
});
|
||||
});
|
||||
|
||||
it('errors on an endpoint missing both coordinates and a code', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: {
|
||||
tripId: trip.id, type: 'car', title: 'Road trip',
|
||||
endpoints: [{ role: 'from', sequence: 0, name: 'My house' }],
|
||||
},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as any)[0].text).toContain('missing coordinates');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a transport with no endpoints', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: { tripId: trip.id, type: 'flight', title: 'TBD flight' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.title).toBe('TBD flight');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: update_transport', () => {
|
||||
it('backfills coords when replacing endpoints', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = parseToolResult(await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
|
||||
})) as any;
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_transport',
|
||||
arguments: {
|
||||
tripId: trip.id, reservationId: created.reservation.id,
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'JFK', code: 'JFK' },
|
||||
{ role: 'to', sequence: 1, name: 'Zurich', code: 'ZRH' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
|
||||
expect(from.code).toBe('JFK');
|
||||
expect(typeof from.lat).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves endpoints untouched when not provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = parseToolResult(await h.client.callTool({
|
||||
name: 'create_transport',
|
||||
arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints },
|
||||
})) as any;
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_transport',
|
||||
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.status).toBe('confirmed');
|
||||
expect(data.reservation.endpoints).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,9 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
updatePlan: vi.fn().mockResolvedValue(undefined),
|
||||
updatePlan: vi.fn().mockResolvedValue({
|
||||
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
|
||||
}),
|
||||
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
|
||||
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
|
||||
};
|
||||
@@ -106,7 +108,7 @@ describe('Tool: get_vacay_plan', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_vacay_plan', () => {
|
||||
it('calls updatePlan and returns success', async () => {
|
||||
it('calls updatePlan and returns the hydrated plan', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
@@ -114,7 +116,11 @@ describe('Tool: update_vacay_plan', () => {
|
||||
arguments: { block_weekends: true, holidays_enabled: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// Now returns the fully-hydrated plan (matching get_vacay_plan), not { success }.
|
||||
expect(data.plan).toBeDefined();
|
||||
expect(data.plan.block_weekends).toBe(true);
|
||||
expect(data.plan.holidays_enabled).toBe(false);
|
||||
expect(Array.isArray(data.plan.holiday_calendars)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,9 +142,10 @@ describe('Tool: set_vacay_color', () => {
|
||||
it('updates color and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.color).toBe('#ff0000'); // echoes the persisted color
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,37 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ updateSettlement: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id updates and broadcasts', () => {
|
||||
const updateSettlement = vi.fn().mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
|
||||
@@ -75,13 +75,28 @@ describe('ReservationsService', () => {
|
||||
});
|
||||
|
||||
describe('syncBudgetOnUpdate', () => {
|
||||
it('deletes the linked item when the price is cleared', () => {
|
||||
it('deletes the linked item when the price is explicitly cleared (total_price 0)', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(7, '5');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('leaves the linked item alone when no budget entry is on the payload (no wipe)', () => {
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
|
||||
expect(budget.updateBudgetItem).not.toHaveBeenCalled();
|
||||
expect(budget.createBudgetItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs the linked expense category when the booking type changes', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7, category: 'other' });
|
||||
budget.updateBudgetItem.mockReturnValue({ id: 7, category: 'flights' });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'X', 'flight', 'X', 'other', undefined, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(7, '5', { category: 'flights' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 7, category: 'flights' } }, 'sock');
|
||||
});
|
||||
|
||||
it('updates an existing linked item when a price is provided', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
||||
budget.updateBudgetItem.mockReturnValue({ id: 7 });
|
||||
|
||||
@@ -23,8 +23,11 @@ function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
|
||||
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
|
||||
date: '2021-09-01',
|
||||
datePrecision: 'day',
|
||||
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
||||
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
||||
// Actual times (delayed) — TREK must IGNORE these and read the scheduled times.
|
||||
departure: '2021-09-01T23:42:00.000+00:00',
|
||||
arrival: '2021-09-02T07:42:00.000+00:00',
|
||||
departureScheduled: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
||||
arrivalScheduled: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
||||
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||
flightNumber: 'BA178',
|
||||
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||
@@ -48,6 +51,9 @@ describe('airtrailMapper.normalizeFlight', () => {
|
||||
flightNumber: 'BA178',
|
||||
seatClass: 'economy',
|
||||
});
|
||||
// The picker preview surfaces the scheduled times, not the actual ones.
|
||||
expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00');
|
||||
expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00');
|
||||
});
|
||||
|
||||
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
||||
@@ -59,14 +65,24 @@ describe('airtrailMapper.normalizeFlight', () => {
|
||||
});
|
||||
|
||||
describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
it('composes airport-local times from the instant + airport tz', () => {
|
||||
it('composes airport-local times from the SCHEDULED instant + airport tz', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
|
||||
// Scheduled 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
|
||||
// (The actual times in the fixture are 23:42/07:42 — proving they are ignored.)
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||
// 07:00 UTC at LHR in September is 08:00 BST.
|
||||
// Scheduled 07:00 UTC at LHR in September is 08:00 BST.
|
||||
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
|
||||
});
|
||||
|
||||
it('leaves the clock blank (date only) when the flight has no scheduled time', () => {
|
||||
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
|
||||
// Date is preserved from the AirTrail canonical date; no fabricated 00:00.
|
||||
expect(m.reservation_time).toBe('2021-09-01');
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
expect(m.endpoints.find(e => e.role === 'from')?.local_time).toBeNull();
|
||||
expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBeNull();
|
||||
});
|
||||
|
||||
it('builds two endpoints with codes, coords and timezones', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
expect(m.endpoints).toHaveLength(2);
|
||||
@@ -88,6 +104,26 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
expect(m.notes).toBe('window seat');
|
||||
});
|
||||
|
||||
it('uses only the seat number for the seat, not the cabin class (#1246)', () => {
|
||||
// AirTrail often has a class but no seat number until check-in; the class
|
||||
// must not leak into the seat field.
|
||||
const m = mapFlightToReservation(
|
||||
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: null, seatClass: 'economy' }] }),
|
||||
);
|
||||
expect(m.metadata).not.toHaveProperty('seat');
|
||||
});
|
||||
|
||||
it('keeps the seat number when present even with no class', () => {
|
||||
const m = mapFlightToReservation(
|
||||
flight({ seats: [{ userId: 'u1', guestName: null, seat: null, seatNumber: '3F', seatClass: null }] }),
|
||||
);
|
||||
expect(m.metadata).toMatchObject({ seat: '3F' });
|
||||
});
|
||||
|
||||
it('omits the seat for a flight with no seats', () => {
|
||||
expect(mapFlightToReservation(flight({ seats: [] })).metadata).not.toHaveProperty('seat');
|
||||
});
|
||||
|
||||
it('flags needs_review for a non-day date precision', () => {
|
||||
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
|
||||
});
|
||||
@@ -99,8 +135,8 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves the end time null for a partial flight with no arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrival: null }));
|
||||
it('leaves the end time null for a partial flight with no scheduled arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrivalScheduled: null }));
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||
});
|
||||
@@ -116,6 +152,17 @@ describe('airtrailMapper.canonicalHash', () => {
|
||||
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
|
||||
});
|
||||
|
||||
it('tracks the scheduled time and ignores actual-time changes', () => {
|
||||
// A scheduled-time change is what TREK imports, so it must re-sync...
|
||||
expect(canonicalHash(flight())).not.toBe(
|
||||
canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })),
|
||||
);
|
||||
// ...but a change to the actual time alone must not (TREK never shows it).
|
||||
expect(canonicalHash(flight())).toBe(
|
||||
canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('is independent of seat ordering', () => {
|
||||
const a = flight({
|
||||
seats: [
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSavePayload } from '../../../src/services/airtrail/airtrailSync';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
|
||||
|
||||
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
|
||||
return {
|
||||
id: 1,
|
||||
icao: 'KJFK',
|
||||
iata: 'JFK',
|
||||
name: 'John F. Kennedy Intl.',
|
||||
lat: 40.6413,
|
||||
lon: -73.7781,
|
||||
tz: 'America/New_York',
|
||||
country: 'US',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An AirTrail flight as GET returns it, including the fields TREK doesn't model.
|
||||
* Typed as the raw object (known shape + arbitrary passthrough keys) because the
|
||||
* push spreads it wholesale rather than mapping each field — see buildSavePayload.
|
||||
*/
|
||||
function existingFlight(
|
||||
over: Partial<AirtrailFlightRaw> & Record<string, unknown> = {},
|
||||
): AirtrailFlightRaw & Record<string, unknown> {
|
||||
return {
|
||||
id: 42,
|
||||
from: airport(),
|
||||
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', tz: 'Europe/London' }),
|
||||
date: '2021-09-01',
|
||||
datePrecision: 'day',
|
||||
departure: '2021-09-01T23:00:00.000+00:00',
|
||||
arrival: '2021-09-02T07:00:00.000+00:00',
|
||||
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||
flightNumber: 'BA178',
|
||||
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||
aircraftReg: 'G-VIIL',
|
||||
flightReason: 'leisure',
|
||||
note: 'window seat',
|
||||
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
||||
// AirTrail-owned detail TREK never surfaces — must survive a writeback (#1240).
|
||||
departureScheduled: '2021-09-01',
|
||||
departureScheduledTime: '18:45',
|
||||
arrivalScheduled: '2021-09-02',
|
||||
arrivalScheduledTime: '08:10',
|
||||
takeoffActual: '2021-09-01',
|
||||
takeoffActualTime: '19:12',
|
||||
landingActual: '2021-09-02',
|
||||
landingActualTime: '07:55',
|
||||
departureTerminal: '7',
|
||||
departureGate: 'B22',
|
||||
arrivalTerminal: '5',
|
||||
arrivalGate: 'A10',
|
||||
customFields: { confirmation: 'ABC123' },
|
||||
track: [{ lat: 40.6, lon: -73.7 }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A linked TREK reservation (the shape getReservationWithJoins returns). */
|
||||
function reservation(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
external_id: '42',
|
||||
reservation_time: '2021-09-01T19:00',
|
||||
reservation_end_time: '2021-09-02T08:00',
|
||||
notes: 'window seat',
|
||||
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }),
|
||||
endpoints: [
|
||||
{ role: 'from', code: 'JFK' },
|
||||
{ role: 'to', code: 'LHR' },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('airtrailSync.buildSavePayload', () => {
|
||||
it('round-trips the AirTrail-owned fields TREK does not model (issue #1240)', () => {
|
||||
const payload = buildSavePayload(reservation(), existingFlight());
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload).toMatchObject({
|
||||
takeoffActual: '2021-09-01',
|
||||
takeoffActualTime: '19:12',
|
||||
landingActual: '2021-09-02',
|
||||
landingActualTime: '07:55',
|
||||
departureTerminal: '7',
|
||||
departureGate: 'B22',
|
||||
arrivalTerminal: '5',
|
||||
arrivalGate: 'A10',
|
||||
customFields: { confirmation: 'ABC123' },
|
||||
track: [{ lat: 40.6, lon: -73.7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the TREK time to the SCHEDULED fields so it round-trips on the next pull', () => {
|
||||
// Import reads the scheduled time, so a TREK edit must be pushed back there
|
||||
// (mirroring the read), overwriting AirTrail's stored scheduled value.
|
||||
const payload = buildSavePayload(reservation(), existingFlight());
|
||||
expect(payload).toMatchObject({
|
||||
departureScheduled: '2021-09-01T00:00:00.000Z',
|
||||
departureScheduledTime: '19:00',
|
||||
arrivalScheduled: '2021-09-02T00:00:00.000Z',
|
||||
arrivalScheduledTime: '08:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('blanks the scheduled time when the TREK reservation has only a date', () => {
|
||||
const payload = buildSavePayload(reservation({ reservation_time: '2021-09-01', reservation_end_time: null }), existingFlight());
|
||||
// A date carrier with no HH:MM leaves AirTrail's scheduled instant unset.
|
||||
expect(payload?.departureScheduledTime).toBeNull();
|
||||
expect(payload?.arrivalScheduled).toBeNull();
|
||||
expect(payload?.arrivalScheduledTime).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves a non-day date precision instead of resetting it to day', () => {
|
||||
const payload = buildSavePayload(reservation(), existingFlight({ datePrecision: 'month' }));
|
||||
expect(payload?.datePrecision).toBe('month');
|
||||
});
|
||||
|
||||
it('still applies the TREK-owned edits on top of the preserved fields', () => {
|
||||
const payload = buildSavePayload(
|
||||
reservation({
|
||||
reservation_time: '2021-09-01T20:30',
|
||||
notes: 'changed in TREK',
|
||||
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
|
||||
}),
|
||||
existingFlight(),
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
id: 42,
|
||||
from: 'JFK',
|
||||
to: 'LHR',
|
||||
departure: '2021-09-01',
|
||||
departureTime: '20:30',
|
||||
departureScheduled: '2021-09-01T00:00:00.000Z',
|
||||
departureScheduledTime: '20:30',
|
||||
flightNumber: 'BA999',
|
||||
note: 'changed in TREK',
|
||||
});
|
||||
// The user's seat number is pushed onto their own AirTrail seat.
|
||||
expect(payload?.seats[0].seatNumber).toBe('3C');
|
||||
// …without disturbing the preserved AirTrail detail.
|
||||
expect(payload?.departureTerminal).toBe('7');
|
||||
});
|
||||
|
||||
it('preserves AirTrail aircraft/airline/reason when TREK metadata omits them (#1240)', () => {
|
||||
// A TREK edit can drop these AirTrail-owned fields from metadata; the writeback
|
||||
// must fall back to AirTrail's current values rather than nulling them.
|
||||
const payload = buildSavePayload(reservation({ metadata: JSON.stringify({}) }), existingFlight());
|
||||
expect(payload).toMatchObject({
|
||||
airline: 'BAW', // entityCode(existing.airline) — icao preferred
|
||||
aircraft: 'B772',
|
||||
aircraftReg: 'G-VIIL',
|
||||
flightReason: 'leisure',
|
||||
flightNumber: 'BA178',
|
||||
note: 'window seat',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the existing seat manifest rather than replacing it', () => {
|
||||
const payload = buildSavePayload(
|
||||
reservation({ metadata: JSON.stringify({}) }),
|
||||
existingFlight({
|
||||
seats: [
|
||||
{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'business' },
|
||||
{ userId: null, guestName: 'Guest', seat: 'aisle', seatNumber: '12B', seatClass: 'business' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(payload?.seats).toHaveLength(2);
|
||||
expect(payload?.seats[1]).toMatchObject({ guestName: 'Guest', seatNumber: '12B' });
|
||||
});
|
||||
|
||||
it('returns null when an endpoint code is missing and no fallback exists', () => {
|
||||
const payload = buildSavePayload(reservation({ endpoints: [] }), existingFlight({ from: airport({ iata: null, icao: null }) }));
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The #1240 write gate: pushReservationToAirtrail must NOT write to AirTrail unless
|
||||
* the flight's owner has opted in (airtrail_write_enabled). Collaborators are mocked
|
||||
* so the test exercises just the gate + payload wiring.
|
||||
*/
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } }));
|
||||
vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ logError: vi.fn(), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/services/reservationService', () => ({
|
||||
getReservation: vi.fn(),
|
||||
getReservationWithJoins: vi.fn(),
|
||||
updateReservation: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/services/airtrail/airtrailClient', () => ({
|
||||
AirtrailAuthError: class AirtrailAuthError extends Error {},
|
||||
getFlight: vi.fn(),
|
||||
listFlights: vi.fn(),
|
||||
saveFlight: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/services/airtrail/airtrailMapper', () => ({
|
||||
canonicalHash: vi.fn(() => 'hash'),
|
||||
mapFlightToReservation: vi.fn(() => ({})),
|
||||
entityCode: (e: any) => e?.icao || e?.iata || null,
|
||||
}));
|
||||
vi.mock('../../../src/services/airtrail/airtrailService', () => ({
|
||||
isAirtrailWriteEnabled: vi.fn(),
|
||||
getAirtrailCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pushReservationToAirtrail } from '../../../src/services/airtrail/airtrailSync';
|
||||
import { db } from '../../../src/db/database';
|
||||
import { getReservationWithJoins } from '../../../src/services/reservationService';
|
||||
import { getFlight, saveFlight } from '../../../src/services/airtrail/airtrailClient';
|
||||
import { isAirtrailWriteEnabled, getAirtrailCredentials } from '../../../src/services/airtrail/airtrailService';
|
||||
|
||||
const linkedRow = { id: 5, trip_id: 9, external_id: '42', external_owner_user_id: 7, sync_enabled: 1 };
|
||||
const runSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Route db reads: global sync setting + the linked reservation row.
|
||||
(db.prepare as any).mockImplementation((sql: string) => ({
|
||||
get: () => {
|
||||
if (sql.includes('app_settings')) return { value: 'true' };
|
||||
if (sql.includes('FROM reservations')) return { ...linkedRow };
|
||||
return undefined;
|
||||
},
|
||||
run: (...args: any[]) => {
|
||||
runSpy(sql, args);
|
||||
return {};
|
||||
},
|
||||
all: () => [],
|
||||
}));
|
||||
(getAirtrailCredentials as any).mockReturnValue({ baseUrl: 'https://at.example', apiKey: 'k', allowInsecureTls: false });
|
||||
// GET returns AirTrail-owned detail TREK doesn't model — must survive the writeback.
|
||||
(getFlight as any).mockResolvedValue({ id: 42, from: { iata: 'JFK' }, to: { iata: 'LHR' }, seats: [], departureTerminal: '7' });
|
||||
(saveFlight as any).mockResolvedValue({ id: 42 });
|
||||
(getReservationWithJoins as any).mockReturnValue({
|
||||
external_id: '42',
|
||||
reservation_time: '2021-09-01T19:00',
|
||||
reservation_end_time: '2021-09-02T08:00',
|
||||
notes: 'note',
|
||||
metadata: JSON.stringify({}),
|
||||
endpoints: [
|
||||
{ role: 'from', code: 'JFK' },
|
||||
{ role: 'to', code: 'LHR' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushReservationToAirtrail write gate (#1240)', () => {
|
||||
it('does nothing — and does not detach — when the owner has not opted in', async () => {
|
||||
(isAirtrailWriteEnabled as any).mockReturnValue(false);
|
||||
await pushReservationToAirtrail(5, 9);
|
||||
expect(getFlight).not.toHaveBeenCalled();
|
||||
expect(saveFlight).not.toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled(); // no detach, no hash write — pure no-op
|
||||
});
|
||||
|
||||
it('writes back, preserving AirTrail-owned fields, when the owner has opted in', async () => {
|
||||
(isAirtrailWriteEnabled as any).mockReturnValue(true);
|
||||
await pushReservationToAirtrail(5, 9);
|
||||
expect(saveFlight).toHaveBeenCalledTimes(1);
|
||||
const payload = (saveFlight as any).mock.calls[0][1];
|
||||
expect(payload.departureTerminal).toBe('7'); // spread preserved the unmanaged field
|
||||
expect(payload.from).toBe('JFK'); // TREK-managed field still applied as a code
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Avoid any real DNS/network from the SSRF guard during saveSettings.
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false })),
|
||||
safeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
isAirtrailWriteEnabled,
|
||||
saveSettings,
|
||||
} from '../../../src/services/airtrail/airtrailService';
|
||||
|
||||
describe('airtrail writeback opt-in persistence (#1240)', () => {
|
||||
it('defaults the writeback opt-in to off for a new user', () => {
|
||||
const { user } = createUser(db);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect(getConnectionSettings(user.id).writeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('persists the opt-in and lets it be toggled back off without dropping the key', async () => {
|
||||
const { user } = createUser(db);
|
||||
|
||||
await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(true);
|
||||
const on = getConnectionSettings(user.id);
|
||||
expect(on.writeEnabled).toBe(true);
|
||||
expect(on.connected).toBe(true); // key stored
|
||||
|
||||
// No key supplied keeps the stored key; only the opt-in flips back off.
|
||||
await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null);
|
||||
expect(isAirtrailWriteEnabled(user.id)).toBe(false);
|
||||
expect(getConnectionSettings(user.id).connected).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
// Data-integrity guard for the shipped Atlas region bundle. geoBoundaries fills
|
||||
// shapeISO with the bare country code for some countries (every Spanish region got
|
||||
// "ESP", every Chinese "CHN", also CL/OM), which made marking one region light up the
|
||||
// whole country (#1217). build-atlas-geo.mjs now synthesizes a unique per-region code
|
||||
// for those; this asserts the shipped bundle actually carries distinct codes.
|
||||
describe('Atlas admin1 region bundle (#1217)', () => {
|
||||
const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
||||
const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
|
||||
properties: { iso_a2: string | null; iso_3166_2: string };
|
||||
}[];
|
||||
|
||||
const regions = (a2: string) => features.filter(f => f.properties.iso_a2 === a2);
|
||||
|
||||
it('ATLAS-BUNDLE-001 — previously-broken countries now have distinct region codes', () => {
|
||||
for (const a2 of ['ES', 'CN', 'CL', 'OM']) {
|
||||
const f = regions(a2);
|
||||
expect(f.length, `${a2} should ship regions`).toBeGreaterThan(1);
|
||||
expect(new Set(f.map(r => r.properties.iso_3166_2)).size, `${a2} region codes must be unique`).toBe(f.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('ATLAS-BUNDLE-002 — countries with real ISO codes keep them and stay unique', () => {
|
||||
for (const a2 of ['DE', 'FR', 'US']) {
|
||||
const f = regions(a2);
|
||||
expect(f.length).toBeGreaterThan(1);
|
||||
// real ISO 3166-2 form, e.g. DE-BW
|
||||
expect(f.some(r => /^[A-Z]{2}-[A-Z0-9]+$/.test(r.properties.iso_3166_2))).toBe(true);
|
||||
expect(new Set(f.map(r => r.properties.iso_3166_2)).size).toBe(f.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const mockDb = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||
import { calculateSettlement, updateSettlement } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -189,4 +189,60 @@ describe('calculateSettlement', () => {
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].amount).toBe(20);
|
||||
});
|
||||
|
||||
it('counts a settlement with no matching expense as an amount still to square up', () => {
|
||||
// bob paid alice 30 but every expense behind it was deleted: alice now owes bob.
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('FROM budget_settlements')) {
|
||||
return { all: vi.fn(() => [
|
||||
{ id: 1, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||
]), get: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||
});
|
||||
const result = calculateSettlement(1);
|
||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||
const bob = result.balances.find(b => b.user_id === 2)!;
|
||||
expect(bob.balance).toBe(30);
|
||||
expect(alice.balance).toBe(-30);
|
||||
expect(result.flows).toEqual([
|
||||
expect.objectContaining({ amount: 30, from: expect.objectContaining({ user_id: 1 }), to: expect.objectContaining({ user_id: 2 }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateSettlement ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateSettlement', () => {
|
||||
it('returns null when the settlement is not in the trip', () => {
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
return { get: vi.fn(() => undefined), all: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
expect(updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the row (rounded to cents) and returns the refreshed settlement', () => {
|
||||
const run = vi.fn();
|
||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||
if (sql.includes('SELECT id FROM budget_settlements')) {
|
||||
return { get: vi.fn(() => ({ id: 7 })), all: vi.fn(), run: vi.fn() };
|
||||
}
|
||||
if (sql.includes('UPDATE budget_settlements')) {
|
||||
return { get: vi.fn(), all: vi.fn(), run };
|
||||
}
|
||||
if (sql.includes('FROM budget_settlements')) {
|
||||
return { get: vi.fn(), all: vi.fn(() => [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 10.13, from_username: 'bob', to_username: 'alice', from_avatar: null, to_avatar: null },
|
||||
]), run: vi.fn() };
|
||||
}
|
||||
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
});
|
||||
|
||||
const res = updateSettlement(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
|
||||
expect(run).toHaveBeenCalledWith(2, 1, 10.13, 7);
|
||||
expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,6 +332,41 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
|
||||
expect(result.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
|
||||
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
|
||||
// inline coords; the redirect target carries the !3d!4d data param.
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
|
||||
}
|
||||
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
|
||||
expect(result.lat).toBeCloseTo(48.8584, 3);
|
||||
expect(result.lng).toBeCloseTo(2.2945, 3);
|
||||
});
|
||||
|
||||
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
|
||||
const fetchMock = vi.fn(async (u: string) => {
|
||||
if (u.includes('nominatim')) {
|
||||
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
|
||||
}
|
||||
if (u.includes('cid=')) {
|
||||
// Redirect target has no inline coords.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
|
||||
}
|
||||
// Body fetch of the resolved URL embeds coords in the map data.
|
||||
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
|
||||
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
|
||||
expect(result.lat).toBeCloseTo(40.6892, 3);
|
||||
expect(result.lng).toBeCloseTo(-74.0445, 3);
|
||||
});
|
||||
|
||||
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
|
||||
const adversarial = '/@' + '1'.repeat(10000) + '.';
|
||||
const start = Date.now();
|
||||
|
||||
@@ -397,6 +397,46 @@ describe('exportICS', () => {
|
||||
|
||||
expect(ics).toContain('DTEND:20250602T160000');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-010: flight with endpoint times but no reservation_time is included', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
|
||||
const reservation = createReservation(testDb, trip.id, {
|
||||
title: 'CDG → JFK',
|
||||
type: 'flight',
|
||||
});
|
||||
// Confirmed flights store times per endpoint, never as reservation_time.
|
||||
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
|
||||
const insertEp = testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
|
||||
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
|
||||
|
||||
const { ics } = exportICS(trip.id);
|
||||
|
||||
expect(ics).toContain('SUMMARY:CDG → JFK');
|
||||
expect(ics).toContain('DTSTART:20250602T090000');
|
||||
expect(ics).toContain('DTEND:20250602T120000');
|
||||
expect(ics).toContain('Route: CDG → JFK');
|
||||
});
|
||||
|
||||
it('TRIP-SVC-011: flight endpoint with no local_date is skipped (relative Day-N trips)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Relative Trip' });
|
||||
const reservation = createReservation(testDb, trip.id, {
|
||||
title: 'Timeless Flight',
|
||||
type: 'flight',
|
||||
});
|
||||
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null);
|
||||
|
||||
const { ics } = exportICS(trip.id);
|
||||
|
||||
expect(ics).not.toContain('SUMMARY:Timeless Flight');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteOldCover — path containment ──────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user