Typecheck the test tree, and fix what it found

server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.

tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.

Three of the first fixes were not type noise.

DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.

host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.

auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
This commit is contained in:
Maurice
2026-08-10 17:31:37 +02:00
committed by Maurice
parent 6cf5760383
commit 4780a7be90
5 changed files with 69 additions and 22 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* A request body the case under test does not read.
*
* Controller unit tests stub the service and assert on the mapping around it:
* which status an error becomes, whether the cookie is set, which audit fires.
* The body never reaches validation on that path, because the global
* ZodValidationPipe is not in the unit harness, so filling in plausible fields
* would be inventing data no assertion looks at.
*
* Written as a named helper rather than a bare `as` cast so the intent is
* greppable: `anyBody()` means "irrelevant here", not "we gave up on the type".
* Anything a case DOES read belongs in the argument.
*/
export function anyBody<T>(body: Partial<T> = {}): T {
return body as T;
}
+22 -20
View File
@@ -16,6 +16,8 @@ import type { AuthService } from '../../../src/nest/auth/auth.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import { isDemoEmail } from '../../../src/nest/common/demo';
import type { User } from '../../../src/types';
import { anyBody } from '../../helpers/dto';
import type { ForgotPasswordDto } from '../../../src/nest/auth/auth.dto';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
const req = { ip: '9.9.9.9', headers: {} } as Request;
@@ -89,7 +91,7 @@ describe('AuthPublicController', () => {
});
it('register audits + sets cookie; maps error', () => {
expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
expect(thrown(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
const setAuthCookie = vi.fn();
const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
@@ -107,13 +109,13 @@ describe('AuthPublicController', () => {
it('login: mfa branch, success cookie, error mapping', async () => {
const setAuthCookie = vi.fn();
const mfa = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
expect(await mfa.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
expect(await ok.login(anyBody(), req, res)).toEqual({ token: 'tk', user });
// The "remember me" flag from the service rides through to the cookie service.
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
expect(await thrownAsync(() => bad.login(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
}, 10000);
it('forgot-password issues a reset email then returns the generic ok', async () => {
@@ -124,10 +126,10 @@ describe('AuthPublicController', () => {
}, 10000);
it('reset-password: error audits a fail, mfa branch, success', () => {
expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
expect(thrown(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true });
expect(apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ success: true });
});
it('app-config forwards the optional user (present and absent)', () => {
@@ -147,7 +149,7 @@ describe('AuthPublicController', () => {
it('login takes the mfa-required branch and never sets a cookie', async () => {
const setAuthCookie = vi.fn();
const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), rl());
expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
expect(await c.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
expect(setAuthCookie).not.toHaveBeenCalled();
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' }));
}, 10000);
@@ -169,7 +171,7 @@ describe('AuthPublicController', () => {
it('forgot-password ignores a non-string email body', async () => {
const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null });
const c = apc(asvc({ requestPasswordReset } as Partial<AuthService>), rl());
expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true });
expect(await c.forgotPassword(anyBody<ForgotPasswordDto>({ email: 42 as unknown as string }), req)).toEqual({ ok: true });
expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String));
}, 10000);
@@ -178,12 +180,12 @@ describe('AuthPublicController', () => {
const now = Date.now();
for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now);
const c = apc(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), s);
expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
expect(thrown(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
});
it('mfa/verify-login maps a service error', () => {
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
expect(thrown(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
});
it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => {
@@ -191,14 +193,14 @@ describe('AuthPublicController', () => {
const now = Date.now();
for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
expect(thrown(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
});
it('mfa/verify-login sets cookie + audits; logout clears cookie', () => {
const setAuthCookie = vi.fn();
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user });
expect(c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalled();
const clearAuthCookie = vi.fn();
expect(apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
@@ -213,8 +215,8 @@ describe('AuthController (authenticated)', () => {
});
it('change-password maps error, else audits', () => {
expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, {}, req)).toEqual({ success: true });
expect(thrown(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } });
expect(ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
});
@@ -256,13 +258,13 @@ describe('AuthController (authenticated)', () => {
// exhaust the shared 'login' bucket for this ip (max 5)
for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now);
const c = ac(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
expect(thrown(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
});
it('change-password refreshes this device cookie when the service returns a token', () => {
const setAuthCookie = vi.fn();
const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
expect(c.changePassword(user, {}, req, res)).toEqual({ success: true });
expect(c.changePassword(user, anyBody(), req, res)).toEqual({ success: true });
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
});
@@ -277,7 +279,7 @@ describe('AuthController (authenticated)', () => {
expect(ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, { a: 1 })).toEqual({ ok: 1 });
expect(ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never))).toEqual({ ok: 1 });
});
it('update-settings + get-settings map errors, else return their payloads', () => {
@@ -318,9 +320,9 @@ describe('AuthController (authenticated)', () => {
it('mfa/enable + mfa/disable map errors', () => {
expect(thrown(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
expect(thrown(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
const ok = ac(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false });
expect(ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
});
+8 -1
View File
@@ -59,8 +59,10 @@ import { DaysService, DayReorderError, addDays } from '../../../src/nest/days/da
import { getDay as bridgeGetDay, listDays as bridgeListDays } from '../../../src/nest/days/days.bridge';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
const svc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb)));
const accommodations = new AccommodationsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
beforeAll(() => {
createTables(testDb);
@@ -332,6 +334,11 @@ describe('quirk fixes', () => {
expect(day.title).toBe('Arrival');
});
// Asserted against AccommodationsService, which owns createAccommodation since
// the days/accommodations split. It read `svc.createAccommodation` until then:
// that is `undefined`, calling it throws, and `.toThrow()` was satisfied by the
// TypeError rather than by the rollback. The invariant went unchecked for the
// whole time the case reported green.
it('DAY-SVC-034 — createAccommodation is atomic: a failed reservation insert leaves no orphan stay', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -339,7 +346,7 @@ describe('quirk fixes', () => {
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
testDb.exec("CREATE TRIGGER boom BEFORE INSERT ON reservations BEGIN SELECT RAISE(ABORT, 'boom'); END");
try {
expect(() => svc.createAccommodation(trip.id, {
expect(() => accommodations.createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
})).toThrow();
expect(testDb.prepare('SELECT COUNT(*) as n FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toMatchObject({ n: 0 });
+15 -1
View File
@@ -102,7 +102,21 @@ const registry = createTestPluginRegistry([
const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
type Res = RpcResponse & Partial<RpcError>;
/**
* A response read loosely, on purpose.
*
* `RpcResponse & Partial<RpcError>` collapsed to `never`: both carry `ok`, one
* as the literal `true` and the other as `false`, so the intersection has no
* inhabitant and every `.ok` / `.result` / `.error` below was an error the
* build never saw, because tests/ sits outside tsconfig's include. The union
* would be the honest runtime type, but `dispatch` here is asserted on from
* both sides in the same expression, so this widens instead of narrowing.
*/
type Res = Pick<RpcResponse, 'k' | 'id'> & {
ok: boolean;
result?: unknown;
error?: RpcError['error'];
};
// The acting user is a REST arg, not a defaulted one: an explicit `undefined` has to
// stay undefined (that is the userless-context case), and a default parameter would
// quietly turn it back into user 5.
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src", "tests"]
}