Files
TREK/shared/src/budget/budget.schema.spec.ts
T
Maurice 6d6efddc4e feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools
- list_budget_items lists a trip's expenses with their ids, payers and
  splits, page by page so no page is cut down on its way to a model;
  get_budget_per_person answers who paid and owes what. Both in a new
  budget-items.mcp.ts, checked like their REST twins.
- get_wind_forecast reads the hourly wind, gusts and direction of a day.
- get_journey_tracks lists a journey's GPX tracks; the line for drawing
  rides under a key the provider never receives.
- The Dawarich wishlist scan and the countries the recordings suggest
  become tools, with their confirming writes, in DawarichAtlasMcp in the
  Atlas group; the shared time window check refuses alike on REST and
  MCP, and the model never sees when the user was last seen in a city.
- The journal's map, tracks and recorded days load with the journey
  group instead of riding in the core of every step.
- The prompt points at list_budget_items for expense ids (the trip
  summary never carried them) and names the shares and the wind tools;
  each new read has its own live step.
2026-10-22 21:30:38 +00:00

174 lines
6.7 KiB
TypeScript

import {
budgetCreateItemRequestSchema,
budgetUpdateItemRequestSchema,
budgetCreateSettlementRequestSchema,
budgetUpdateSettlementRequestSchema,
budgetFreezeRatesRequestSchema,
budgetSettlementQuerySchema,
budgetUnconvertedSchema,
budgetUpdateMembersRequestSchema,
budgetToggleMemberPaidRequestSchema,
budgetReorderItemsRequestSchema,
budgetItemsPageSchema,
BUDGET_ITEMS_PAGE_DEFAULT,
COST_CATEGORIES,
typeToCostCategory,
} from './budget.schema';
import { describe, it, expect } from 'vitest';
describe('budgetCreateItemRequestSchema', () => {
it('requires a name; money/meta fields optional + nullable', () => {
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success).toBe(true);
expect(
budgetCreateItemRequestSchema.safeParse({
name: 'Hotel',
total_price: 200,
persons: null,
}).success,
).toBe(true);
expect(budgetCreateItemRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('budgetUpdateMembersRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
});
});
describe('budgetToggleMemberPaidRequestSchema', () => {
it('requires a boolean paid', () => {
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success).toBe(true);
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success).toBe(false);
});
});
describe('budgetReorderItemsRequestSchema', () => {
it('requires numeric ids', () => {
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
});
});
describe('COST_CATEGORIES', () => {
it('includes fuel and parking alongside the existing fixed categories', () => {
expect(COST_CATEGORIES).toContain('fuel');
expect(COST_CATEGORIES).toContain('parking');
});
});
describe('typeToCostCategory', () => {
it('files a parking booking under parking, not transport', () => {
expect(typeToCostCategory('parking')).toBe('parking');
});
it('leaves the other vehicle types on transport', () => {
expect(typeToCostCategory('car-rental')).toBe('transport');
expect(typeToCostCategory('taxi')).toBe('transport');
});
});
describe('fallback_fx (rates lent for a write)', () => {
const fx = { base: 'AUD', rates: { VND: 18241.3 } };
it('is optional on all four write bodies and kept when well formed', () => {
const cases = [
[budgetCreateItemRequestSchema, { name: 'Pho' }],
[budgetUpdateItemRequestSchema, {}],
[budgetCreateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }],
[budgetUpdateSettlementRequestSchema, { from_user_id: 1, to_user_id: 2, amount: 10 }],
] as const;
for (const [schema, body] of cases) {
expect(schema.safeParse(body).success).toBe(true);
const parsed = schema.safeParse({ ...body, fallback_fx: fx });
expect(parsed.success).toBe(true);
expect(parsed.data?.fallback_fx).toEqual(fx);
}
});
const accepted = (fallback_fx: unknown) =>
budgetCreateItemRequestSchema.safeParse({ name: 'Pho', fallback_fx }).success;
it('refuses a base or a currency that is not three upper-case letters', () => {
expect(accepted({ base: 'aud', rates: { VND: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUDX', rates: { VND: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUD', rates: { vnd: 1.5 } })).toBe(false);
expect(accepted({ base: 'AUD', rates: { VNDX: 1.5 } })).toBe(false);
});
it('refuses a rate that is zero, negative, NaN, infinite or above 1e9', () => {
for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1e9 + 1]) {
expect(accepted({ base: 'AUD', rates: { VND: rate } })).toBe(false);
}
expect(accepted({ base: 'AUD', rates: { VND: 1e9 } })).toBe(true);
expect(accepted({ base: 'AUD', rates: { VND: 1e-9 } })).toBe(true);
});
it('refuses an empty table and one with more than 200 currencies', () => {
// AAA, AAB, … : n distinct three-letter codes.
const codes = (n: number) =>
Object.fromEntries(
Array.from({ length: n }, (_, i) => [
String.fromCharCode(65 + Math.floor(i / 676), 65 + (Math.floor(i / 26) % 26), 65 + (i % 26)),
1.5,
]),
);
expect(accepted({ base: 'AUD', rates: {} })).toBe(false);
expect(accepted({ base: 'AUD', rates: codes(200) })).toBe(true);
expect(accepted({ base: 'AUD', rates: codes(201) })).toBe(false);
});
});
describe('budgetFreezeRatesRequestSchema', () => {
it('accepts an empty body and a well-formed fallback table', () => {
expect(budgetFreezeRatesRequestSchema.safeParse({}).success).toBe(true);
expect(
budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 18241.3 } } }).success,
).toBe(true);
expect(budgetFreezeRatesRequestSchema.safeParse({ fallback_fx: { base: 'AUD', rates: { VND: 0 } } }).success).toBe(
false,
);
});
});
describe('budgetSettlementQuerySchema', () => {
it('coerces base_rate from the query string and leaves base as it came', () => {
expect(budgetSettlementQuerySchema.parse({})).toEqual({});
expect(budgetSettlementQuerySchema.parse({ base: 'eur', base_rate: '0.61' })).toEqual({
base: 'eur',
base_rate: 0.61,
});
});
it('refuses a base_rate that is not a positive number in range', () => {
for (const base_rate of ['abc', '0', '-1', '1e10', '']) {
expect(budgetSettlementQuerySchema.safeParse({ base_rate }).success).toBe(false);
}
});
});
describe('budgetUnconvertedSchema', () => {
it('lists row ids and currencies', () => {
expect(budgetUnconvertedSchema.safeParse({ item_ids: [3], settlement_ids: [], currencies: ['VND'] }).success).toBe(
true,
);
expect(budgetUnconvertedSchema.safeParse({ item_ids: ['3'], settlement_ids: [], currencies: [] }).success).toBe(
false,
);
});
});
describe('budgetItemsPageSchema', () => {
it('takes no paging at all, or an offset and a limit of 1 to 100 whole expenses', () => {
expect(budgetItemsPageSchema.safeParse({}).success).toBe(true);
expect(budgetItemsPageSchema.safeParse({ offset: 0, limit: 1 }).success).toBe(true);
expect(budgetItemsPageSchema.safeParse({ offset: 150, limit: 100 }).success).toBe(true);
for (const page of [{ offset: -1 }, { offset: 1.5 }, { limit: 0 }, { limit: 101 }, { limit: '10' }]) {
expect(budgetItemsPageSchema.safeParse(page).success, JSON.stringify(page)).toBe(false);
}
expect(BUDGET_ITEMS_PAGE_DEFAULT).toBe(50);
});
});