Widen the test stubs the typecheck rejected

conditions.test.ts lost noTrips when the condition kind landed; oauth.test.ts
lost the RFC 8707 resource indicator when it became required. Both are
fixtures that stopped matching a contract that grew, and both are filled in
with the value that keeps every existing case reading as it did.

budget.service.calc.test.ts typed its prepare mock from the default
implementation rather than from prepare's own signature, so all ten later
mockImplementation calls had to reproduce that exact three-member literal.
One stmt() helper names the widening instead.
This commit is contained in:
Maurice
2026-08-10 17:55:54 +02:00
committed by Maurice
parent f691da8f7e
commit ff286edc45
3 changed files with 63 additions and 31 deletions
+13
View File
@@ -212,6 +212,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Try to use it with client2
@@ -241,6 +242,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const res = await request(app)
@@ -269,6 +271,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const res = await request(app)
@@ -297,6 +300,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const res = await request(app)
@@ -325,6 +329,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const res = await request(app)
@@ -387,6 +392,7 @@ describe('POST /oauth/token — refresh_token grant', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Exchange code for tokens
@@ -478,6 +484,7 @@ describe('POST /oauth/revoke', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const tokenRes = await request(app)
@@ -898,6 +905,7 @@ describe('Sessions — /api/oauth/sessions', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Get a token so there's a session to revoke
@@ -1006,6 +1014,7 @@ describe('H1 — PKCE format validation', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Submit a valid-looking but wrong-format verifier (too short)
@@ -1063,6 +1072,7 @@ describe('H5 — All invalid_grant cases return identical response body', () =>
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Bad code
@@ -1083,6 +1093,7 @@ describe('H5 — All invalid_grant cases return identical response body', () =>
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const res2 = await request(app).post('/oauth/token').send({
grant_type: 'authorization_code',
@@ -1208,6 +1219,7 @@ describe('C3 — Refresh token replay detection', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
// Get initial tokens
@@ -1254,6 +1266,7 @@ describe('C3 — Refresh token replay detection', () => {
scopes: ['trips:read'],
codeChallenge: challenge,
codeChallengeMethod: 'S256',
resource: null,
});
const t1 = await request(app).post('/oauth/token').send({
@@ -14,11 +14,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockDb = vi.hoisted(() => {
return {
db: {
prepare: vi.fn(() => ({
all: vi.fn(() => []),
get: vi.fn(() => undefined),
run: vi.fn(),
})),
// Typed as the real prepare(sql) rather than inferred from this default
// implementation: inferred, every later mockImplementation had to match
// the exact three-member object literal below, so widening one of them
// broke the other nine.
prepare: vi.fn<(sql: string) => import('better-sqlite3').Statement>(
() =>
({
all: vi.fn(() => [] as unknown[]),
get: vi.fn(() => undefined as unknown),
run: vi.fn(),
}) as unknown as import('better-sqlite3').Statement,
),
},
closeDb: () => {},
reinitialize: () => {},
@@ -41,6 +48,16 @@ import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src
import type Database from 'better-sqlite3';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
/**
* A prepared-statement stub.
*
* better-sqlite3's Statement has a dozen members; these cases drive three of
* them. Naming the widening once beats a cast at each of the sixteen returns,
* and keeps `prepare`'s own signature honest so a genuinely wrong mock still
* fails to compile.
*/
const stmt = (impl: Partial<Database.Statement>) => impl as Database.Statement;
const permissionsStub = { checkPermission: vi.fn(() => true) } as unknown as PermissionsService;
const budget = new BudgetService(
@@ -87,18 +104,18 @@ function setupDb(
) {
mockDb.db.prepare.mockImplementation((sql: string) => {
if (sql.includes('SELECT * FROM budget_items')) {
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => items), get: vi.fn(), run: vi.fn() });
}
if (sql.includes('budget_item_members')) {
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => members), get: vi.fn(), run: vi.fn() });
}
if (sql.includes('budget_item_payers')) {
return { all: vi.fn(() => payers), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => payers), get: vi.fn(), run: vi.fn() });
}
if (sql.includes('budget_settlements')) {
return { all: vi.fn(() => settlements), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => settlements), get: vi.fn(), run: vi.fn() });
}
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() });
});
}
@@ -240,11 +257,11 @@ describe('calculateSettlement', () => {
// 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(() => [
return stmt({ 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() };
]), get: vi.fn(), run: vi.fn() });
}
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
return stmt({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() });
});
const result = budget.calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
@@ -391,14 +408,13 @@ describe('calculateSettlement', () => {
// ── freezeForeignRate (write-path FX freeze, #1445) ───────────────────────────
describe('freezeForeignRate', () => {
const tripRow = (currency: string) => ({
get: vi.fn(() => ({ currency })), all: vi.fn(), run: vi.fn(),
});
const tripRow = (currency: string) =>
stmt({ get: vi.fn(() => ({ currency })), all: vi.fn(), run: vi.fn() });
it('freezes the live rate for a foreign currency into exchange_rate', async () => {
mockDb.db.prepare.mockImplementation((sql: string) => {
if (sql.includes('FROM trips')) return tripRow('EUR');
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
});
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'usd' };
@@ -409,7 +425,7 @@ describe('freezeForeignRate', () => {
it('leaves the rate unset when the currency equals the trip currency', async () => {
mockDb.db.prepare.mockImplementation((sql: string) =>
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
sql.includes('FROM trips') ? tripRow('EUR') : stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }));
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'EUR' };
await budget.freezeForeignRate(1, data);
expect(mockRates.getRates).not.toHaveBeenCalled();
@@ -425,7 +441,7 @@ describe('freezeForeignRate', () => {
it('degrades to live rates (no freeze) when the rate fetch fails', async () => {
mockDb.db.prepare.mockImplementation((sql: string) =>
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
sql.includes('FROM trips') ? tripRow('EUR') : stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }));
mockRates.getRates.mockResolvedValue(null);
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
await budget.freezeForeignRate(1, data);
@@ -434,9 +450,9 @@ describe('freezeForeignRate', () => {
it('does not re-freeze on update when the currency is unchanged', async () => {
mockDb.db.prepare.mockImplementation((sql: string) => {
if (sql.includes('FROM budget_items')) return { get: vi.fn(() => ({ currency: 'USD' })), all: vi.fn(), run: vi.fn() };
if (sql.includes('FROM budget_items')) return stmt({ get: vi.fn(() => ({ currency: 'USD' })), all: vi.fn(), run: vi.fn() });
if (sql.includes('FROM trips')) return tripRow('EUR');
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
});
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
await budget.freezeForeignRate(1, data, 9);
@@ -446,7 +462,7 @@ describe('freezeForeignRate', () => {
it('does not re-freeze a settlement edit when its stored currency is unchanged (#1445)', async () => {
mockDb.db.prepare.mockImplementation((sql: string) =>
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
sql.includes('FROM trips') ? tripRow('EUR') : stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }));
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
// the settlement already holds USD — pass it as existingCurrency → keep the frozen rate
await budget.freezeForeignRate(1, data, undefined, 'USD');
@@ -456,7 +472,7 @@ describe('freezeForeignRate', () => {
it('re-freezes a settlement edit when its currency actually changes', async () => {
mockDb.db.prepare.mockImplementation((sql: string) =>
sql.includes('FROM trips') ? tripRow('EUR') : { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
sql.includes('FROM trips') ? tripRow('EUR') : stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() }));
mockRates.getRates.mockResolvedValue({ EUR: 1, USD: 1.25 });
const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' };
await budget.freezeForeignRate(1, data, undefined, 'GBP'); // was GBP → now USD → re-freeze
@@ -470,9 +486,9 @@ describe('applySettlementUpdate', () => {
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 stmt({ get: vi.fn(() => undefined), all: vi.fn(), run: vi.fn() });
}
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
});
expect(budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10 })).toBeNull();
});
@@ -481,19 +497,19 @@ describe('applySettlementUpdate', () => {
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() };
return stmt({ 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 };
return stmt({ get: vi.fn(), all: vi.fn(), run });
}
if (sql.includes('FROM budget_settlements')) {
// Quirk fix: the re-select is a targeted single-row get, not a full
// listSettlements scan.
return { get: vi.fn(() => (
return stmt({ get: 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 }
)), all: vi.fn(() => []), run: vi.fn() };
)), all: vi.fn(() => []), run: vi.fn() });
}
return { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
return stmt({ get: vi.fn(), all: vi.fn(() => []), run: vi.fn() });
});
const res = budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 });
@@ -14,7 +14,10 @@ const baseNotice: SystemNotice = {
};
const baseCtx = {
user: { login_count: 5, first_seen_version: '1.0.0', role: 'user' },
// noTrips joined the context when the 'noTrips' condition kind landed. 1 keeps
// every case below reading as it did: the condition is `noTrips === 0`, so a
// user WITH trips leaves it false and the other kinds decide on their own.
user: { login_count: 5, first_seen_version: '1.0.0', role: 'user', noTrips: 1 },
currentAppVersion: '2.0.0',
now: new Date('2026-06-01T00:00:00Z'),
};