import { BadRequestException, ForbiddenException } from "@nestjs/common"; import { HouseholdsService } from "../src/households/households.service"; import { createPrismaMock } from "./utils/mock-prisma"; describe("HouseholdsService", () => { const createService = () => { const prisma = createPrismaMock(); const email = { sendHouseholdInviteEmail: jest.fn().mockResolvedValue(undefined) }; const service = new HouseholdsService(prisma as any, email as any); return { prisma, email, service }; }; it("creates a household with the current user as owner", async () => { const { prisma, service } = createService(); const household = { id: "household_1", name: "Mohan Household", createdByUserId: "user_1", metadata: { type: "couple" }, members: [{ id: "member_1", userId: "user_1", role: "owner", status: "active" }], }; prisma.household.create.mockResolvedValue(household); prisma.auditLog.create.mockResolvedValue({}); const result = await service.create("user_1", { name: " Mohan Household ", metadata: { type: "couple" }, }); expect(result).toBe(household); expect(prisma.household.create).toHaveBeenCalledWith({ data: { name: "Mohan Household", createdByUserId: "user_1", metadata: { type: "couple" }, members: { create: { userId: "user_1", role: "owner", status: "active", }, }, }, include: expect.any(Object), }); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: "user_1", action: "household.create", metadata: expect.objectContaining({ householdId: "household_1", role: "owner" }), }), }); }); it("lists only active household memberships for the current user", async () => { const { prisma, service } = createService(); prisma.household.findMany.mockResolvedValue([{ id: "household_1" }]); await service.listForUser("user_1"); expect(prisma.household.findMany).toHaveBeenCalledWith({ where: { members: { some: { userId: "user_1", status: "active" }, }, }, include: expect.any(Object), orderBy: { updatedAt: "desc" }, }); }); it("calculates income-weighted fair splits for household members", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); const result = await service.calculateFairSplit("user_1", "household_1", { expenseAmount: 1000, mineMonthlyIncome: 7000, yoursMonthlyIncome: 3000, splitMode: "income_weighted", }); expect(result.split).toEqual({ minePercent: 70, yoursPercent: 30, mineAmount: 700, yoursAmount: 300, }); expect(result.transactionDefaults).toEqual({ attribution: "ours", splitMode: "custom", splitMinePercent: 70, splitYoursPercent: 30, }); }); it("rejects invalid custom fair split percentages", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); await expect(service.calculateFairSplit("user_1", "household_1", { expenseAmount: 100, splitMode: "custom", customMinePercent: 140, })).rejects.toBeInstanceOf(BadRequestException); }); it("blocks reading households when the user is not an active member", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue(null); await expect(service.getForUser("user_1", "household_1")).rejects.toBeInstanceOf(BadRequestException); expect(prisma.household.findFirst).not.toHaveBeenCalled(); }); it("updates household privacy mode for managers", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); prisma.household.findFirst.mockResolvedValue({ id: "household_1", metadata: { type: "couple" } }); prisma.household.update.mockResolvedValue({ id: "household_1" }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.updatePrivacyMode("user_1", "household_1", { enabled: true, hideIndividualBalances: true, hideIndividualTransactions: true, }); expect(result.privacyMode.enabled).toBe(true); expect(prisma.household.update).toHaveBeenCalledWith({ where: { id: "household_1" }, data: { metadata: { type: "couple", privacyMode: { enabled: true, hideIndividualBalances: true, hideIndividualTransactions: true, }, }, }, }); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "household.privacy.update", metadata: expect.objectContaining({ householdId: "household_1" }), }), }); }); it("builds a shared household financial dashboard for active members", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" }); prisma.household.findFirst.mockResolvedValue({ id: "household_1", name: "Mohan Household", createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), members: [ { id: "member_1", userId: "user_1", role: "owner", joinedAt: new Date("2026-07-01T00:00:00.000Z"), user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }, { id: "member_2", userId: "user_2", role: "member", joinedAt: new Date("2026-07-02T00:00:00.000Z"), user: { id: "user_2", email: "partner@example.com", fullName: "Partner User" }, }, ], }); prisma.account.findMany.mockResolvedValue([ { institutionName: "Bank A", accountType: "checking", mask: "1111", currentBalance: "1000.25", availableBalance: "900.25", isoCurrencyCode: "USD", ownerUserId: null, ownershipType: "joint", lastBalanceSync: new Date("2026-07-15T00:00:00.000Z"), syncStatus: "idle", createdAt: new Date("2026-07-01T00:00:00.000Z"), }, { institutionName: "Bank B", accountType: "credit", mask: "2222", currentBalance: "-100.25", availableBalance: null, isoCurrencyCode: "USD", ownerUserId: "user_2", ownershipType: "theirs", lastBalanceSync: null, syncStatus: "idle", createdAt: new Date("2026-07-02T00:00:00.000Z"), }, ]); prisma.transactionRaw.findMany .mockResolvedValueOnce([ { date: new Date("2026-07-15T00:00:00.000Z"), amount: "42.5", description: "Groceries", source: "plaid", derived: { userCategory: "Food", isHidden: false }, account: { institutionName: "Bank A", mask: "1111", ownerUserId: null, ownershipType: "joint" }, }, ]) .mockResolvedValueOnce([ { date: new Date(), amount: "-2500", description: "Payroll", source: "plaid", derived: { userCategory: "Income", isHidden: false }, account: { ownershipType: "joint" }, }, { date: new Date(), amount: "42.5", description: "Groceries", source: "plaid", derived: { userCategory: "Food", isHidden: false }, account: { ownershipType: "joint" }, }, ]); prisma.householdGoal.findMany.mockResolvedValue([ { targetAmount: "10000", currentAmount: "5000", status: "active", }, ]); const result = await service.getDashboard("user_1", "household_1"); expect(result.summary.memberCount).toBe(2); expect(result.summary.accountCount).toBe(2); expect(result.summary.totalBalance).toBe(900); expect(result.ownershipBreakdown.joint).toEqual({ accountCount: 1, balance: 1000.25 }); expect(result.ownershipBreakdown.theirs).toEqual({ accountCount: 1, balance: -100.25 }); expect(result.accounts[0]).not.toHaveProperty("id"); expect(result.recentTransactions[0].category).toBe("Food"); expect(result.summary.monthlyIncome).toBe(2500); expect(result.summary.monthlyExpenses).toBe(42.5); expect(result.healthScore).toEqual(expect.objectContaining({ score: expect.any(Number), rating: expect.any(String), components: expect.objectContaining({ cashflow: expect.any(Number), balanceBuffer: expect.any(Number), goalProgress: expect.any(Number), collaboration: expect.any(Number), syncHealth: expect.any(Number), }), metrics: expect.objectContaining({ activeGoalCount: 1, jointAccountCount: 1, collaborativeTransactionCount: 2, }), })); expect(result.healthScore.score).toBeGreaterThan(70); expect(prisma.transactionRaw.findMany).toHaveBeenCalledTimes(2); expect(prisma.householdGoal.findMany).toHaveBeenCalledWith({ where: { householdId: "household_1", status: { in: ["active", "completed"] } }, select: { targetAmount: true, currentAmount: true, status: true, }, }); }); it("generates money date prompts from household cashflow and goals", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "member", status: "active" }); prisma.household.findFirst.mockResolvedValue({ id: "household_1", metadata: {}, members: [ { id: "member_1", userId: "user_1", role: "owner", status: "active", joinedAt: new Date(), user: { id: "user_1", email: "owner@example.com" } }, { id: "member_2", userId: "user_2", role: "member", status: "active", joinedAt: new Date(), user: { id: "user_2", email: "partner@example.com" } }, ], }); prisma.account.findMany.mockResolvedValue([ { currentBalance: "500", availableBalance: "400", ownershipType: "joint", syncStatus: "idle" }, ]); prisma.transactionRaw.findMany.mockResolvedValue([ { date: new Date(), amount: "850", derived: { userCategory: "Dining", isHidden: false }, account: { ownershipType: "joint" }, }, { date: new Date(), amount: "-500", derived: { userCategory: "Income", isHidden: false }, account: { ownershipType: "joint" }, }, ]); prisma.householdGoal.findMany.mockResolvedValue([ { name: "Emergency Fund", targetAmount: "10000", currentAmount: "1000", priority: "high", status: "active", targetDate: new Date("2026-12-31T00:00:00.000Z"), }, ]); const result = await service.listMoneyDatePrompts("user_1", "household_1"); expect(result.cadenceSuggestion).toBe("weekly"); expect(result.prompts).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "cashflow-reset", priority: "high" }), expect.objectContaining({ id: "goal-progress", topic: "Goals" }), expect.objectContaining({ id: "spending-pattern", topic: "Spending pattern" }), ])); expect(result.prompts[0]).not.toHaveProperty("transactionId"); expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ account: { householdId: "household_1", isActive: true }, }), })); }); it("allows owners to update household member roles", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst .mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }) .mockResolvedValueOnce({ id: "member_2", userId: "user_2", role: "member", status: "active" }); prisma.householdMember.update.mockResolvedValue({ id: "member_2", userId: "user_2", role: "admin", status: "active", }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.updateMember("user_1", "household_1", "member_2", { role: "admin" }); expect(result.role).toBe("admin"); expect(prisma.householdMember.update).toHaveBeenCalledWith({ where: { id: "member_2" }, data: { role: "admin" }, include: expect.any(Object), }); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "household.member.update", metadata: expect.objectContaining({ householdId: "household_1", memberId: "member_2", role: "admin", }), }), }); }); it("allows managers to assign accountant and advisor collaborator roles", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst .mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }) .mockResolvedValueOnce({ id: "member_2", userId: "user_2", role: "viewer", status: "active" }); prisma.householdMember.update.mockResolvedValue({ id: "member_2", userId: "user_2", role: "advisor", status: "active", }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.updateMember("user_1", "household_1", "member_2", { role: "advisor" }); expect(result.role).toBe("advisor"); expect(prisma.householdMember.update).toHaveBeenCalledWith({ where: { id: "member_2" }, data: { role: "advisor" }, include: expect.any(Object), }); }); it("lists shared household goals for active members", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "member", status: "active" }); prisma.householdGoal.findMany.mockResolvedValue([ { id: "goal_1", householdId: "household_1", name: "Emergency Fund", description: "Six months of expenses", targetAmount: "10000", currentAmount: "2500", isoCurrencyCode: "USD", targetDate: new Date("2026-12-31T00:00:00.000Z"), priority: "high", status: "active", metadata: {}, createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }, ]); const result = await service.listGoals("user_1", "household_1"); expect(result[0]).toEqual(expect.objectContaining({ id: "goal_1", targetAmount: 10000, currentAmount: 2500, remainingAmount: 7500, progressPercent: 25, })); expect(prisma.householdGoal.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { householdId: "household_1" }, })); }); it("creates shared goals for household managers", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); prisma.householdGoal.create.mockResolvedValue({ id: "goal_1", householdId: "household_1", name: "Vacation", description: "Summer trip", targetAmount: "5000", currentAmount: "1000", isoCurrencyCode: "USD", targetDate: new Date("2026-10-01T00:00:00.000Z"), priority: "medium", status: "active", metadata: { color: "teal" }, createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.createGoal("user_1", "household_1", { name: " Vacation ", description: "Summer trip", targetAmount: 5000, currentAmount: 1000, targetDate: "2026-10-01", metadata: { color: "teal" }, }); expect(result.progressPercent).toBe(20); expect(prisma.householdGoal.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ householdId: "household_1", createdByUserId: "user_1", name: "Vacation", targetAmount: 5000, currentAmount: 1000, status: "active", }), })); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "household.goal.create", metadata: expect.objectContaining({ householdId: "household_1", goalId: "goal_1" }), }), }); }); it("updates shared goals for household managers", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "admin", status: "active" }); prisma.householdGoal.findFirst.mockResolvedValue({ id: "goal_1", householdId: "household_1", targetAmount: "5000", currentAmount: "1000", }); prisma.householdGoal.update.mockResolvedValue({ id: "goal_1", householdId: "household_1", name: "Vacation", description: null, targetAmount: "5000", currentAmount: "2500", isoCurrencyCode: "USD", targetDate: null, priority: "high", status: "active", metadata: {}, createdAt: new Date("2026-07-01T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), createdBy: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.updateGoal("user_1", "household_1", "goal_1", { currentAmount: 2500, priority: "high", }); expect(result.progressPercent).toBe(50); expect(prisma.householdGoal.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "goal_1" }, data: expect.objectContaining({ currentAmount: 2500, priority: "high" }), })); }); it("rejects household goals with current amount above target", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); await expect(service.createGoal("user_1", "household_1", { name: "Bad goal", targetAmount: 100, currentAmount: 150, })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.householdGoal.create).not.toHaveBeenCalled(); }); it("blocks non-managers from updating member roles", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "member", status: "active", }); await expect(service.updateMember("user_1", "household_1", "member_2", { role: "admin" })).rejects.toBeInstanceOf(ForbiddenException); expect(prisma.householdMember.update).not.toHaveBeenCalled(); }); it("keeps at least one active household owner", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst .mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }) .mockResolvedValueOnce({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); prisma.householdMember.count.mockResolvedValue(1); await expect(service.updateMember("user_1", "household_1", "owner_member", { role: "member" })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.householdMember.update).not.toHaveBeenCalled(); }); it("creates a partner invite for household managers and sends email", async () => { const { prisma, email, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); prisma.household.findFirst.mockResolvedValue({ id: "household_1", name: "Mohan Household" }); prisma.user.findUnique.mockResolvedValue({ email: "owner@example.com", fullName: "Owner User" }); prisma.householdInvite.create.mockResolvedValue({ id: "invite_1", email: "partner@example.com", role: "admin", status: "pending", expiresAt: new Date("2026-07-23T00:00:00.000Z"), createdAt: new Date("2026-07-16T00:00:00.000Z"), }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.invite("user_1", "household_1", { email: " Partner@Example.com ", role: "admin", }); expect(result.id).toBe("invite_1"); expect(prisma.householdInvite.create).toHaveBeenCalledWith({ data: expect.objectContaining({ householdId: "household_1", invitedById: "user_1", email: "partner@example.com", role: "admin", status: "pending", tokenHash: expect.stringMatching(/^[a-f0-9]{64}$/), }), select: expect.any(Object), }); expect(email.sendHouseholdInviteEmail).toHaveBeenCalledWith( "partner@example.com", "Mohan Household", "Owner User", expect.any(String), ); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "household.invite.create", }), }); }); it("creates accountant collaborator invites for household managers", async () => { const { prisma, email, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); prisma.household.findFirst.mockResolvedValue({ id: "household_1", name: "Mohan Household" }); prisma.user.findUnique.mockResolvedValue({ email: "owner@example.com", fullName: "Owner User" }); prisma.householdInvite.create.mockResolvedValue({ id: "invite_2", email: "cpa@example.com", role: "accountant", status: "pending", expiresAt: new Date("2026-07-23T00:00:00.000Z"), createdAt: new Date("2026-07-16T00:00:00.000Z"), }); prisma.auditLog.create.mockResolvedValue({}); const result = await service.invite("user_1", "household_1", { email: " CPA@Example.com ", role: "accountant", }); expect(result.role).toBe("accountant"); expect(prisma.householdInvite.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ email: "cpa@example.com", role: "accountant", }), })); expect(email.sendHouseholdInviteEmail).toHaveBeenCalledWith( "cpa@example.com", "Mohan Household", "Owner User", expect.any(String), ); }); it("accepts a pending invite for the matching email", async () => { const { prisma, service } = createService(); const expiresAt = new Date(Date.now() + 60_000); prisma.user.findUnique.mockResolvedValue({ email: "partner@example.com" }); prisma.householdInvite.findUnique.mockResolvedValue({ id: "invite_1", householdId: "household_1", email: "partner@example.com", role: "member", status: "pending", expiresAt, household: { id: "household_1", name: "Mohan Household" }, }); prisma.householdMember.upsert.mockResolvedValue({ id: "member_2", householdId: "household_1", userId: "user_2", role: "member", status: "active", }); prisma.householdInvite.update.mockResolvedValue({}); prisma.auditLog.create.mockResolvedValue({}); const result = await service.acceptInvite("user_2", { token: "x".repeat(40) }); expect(result.member.id).toBe("member_2"); expect(prisma.householdMember.upsert).toHaveBeenCalledWith({ where: { householdId_userId: { householdId: "household_1", userId: "user_2" } }, create: expect.objectContaining({ role: "member", status: "active" }), update: expect.objectContaining({ role: "member", status: "active" }), include: expect.any(Object), }); expect(prisma.householdInvite.update).toHaveBeenCalledWith({ where: { id: "invite_1" }, data: expect.objectContaining({ status: "accepted", acceptedById: "user_2" }), }); }); it("blocks accepting an invite sent to a different email", async () => { const { prisma, service } = createService(); prisma.user.findUnique.mockResolvedValue({ email: "other@example.com" }); prisma.householdInvite.findUnique.mockResolvedValue({ id: "invite_1", householdId: "household_1", email: "partner@example.com", role: "member", status: "pending", expiresAt: new Date(Date.now() + 60_000), household: { id: "household_1" }, }); await expect(service.acceptInvite("user_2", { token: "x".repeat(40) })).rejects.toBeInstanceOf(ForbiddenException); expect(prisma.householdMember.upsert).not.toHaveBeenCalled(); }); });