502 lines
18 KiB
TypeScript
502 lines
18 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("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("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("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("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("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();
|
|
});
|
|
});
|