import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Fastify from 'fastify'; import { fastifyAwilixPlugin } from '@fastify/awilix'; import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import { HouseholdRole } from '@meshitrack/shared'; // Mock jose for auth plugin vi.mock('jose', () => ({ createRemoteJWKSet: vi.fn(() => 'mock-jwks'), jwtVerify: vi.fn().mockResolvedValue({ payload: { sub: 'kc-1', email: 'test@example.com', preferred_username: 'testuser', realm_access: { roles: ['member'] }, householdIds: ['hh1'], }, protectedHeader: { alg: 'RS256' }, key: {}, }), })); // Hoist mock fns const { mockCreate, mockFindById, mockUpdate, mockUpdateInviteCode, mockFindByInviteCode, mockAddMember, mockFindByKeycloakId, mockUserUpdate, } = vi.hoisted(() => ({ mockCreate: vi.fn(), mockFindById: vi.fn(), mockUpdate: vi.fn(), mockUpdateInviteCode: vi.fn(), mockFindByInviteCode: vi.fn(), mockAddMember: vi.fn(), mockFindByKeycloakId: vi.fn(), mockUserUpdate: vi.fn(), })); vi.mock('../../../src/modules/households/households.repository.js', () => ({ HouseholdsRepository: class { create = mockCreate; findById = mockFindById; update = mockUpdate; updateInviteCode = mockUpdateInviteCode; findByInviteCode = mockFindByInviteCode; addMember = mockAddMember; }, })); vi.mock('../../../src/modules/users/users.repository.js', () => ({ UsersRepository: class { findByKeycloakId = mockFindByKeycloakId; update = mockUserUpdate; upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); }, })); vi.mock('uuid', () => ({ v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'), })); vi.mock('mongoose', () => ({ default: { startSession: vi.fn().mockResolvedValue({ startTransaction: vi.fn(), commitTransaction: vi.fn().mockResolvedValue(undefined), abortTransaction: vi.fn().mockResolvedValue(undefined), endSession: vi.fn(), }), }, })); import authPlugin from '../../../src/plugins/auth.plugin.js'; import householdPlugin from '../../../src/plugins/household.plugin.js'; import usersRoutes from '../../../src/modules/users/users.routes.js'; import householdsRoutes from '../../../src/modules/households/households.routes.js'; function makeFakeHousehold(overrides = {}) { return { _id: 'hh1', name: 'Test Household', ownerUserId: 'kc-1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() }], inviteCode: '12345678', settings: { timezone: 'UTC', currency: 'USD', language: 'en' }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('households.routes', () => { let app: Awaited>; async function buildTestApp() { const instance = Fastify({ logger: false }); instance.setValidatorCompiler(validatorCompiler); instance.setSerializerCompiler(serializerCompiler); await instance.register(fastifyAwilixPlugin, { disposeOnClose: true, disposeOnResponse: true, strictBooleanEnforced: true, }); await instance.register(authPlugin); await instance.register(householdPlugin); await instance.register(usersRoutes); await instance.register(householdsRoutes); await instance.ready(); return instance; } const authHeaders = { authorization: 'Bearer valid-token' }; beforeEach(async () => { vi.clearAllMocks(); // Default: auth plugin finds user with hh1 membership (routes with householdId guard pass) mockFindByKeycloakId.mockResolvedValue({ householdIds: ['hh1'], defaultHouseholdId: null }); app = await buildTestApp(); }); afterEach(async () => { if (app) await app.close(); }); describe('POST /api/v1/households', () => { it('creates a household', async () => { const household = makeFakeHousehold(); mockCreate.mockResolvedValue(household); mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null }); mockUserUpdate.mockResolvedValue({}); const res = await app.inject({ method: 'POST', url: '/api/v1/households', headers: authHeaders, payload: { name: 'Test Household' }, }); expect(res.statusCode).toBe(201); expect(res.json().name).toBe('Test Household'); }); }); describe('POST /api/v1/households (ObjectId/Date conversion)', () => { it('handles ObjectId and Date objects in response', async () => { const household = makeFakeHousehold({ _id: { toString: () => 'hh-obj' }, createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' }, updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' }, members: [ { userId: 'kc-1', role: HouseholdRole.OWNER, joinedAt: { toISOString: () => '2024-01-01T00:00:00.000Z' }, }, ], settings: null, }); mockCreate.mockResolvedValue(household); mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null }); mockUserUpdate.mockResolvedValue({}); const res = await app.inject({ method: 'POST', url: '/api/v1/households', headers: authHeaders, payload: { name: 'Test' }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body._id).toBe('hh-obj'); expect(body.createdAt).toBe('2024-01-01T00:00:00.000Z'); expect(body.members[0].joinedAt).toBe('2024-01-01T00:00:00.000Z'); expect(body.settings.timezone).toBe('UTC'); expect(body.settings.currency).toBe('USD'); expect(body.settings.language).toBe('en'); }); }); describe('GET /api/v1/households/:id', () => { it('returns a household', async () => { const household = makeFakeHousehold(); mockFindById.mockResolvedValue(household); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Test Household'); }); }); describe('PATCH /api/v1/households/:id', () => { it('updates a household', async () => { const household = makeFakeHousehold(); mockFindById.mockResolvedValue(household); mockUpdate.mockResolvedValue({ ...household, name: 'Updated' }); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1', headers: authHeaders, payload: { name: 'Updated' }, }); expect(res.statusCode).toBe(200); expect(res.json().name).toBe('Updated'); }); }); describe('POST /api/v1/households/:id/invite', () => { it('generates a new invite code', async () => { const household = makeFakeHousehold(); mockFindById.mockResolvedValue(household); mockUpdateInviteCode.mockResolvedValue({ ...household, inviteCode: '12345678' }); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/invite', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().inviteCode).toBeDefined(); }); }); describe('POST /api/v1/households/join', () => { it('joins a household via invite code', async () => { const household = makeFakeHousehold({ members: [ { userId: 'kc-other', role: HouseholdRole.OWNER, joinedAt: new Date().toISOString() }, ], }); mockFindByInviteCode.mockResolvedValue(household); mockAddMember.mockResolvedValue({ ...household, members: [ ...household.members, { userId: 'kc-1', role: HouseholdRole.MEMBER, joinedAt: new Date().toISOString() }, ], }); mockFindByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null }); mockUserUpdate.mockResolvedValue({}); const res = await app.inject({ method: 'POST', url: '/api/v1/households/join', headers: authHeaders, payload: { inviteCode: '12345678' }, }); expect(res.statusCode).toBe(200); }); }); });