ledgerone_backend/test/households.service.spec.ts

470 lines
17 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" },
},
]);
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(prisma.transactionRaw.findMany).toHaveBeenCalledTimes(2);
});
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();
});
});