import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HouseholdsService } from './households.service.js'; import { NotFoundError, ForbiddenError, ConflictError } from '../../common/errors.js'; import { HouseholdRole } from '@meshitrack/shared'; // Mock uuid to return deterministic values vi.mock('uuid', () => ({ v4: vi.fn(() => '12345678-1234-1234-1234-123456789abc'), })); const { mockSession } = vi.hoisted(() => ({ mockSession: { startTransaction: vi.fn(), commitTransaction: vi.fn().mockResolvedValue(undefined), abortTransaction: vi.fn().mockResolvedValue(undefined), endSession: vi.fn(), }, })); vi.mock('mongoose', () => ({ default: { startSession: vi.fn().mockResolvedValue(mockSession) }, })); describe('HouseholdsService', () => { const mockHouseholdsRepo = { findById: vi.fn(), findByInviteCode: vi.fn(), create: vi.fn(), update: vi.fn(), addMember: vi.fn(), updateInviteCode: vi.fn(), }; const mockUsersRepo = { findByKeycloakId: vi.fn(), findById: vi.fn(), create: vi.fn(), update: vi.fn(), upsertFromToken: vi.fn(), }; let service: HouseholdsService; beforeEach(() => { vi.clearAllMocks(); service = new HouseholdsService({ householdsRepository: mockHouseholdsRepo as never, usersRepository: mockUsersRepo as never, }); }); describe('create', () => { it('creates a household and updates owner user', async () => { const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1', inviteCode: '12345678' }; mockHouseholdsRepo.create.mockResolvedValue(household); mockUsersRepo.findByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null, }); mockUsersRepo.update.mockResolvedValue({}); const result = await service.create({ name: 'Test' }, 'kc-1'); expect(result).toEqual(household); expect(mockHouseholdsRepo.create).toHaveBeenCalledWith( { name: 'Test' }, 'kc-1', '12345678', mockSession, ); expect(mockUsersRepo.update).toHaveBeenCalledWith( 'kc-1', { householdIds: ['hh1'], defaultHouseholdId: 'hh1' }, mockSession, ); }); it('preserves existing defaultHouseholdId when user already has one', async () => { const household = { _id: 'hh2', name: 'Second', ownerUserId: 'kc-1' }; mockHouseholdsRepo.create.mockResolvedValue(household); mockUsersRepo.findByKeycloakId.mockResolvedValue({ householdIds: ['hh1'], defaultHouseholdId: 'hh1', }); mockUsersRepo.update.mockResolvedValue({}); await service.create({ name: 'Second' }, 'kc-1'); expect(mockUsersRepo.update).toHaveBeenCalledWith( 'kc-1', { householdIds: ['hh1', 'hh2'], defaultHouseholdId: 'hh1' }, mockSession, ); }); it('aborts transaction on error', async () => { mockHouseholdsRepo.create.mockRejectedValue(new Error('DB error')); await expect(service.create({ name: 'Test' }, 'kc-1')).rejects.toThrow('DB error'); expect(mockSession.abortTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); }); it('handles case when owner user not found in db', async () => { const household = { _id: 'hh1', name: 'Test', ownerUserId: 'kc-1' }; mockHouseholdsRepo.create.mockResolvedValue(household); mockUsersRepo.findByKeycloakId.mockResolvedValue(null); const result = await service.create({ name: 'Test' }, 'kc-1'); expect(result).toEqual(household); expect(mockUsersRepo.update).not.toHaveBeenCalled(); }); }); describe('getById', () => { it('returns household when found', async () => { const household = { _id: 'hh1', name: 'Test' }; mockHouseholdsRepo.findById.mockResolvedValue(household); const result = await service.getById('hh1'); expect(result).toEqual(household); }); it('throws NotFoundError when not found', async () => { mockHouseholdsRepo.findById.mockResolvedValue(null); await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); }); describe('update', () => { it('allows owner to update', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' }); const result = await service.update('hh1', { name: 'Updated' }, 'kc-1'); expect(result).toEqual({ _id: 'hh1', name: 'Updated' }); }); it('allows admin to update', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-2', role: HouseholdRole.ADMIN }], }); mockHouseholdsRepo.update.mockResolvedValue({ _id: 'hh1', name: 'Updated' }); await service.update('hh1', { name: 'Updated' }, 'kc-2'); expect(mockHouseholdsRepo.update).toHaveBeenCalled(); }); it('throws ForbiddenError for regular member', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }], }); await expect(service.update('hh1', { name: 'X' }, 'kc-3')).rejects.toThrow(ForbiddenError); }); it('throws NotFoundError when repo update returns null', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); mockHouseholdsRepo.update.mockResolvedValue(null); await expect(service.update('hh1', { name: 'X' }, 'kc-1')).rejects.toThrow(NotFoundError); }); it('throws ForbiddenError for non-member', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); await expect(service.update('hh1', { name: 'X' }, 'kc-other')).rejects.toThrow( ForbiddenError, ); }); }); describe('generateInviteCode', () => { it('generates new invite code for owner', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); mockHouseholdsRepo.updateInviteCode.mockResolvedValue({ inviteCode: '12345678' }); const result = await service.generateInviteCode('hh1', 'kc-1'); expect(mockHouseholdsRepo.updateInviteCode).toHaveBeenCalledWith('hh1', '12345678'); expect(result).toEqual({ inviteCode: '12345678' }); }); it('throws ForbiddenError for regular member', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-3', role: HouseholdRole.MEMBER }], }); await expect(service.generateInviteCode('hh1', 'kc-3')).rejects.toThrow(ForbiddenError); }); it('throws NotFoundError when updateInviteCode returns null', async () => { mockHouseholdsRepo.findById.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); mockHouseholdsRepo.updateInviteCode.mockResolvedValue(null); await expect(service.generateInviteCode('hh1', 'kc-1')).rejects.toThrow(NotFoundError); }); }); describe('join', () => { it('joins a household via invite code', async () => { const household = { _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }; mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household); mockHouseholdsRepo.addMember.mockResolvedValue({ ...household, members: [...household.members, { userId: 'kc-2' }], }); mockUsersRepo.findByKeycloakId.mockResolvedValue({ householdIds: [], defaultHouseholdId: null, }); mockUsersRepo.update.mockResolvedValue({}); const result = await service.join('ABCD1234', 'kc-2'); expect(mockHouseholdsRepo.addMember).toHaveBeenCalledWith( 'hh1', 'kc-2', HouseholdRole.MEMBER, mockSession, ); expect(mockUsersRepo.update).toHaveBeenCalledWith( 'kc-2', { householdIds: ['hh1'], defaultHouseholdId: 'hh1' }, mockSession, ); expect(result).toBeDefined(); }); it('throws NotFoundError for invalid invite code', async () => { mockHouseholdsRepo.findByInviteCode.mockResolvedValue(null); await expect(service.join('INVALID', 'kc-2')).rejects.toThrow(NotFoundError); }); it('throws ConflictError when already a member', async () => { mockHouseholdsRepo.findByInviteCode.mockResolvedValue({ _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }); await expect(service.join('CODE', 'kc-1')).rejects.toThrow(ConflictError); }); it('throws NotFoundError when addMember returns null', async () => { const household = { _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }; mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household); mockHouseholdsRepo.addMember.mockResolvedValue(null); await expect(service.join('CODE', 'kc-2')).rejects.toThrow(NotFoundError); }); it('aborts transaction on error during join', async () => { const household = { _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }; mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household); mockHouseholdsRepo.addMember.mockRejectedValue(new Error('DB error')); await expect(service.join('CODE', 'kc-2')).rejects.toThrow('DB error'); expect(mockSession.abortTransaction).toHaveBeenCalled(); expect(mockSession.endSession).toHaveBeenCalled(); }); it('handles case when joining user not found in db', async () => { const household = { _id: 'hh1', members: [{ userId: 'kc-1', role: HouseholdRole.OWNER }], }; mockHouseholdsRepo.findByInviteCode.mockResolvedValue(household); mockHouseholdsRepo.addMember.mockResolvedValue({}); mockUsersRepo.findByKeycloakId.mockResolvedValue(null); await service.join('CODE', 'kc-new'); expect(mockUsersRepo.update).not.toHaveBeenCalled(); }); }); });