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'; 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: {}, }), })); const { mockFindByHousehold, mockFindById, mockCreate, mockUpdate, mockDelete } = vi.hoisted( () => ({ mockFindByHousehold: vi.fn(), mockFindById: vi.fn(), mockCreate: vi.fn(), mockUpdate: vi.fn(), mockDelete: vi.fn(), }), ); vi.mock('../../../src/modules/freshness-rules/freshness-rules.repository.js', () => ({ FreshnessRulesRepository: class { findByHousehold = mockFindByHousehold; findById = mockFindById; findApplicableRule = vi.fn(); create = mockCreate; update = mockUpdate; delete = mockDelete; }, })); vi.mock('../../../src/modules/users/users.repository.js', () => ({ UsersRepository: class { findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); }, })); 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 freshnessRulesRoutes from '../../../src/modules/freshness-rules/freshness-rules.routes.js'; function makeRule(overrides: Record = {}) { return { _id: 'rule-1', householdId: 'hh1', category: 'dairy', storageLocation: 'fridge', shelfLifeDays: 14, openedLifeDays: 7, spoilageSignsToCheck: ['smell'], source: 'household', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, }; } describe('freshness-rules.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(freshnessRulesRoutes); await instance.ready(); return instance; } const authHeaders = { authorization: 'Bearer valid-token' }; beforeEach(async () => { vi.clearAllMocks(); app = await buildTestApp(); }); afterEach(async () => { if (app) await app.close(); }); describe('GET /freshness-rules', () => { it('returns paginated list', async () => { mockFindByHousehold.mockResolvedValue({ data: [makeRule()], pagination: { cursor: null, hasMore: false }, }); const res = await app.inject({ method: 'GET', url: '/api/v1/households/hh1/freshness-rules', headers: authHeaders, }); expect(res.statusCode).toBe(200); expect(res.json().data).toHaveLength(1); }); }); describe('POST /freshness-rules', () => { it('creates a rule', async () => { mockCreate.mockResolvedValue(makeRule()); const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/freshness-rules', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ category: 'dairy', storageLocation: 'fridge', shelfLifeDays: 14, openedLifeDays: 7, }), }); expect(res.statusCode).toBe(201); }); it('rejects invalid category', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/households/hh1/freshness-rules', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ category: 'invalid', storageLocation: 'fridge', shelfLifeDays: 14, openedLifeDays: 7, }), }); expect(res.statusCode).toBe(400); }); }); describe('PATCH /freshness-rules/:id', () => { it('updates a rule', async () => { mockFindById.mockResolvedValue(makeRule()); mockUpdate.mockResolvedValue(makeRule({ shelfLifeDays: 10 })); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/freshness-rules/rule-1', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ shelfLifeDays: 10 }), }); expect(res.statusCode).toBe(200); }); }); describe('PATCH /freshness-rules/:id with optional fields', () => { it('returns rule with all optional fields', async () => { mockFindById.mockResolvedValue(makeRule()); mockUpdate.mockResolvedValue(makeRule({ freezerLifeDays: 90, tips: 'Keep sealed' })); const res = await app.inject({ method: 'PATCH', url: '/api/v1/households/hh1/freshness-rules/rule-1', headers: { ...authHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ freezerLifeDays: 90, tips: 'Keep sealed' }), }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.freezerLifeDays).toBe(90); expect(body.tips).toBe('Keep sealed'); }); }); describe('DELETE /freshness-rules/:id', () => { it('deletes a rule', async () => { mockFindById.mockResolvedValue(makeRule()); mockDelete.mockResolvedValue(makeRule()); const res = await app.inject({ method: 'DELETE', url: '/api/v1/households/hh1/freshness-rules/rule-1', headers: authHeaders, }); expect(res.statusCode).toBe(204); }); }); });