feat: add household shared goals
This commit is contained in:
parent
ade95e6fdf
commit
5794203c80
@ -0,0 +1,25 @@
|
||||
CREATE TABLE "HouseholdGoal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"householdId" TEXT NOT NULL,
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"targetAmount" DECIMAL(18, 2) NOT NULL,
|
||||
"currentAmount" DECIMAL(18, 2) NOT NULL DEFAULT 0,
|
||||
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
|
||||
"targetDate" TIMESTAMP(3),
|
||||
"priority" TEXT NOT NULL DEFAULT 'medium',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "HouseholdGoal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "HouseholdGoal_householdId_status_idx" ON "HouseholdGoal"("householdId", "status");
|
||||
CREATE INDEX "HouseholdGoal_createdByUserId_createdAt_idx" ON "HouseholdGoal"("createdByUserId", "createdAt");
|
||||
CREATE INDEX "HouseholdGoal_targetDate_idx" ON "HouseholdGoal"("targetDate");
|
||||
|
||||
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "HouseholdGoal" ADD CONSTRAINT "HouseholdGoal_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -48,6 +48,7 @@ model User {
|
||||
sentHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteInviter")
|
||||
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
|
||||
ownedAccounts Account[] @relation("AccountOwnerUser")
|
||||
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
|
||||
}
|
||||
|
||||
model Household {
|
||||
@ -62,10 +63,35 @@ model Household {
|
||||
members HouseholdMember[]
|
||||
invites HouseholdInvite[]
|
||||
accounts Account[]
|
||||
goals HouseholdGoal[]
|
||||
|
||||
@@index([createdByUserId, createdAt])
|
||||
}
|
||||
|
||||
model HouseholdGoal {
|
||||
id String @id @default(uuid())
|
||||
householdId String
|
||||
createdByUserId String
|
||||
name String
|
||||
description String?
|
||||
targetAmount Decimal
|
||||
currentAmount Decimal @default(0)
|
||||
isoCurrencyCode String @default("USD")
|
||||
targetDate DateTime?
|
||||
priority String @default("medium")
|
||||
status String @default("active")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation("HouseholdGoalCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([householdId, status])
|
||||
@@index([createdByUserId, createdAt])
|
||||
@@index([targetDate])
|
||||
}
|
||||
|
||||
model HouseholdMember {
|
||||
id String @id @default(uuid())
|
||||
householdId String
|
||||
|
||||
38
src/households/dto/create-household-goal.dto.ts
Normal file
38
src/households/dto/create-household-goal.dto.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
|
||||
|
||||
export class CreateHouseholdGoalDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
targetAmount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
isoCurrencyCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
targetDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["low", "medium", "high"])
|
||||
priority?: "low" | "medium" | "high";
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
44
src/households/dto/update-household-goal.dto.ts
Normal file
44
src/households/dto/update-household-goal.dto.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
|
||||
|
||||
export class UpdateHouseholdGoalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
targetAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
isoCurrencyCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
targetDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["low", "medium", "high"])
|
||||
priority?: "low" | "medium" | "high";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["active", "paused", "completed", "archived"])
|
||||
status?: "active" | "paused" | "completed" | "archived";
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@ -2,8 +2,10 @@ import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
||||
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
|
||||
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
|
||||
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
|
||||
import { HouseholdsService } from "./households.service";
|
||||
|
||||
@ -36,6 +38,30 @@ export class HouseholdsController {
|
||||
return ok(await this.householdsService.listMembers(userId, id));
|
||||
}
|
||||
|
||||
@Get(":id/goals")
|
||||
async goals(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
return ok(await this.householdsService.listGoals(userId, id));
|
||||
}
|
||||
|
||||
@Post(":id/goals")
|
||||
async createGoal(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: CreateHouseholdGoalDto,
|
||||
) {
|
||||
return ok(await this.householdsService.createGoal(userId, id, payload));
|
||||
}
|
||||
|
||||
@Patch(":id/goals/:goalId")
|
||||
async updateGoal(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Param("goalId") goalId: string,
|
||||
@Body() payload: UpdateHouseholdGoalDto,
|
||||
) {
|
||||
return ok(await this.householdsService.updateGoal(userId, id, goalId, payload));
|
||||
}
|
||||
|
||||
@Patch(":id/members/:memberId")
|
||||
async updateMember(
|
||||
@CurrentUser() userId: string,
|
||||
|
||||
@ -4,8 +4,10 @@ import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
||||
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
|
||||
import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
|
||||
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
|
||||
|
||||
@Injectable()
|
||||
@ -247,6 +249,121 @@ export class HouseholdsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listGoals(userId: string, householdId: string) {
|
||||
await this.requireActiveMember(userId, householdId);
|
||||
const goals = await this.prisma.householdGoal.findMany({
|
||||
where: { householdId },
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ status: "asc" }, { priority: "desc" }, { targetDate: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return goals.map((goal: any) => this.serializeGoal(goal));
|
||||
}
|
||||
|
||||
async createGoal(userId: string, householdId: string, payload: CreateHouseholdGoalDto) {
|
||||
await this.requireManager(userId, householdId);
|
||||
const targetAmount = this.positiveMoney(payload.targetAmount, "Target amount");
|
||||
const currentAmount = this.nonNegativeMoney(payload.currentAmount ?? 0, "Current amount");
|
||||
if (currentAmount > targetAmount) {
|
||||
throw new BadRequestException("Current amount cannot exceed target amount.");
|
||||
}
|
||||
|
||||
const goal = await this.prisma.householdGoal.create({
|
||||
data: {
|
||||
householdId,
|
||||
createdByUserId: userId,
|
||||
name: this.requiredTrim(payload.name, "Goal name"),
|
||||
description: payload.description?.trim() || null,
|
||||
targetAmount,
|
||||
currentAmount,
|
||||
isoCurrencyCode: (payload.isoCurrencyCode ?? "USD").toUpperCase(),
|
||||
targetDate: payload.targetDate ? new Date(payload.targetDate) : null,
|
||||
priority: payload.priority ?? "medium",
|
||||
status: "active",
|
||||
metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.goal.create",
|
||||
metadata: {
|
||||
householdId,
|
||||
goalId: goal.id,
|
||||
targetAmount,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.serializeGoal(goal);
|
||||
}
|
||||
|
||||
async updateGoal(userId: string, householdId: string, goalId: string, payload: UpdateHouseholdGoalDto) {
|
||||
if (!Object.keys(payload).length) {
|
||||
throw new BadRequestException("At least one goal field is required.");
|
||||
}
|
||||
await this.requireManager(userId, householdId);
|
||||
const existing = await this.prisma.householdGoal.findFirst({
|
||||
where: { id: goalId, householdId },
|
||||
});
|
||||
if (!existing) throw new BadRequestException("Household goal not found.");
|
||||
|
||||
const targetAmount = payload.targetAmount !== undefined
|
||||
? this.positiveMoney(payload.targetAmount, "Target amount")
|
||||
: this.toNumber((existing as any).targetAmount);
|
||||
const currentAmount = payload.currentAmount !== undefined
|
||||
? this.nonNegativeMoney(payload.currentAmount, "Current amount")
|
||||
: this.toNumber((existing as any).currentAmount);
|
||||
if (currentAmount > targetAmount) {
|
||||
throw new BadRequestException("Current amount cannot exceed target amount.");
|
||||
}
|
||||
|
||||
const updated = await this.prisma.householdGoal.update({
|
||||
where: { id: goalId },
|
||||
data: {
|
||||
...(payload.name !== undefined && { name: this.requiredTrim(payload.name, "Goal name") }),
|
||||
...(payload.description !== undefined && { description: payload.description?.trim() || null }),
|
||||
...(payload.targetAmount !== undefined && { targetAmount }),
|
||||
...(payload.currentAmount !== undefined && { currentAmount }),
|
||||
...(payload.isoCurrencyCode !== undefined && { isoCurrencyCode: payload.isoCurrencyCode.toUpperCase() }),
|
||||
...(payload.targetDate !== undefined && { targetDate: payload.targetDate ? new Date(payload.targetDate) : null }),
|
||||
...(payload.priority !== undefined && { priority: payload.priority }),
|
||||
...(payload.status !== undefined && { status: payload.status }),
|
||||
...(payload.metadata !== undefined && { metadata: payload.metadata as Prisma.InputJsonValue }),
|
||||
},
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, email: true, fullName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: "household.goal.update",
|
||||
metadata: {
|
||||
householdId,
|
||||
goalId,
|
||||
status: updated.status,
|
||||
currentAmount: this.toNumber((updated as any).currentAmount),
|
||||
targetAmount: this.toNumber((updated as any).targetAmount),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.serializeGoal(updated);
|
||||
}
|
||||
|
||||
async updateMember(userId: string, householdId: string, memberId: string, payload: UpdateHouseholdMemberDto) {
|
||||
if (payload.role === undefined && payload.status === undefined) {
|
||||
throw new BadRequestException("Role or status is required.");
|
||||
@ -532,4 +649,49 @@ export class HouseholdsService {
|
||||
yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeGoal(goal: any) {
|
||||
const targetAmount = this.toNumber(goal.targetAmount);
|
||||
const currentAmount = this.toNumber(goal.currentAmount);
|
||||
return {
|
||||
id: goal.id,
|
||||
householdId: goal.householdId,
|
||||
name: goal.name,
|
||||
description: goal.description,
|
||||
targetAmount: this.roundCurrency(targetAmount),
|
||||
currentAmount: this.roundCurrency(currentAmount),
|
||||
remainingAmount: this.roundCurrency(Math.max(targetAmount - currentAmount, 0)),
|
||||
progressPercent: targetAmount > 0 ? this.roundCurrency(Math.min((currentAmount / targetAmount) * 100, 100)) : 0,
|
||||
isoCurrencyCode: goal.isoCurrencyCode,
|
||||
targetDate: goal.targetDate,
|
||||
priority: goal.priority,
|
||||
status: goal.status,
|
||||
metadata: goal.metadata ?? {},
|
||||
createdAt: goal.createdAt,
|
||||
updatedAt: goal.updatedAt,
|
||||
createdBy: goal.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
private requiredTrim(value: string, label: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new BadRequestException(`${label} is required.`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private positiveMoney(value: number, label: string) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException(`${label} must be greater than zero.`);
|
||||
}
|
||||
return this.roundCurrency(amount);
|
||||
}
|
||||
|
||||
private nonNegativeMoney(value: number, label: string) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
throw new BadRequestException(`${label} must be zero or greater.`);
|
||||
}
|
||||
return this.roundCurrency(amount);
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,6 +207,142 @@ describe("HouseholdsService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@ -23,6 +23,12 @@ export const createPrismaMock = () => ({
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
householdGoal: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn()
|
||||
},
|
||||
account: {
|
||||
count: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user