785 lines
28 KiB
TypeScript
785 lines
28 KiB
TypeScript
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("calculates household debt payoff teamwork plans", async () => {
|
|
const { prisma, service } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
|
|
|
const result = await service.calculateDebtPayoff("user_1", "household_1", {
|
|
strategy: "avalanche",
|
|
monthlyExtraPayment: 300,
|
|
splitMode: "income_weighted",
|
|
mineMonthlyIncome: 7000,
|
|
yoursMonthlyIncome: 3000,
|
|
debts: [
|
|
{ name: "Card A", balance: 3000, annualPercentageRate: 24, minimumPayment: 90 },
|
|
{ name: "Loan B", balance: 10000, annualPercentageRate: 8, minimumPayment: 250 },
|
|
],
|
|
});
|
|
|
|
expect(result.strategy).toBe("avalanche");
|
|
expect(result.orderedDebts[0]).toEqual(expect.objectContaining({ name: "Card A", payoffOrder: 1 }));
|
|
expect(result.contributionSplit).toEqual(expect.objectContaining({
|
|
minePercent: 70,
|
|
yoursPercent: 30,
|
|
mineAmount: 448,
|
|
yoursAmount: 192,
|
|
}));
|
|
expect(result.monthlyPayment).toBe(640);
|
|
expect(result.payoffMonths).toBeGreaterThan(0);
|
|
expect(result.totalInterestPaid).toBeGreaterThan(0);
|
|
expect(result.timeline[0]).toEqual(expect.objectContaining({
|
|
month: 1,
|
|
targetDebt: "Card A",
|
|
totalRemainingBalance: expect.any(Number),
|
|
}));
|
|
});
|
|
|
|
it("rejects debt payoff plans with no debts", async () => {
|
|
const { prisma, service } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
|
|
|
await expect(service.calculateDebtPayoff("user_1", "household_1", {
|
|
debts: [],
|
|
})).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("calculates future planning scenarios and comparisons", async () => {
|
|
const { prisma, service } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
|
|
|
const result = await service.calculateFutureScenarios("user_1", "household_1", {
|
|
scenarios: [
|
|
{
|
|
name: "Emergency fund",
|
|
type: "goal",
|
|
startingBalance: 1000,
|
|
monthlyContribution: 500,
|
|
targetAmount: 5000,
|
|
horizonMonths: 12,
|
|
annualGrowthRate: 0,
|
|
},
|
|
{
|
|
name: "Move plan",
|
|
type: "expense_change",
|
|
startingBalance: 1000,
|
|
monthlyContribution: 300,
|
|
monthlyIncome: 6000,
|
|
monthlyExpenses: 5200,
|
|
targetAmount: 10000,
|
|
horizonMonths: 12,
|
|
annualGrowthRate: 3,
|
|
events: [{ month: 6, label: "Moving deposit", amount: -1500 }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.scenarios).toHaveLength(2);
|
|
expect(result.scenarios[0]).toEqual(expect.objectContaining({
|
|
name: "Emergency fund",
|
|
finalBalance: 7000,
|
|
targetReachedMonth: 8,
|
|
monthlyNetChange: 500,
|
|
}));
|
|
expect(result.scenarios[1].eventTotal).toBe(-1500);
|
|
expect(result.comparison.bestFinalBalance.name).toBe("Move plan");
|
|
expect(result.comparison.earliestTarget.name).toBe("Emergency fund");
|
|
});
|
|
|
|
it("rejects future planning scenarios outside the allowed horizon", async () => {
|
|
const { prisma, service } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
|
|
|
await expect(service.calculateFutureScenarios("user_1", "household_1", {
|
|
scenarios: [{
|
|
name: "Too long",
|
|
startingBalance: 0,
|
|
monthlyContribution: 100,
|
|
horizonMonths: 601,
|
|
}],
|
|
})).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();
|
|
});
|
|
});
|