fix(ci): message part types across the strict shared build, formatting and trip member write tests

This commit is contained in:
Maurice
2026-10-22 13:03:59 +02:00
parent 76c7323e23
commit fd7fbef295
7 changed files with 129 additions and 20 deletions
@@ -171,7 +171,7 @@ function pushCard(blocks: MessageBlock[], entry: CardEntry, group: string): void
function withoutYielded(blocks: MessageBlock[], rules: CardRules): MessageBlock[] {
const changes = blocks.flatMap((b) => (b.kind === 'change' ? b.items.flatMap((i) => (i.card ? [i.card] : [])) : []));
if (changes.length === 0) return blocks;
return blocks.flatMap((block) => {
return blocks.flatMap((block): MessageBlock[] => {
if (block.kind !== 'cards') return [block];
const entries = block.entries.filter((e) => !changes.some((change) => rules.yieldsToChange(e.card, change)));
return entries.length > 0 ? [{ ...block, entries }] : [];
+1 -5
View File
@@ -330,11 +330,7 @@ export function nameArgOf(tool: string, key: string, value: number, card: TripCa
}
/** The reads an undo of `tool` makes before its check; none when its package asks for none or fails to say. */
export function undoReadsOf(
tool: string,
args: ToolArgs,
result: unknown,
): UndoReads {
export function undoReadsOf(tool: string, args: ToolArgs, result: unknown): UndoReads {
return TOOL_CARDS[tool]?.undo?.reads?.(args, result) ?? [];
}
@@ -73,9 +73,7 @@ export class TripMemberWritesService {
async deleteGuest(tripId: string | number, guestId: number, writer: TripWriter): Promise<boolean> {
// Read first: the rows that name the guest go with the guest.
const shared = (await this.budget.listBudgetItems(tripId))
.filter(
(item) => item.paid_by_user_id === guestId || item.members?.some((member) => member.user_id === guestId),
)
.filter((item) => item.paid_by_user_id === guestId || item.members?.some((member) => member.user_id === guestId))
.map((item) => item.id);
if (!(await this.roster.deleteGuest(tripId, guestId))) return false;
writer.events.emit('member:removed', { userId: guestId });
+1 -1
View File
@@ -15,10 +15,10 @@ import { RoadtripPreferences } from '../../../src/db/entities/RoadtripPreference
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createUser, createTrip, createDay, createPlace } from '../../helpers/factories';
import { addTripMember } from '../../helpers/factories';
import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
@@ -0,0 +1,113 @@
/**
* TripMemberWritesService over a stubbed roster and budget. MEMBER-WRITES-001+.
*
* The routes and the MCP tools reach these use cases with rows that exist; this
* file pins the two fallbacks they never meet: an inviter whose address cannot
* be read still sends the invitation, and an expense that is gone by the time a
* deleted guest's split is read back again is left out instead of announced.
*/
import { BudgetService } from '../../../src/nest/budget/budget.service';
import type { TripWriter } from '../../../src/nest/common/trip-writer';
import { TripMemberWritesService } from '../../../src/nest/trip-members/trip-member-writes.service';
import { TripMembersModule } from '../../../src/nest/trip-members/trip-members.module';
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let writes: TripMemberWritesService;
const roster = {
addMember: vi.fn(),
notifyInvite: vi.fn(),
deleteGuest: vi.fn(),
};
const budget = {
listBudgetItems: vi.fn(),
getBudgetItem: vi.fn(),
};
/** A writer whose user has no row, so nothing about them can be read back. */
function writerFor(userId: number): TripWriter {
return { userId, role: 'user', surface: 'rest', events: { emit: vi.fn(), emitAll: vi.fn() } };
}
beforeAll(async () => {
t = await createTestModule({
db,
imports: [TripMembersModule],
overrides: [
{ provide: TripMembersService, useValue: roster },
{ provide: BudgetService, useValue: budget },
],
});
writes = t.get(TripMemberWritesService);
});
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(async () => {
await t.close();
db.close();
});
describe('TripMemberWritesService', () => {
it('MEMBER-WRITES-001: an inviter whose address cannot be read still sends the invitation, with an empty address', async () => {
const member = { id: 21, email: 'invitee@example.test' };
roster.addMember.mockResolvedValue({ member, targetUserId: 21, tripTitle: 'Lisbon' });
const writer = writerFor(987654);
const result = await writes.addMember(7, 'invitee@example.test', 3, writer);
expect(result.member).toBe(member);
expect(roster.addMember).toHaveBeenCalledWith(7, 'invitee@example.test', 3, 987654);
expect(writer.events.emit).toHaveBeenCalledWith('member:added', { member });
expect(roster.notifyInvite).toHaveBeenCalledWith(
'7',
{ id: 987654, email: '' },
21,
'Lisbon',
'invitee@example.test',
);
});
it('MEMBER-WRITES-002: deleting a guest announces only the expenses that still read back after the re-split', async () => {
const guestId = 40;
budget.listBudgetItems.mockResolvedValue([
{ id: 1, paid_by_user_id: guestId, members: [] },
{ id: 2, paid_by_user_id: null, members: [{ user_id: guestId }] },
{ id: 3, paid_by_user_id: null, members: [{ user_id: 5 }] },
{ id: 4, paid_by_user_id: null },
]);
roster.deleteGuest.mockResolvedValue(true);
budget.getBudgetItem.mockImplementation((itemId: number) => (itemId === 1 ? { id: 1 } : null));
const writer = writerFor(3);
expect(await writes.deleteGuest(7, guestId, writer)).toBe(true);
expect(budget.getBudgetItem.mock.calls).toEqual([
[1, 7],
[2, 7],
]);
expect(vi.mocked(writer.events.emit).mock.calls).toEqual([
['member:removed', { userId: guestId }],
['budget:updated', { item: { id: 1 } }],
]);
});
it('MEMBER-WRITES-003: a guest the roster does not delete reads nothing back and announces nothing', async () => {
budget.listBudgetItems.mockResolvedValue([{ id: 1, paid_by_user_id: 40, members: [] }]);
roster.deleteGuest.mockResolvedValue(false);
const writer = writerFor(3);
expect(await writes.deleteGuest(7, 40, writer)).toBe(false);
expect(budget.getBudgetItem).not.toHaveBeenCalled();
expect(writer.events.emit).not.toHaveBeenCalled();
});
});
@@ -290,10 +290,7 @@ describe('ReservationsRpc', () => {
const f = build({ created: true });
await f
.host()
.dispatch(
req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'hotel' } }),
42,
);
.dispatch(req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'hotel' } }), 42);
expect(events(f.realtime)).toEqual(['accommodation:created', 'reservation:updated']);
});
+11 -6
View File
@@ -542,12 +542,17 @@ export const assistantToolApprovalSchema = z.object({
});
export type AssistantToolApproval = z.infer<typeof assistantToolApprovalSchema>;
/** The parts of a message, with the change, receipt and card parts read or written as given. */
function messagePartSchema(
change: z.ZodType<AssistantChangePreview>,
applied: z.ZodType<AssistantApplied>,
card: z.ZodType<AssistantCard>,
) {
/**
* The parts of a message, with the change, receipt and card parts read or
* written as given. Generic over the three schemas so the emitted declaration
* keeps each one's own Zod type instead of a precomputed object type, which a
* consumer without strictNullChecks would read differently from its own infer.
*/
function messagePartSchema<
Change extends z.ZodType<AssistantChangePreview>,
Applied extends z.ZodType<AssistantApplied>,
Card extends z.ZodType<AssistantCard>,
>(change: Change, applied: Applied, card: Card) {
return z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
// A file the user handed the assistant. Only its description is kept, never its content.