mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
567 lines
13 KiB
567 lines
13 KiB
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
|
|
import { ConflictException, ForbiddenException } from '@nestjs/common';
|
|
|
|
import { BudgetsService } from './budgets.service';
|
|
|
|
describe('BudgetsService', () => {
|
|
const userId = 'user-1';
|
|
const accountId = 'account-1';
|
|
const categoryId = 'category-1';
|
|
const budgetId = 'budget-1';
|
|
const createdAt = new Date('2026-06-23T00:00:00.000Z');
|
|
const updatedAt = new Date('2026-06-23T00:00:00.000Z');
|
|
|
|
let budgetsService: BudgetsService;
|
|
let prismaService: {
|
|
account: {
|
|
findFirst: jest.Mock;
|
|
};
|
|
budget: {
|
|
create: jest.Mock;
|
|
delete: jest.Mock;
|
|
findFirst: jest.Mock;
|
|
findMany: jest.Mock;
|
|
update: jest.Mock;
|
|
};
|
|
expense: {
|
|
groupBy: jest.Mock;
|
|
};
|
|
expenseCategory: {
|
|
create: jest.Mock;
|
|
delete: jest.Mock;
|
|
findFirst: jest.Mock;
|
|
findMany: jest.Mock;
|
|
update: jest.Mock;
|
|
};
|
|
};
|
|
|
|
beforeEach(() => {
|
|
prismaService = {
|
|
account: {
|
|
findFirst: jest.fn()
|
|
},
|
|
budget: {
|
|
create: jest.fn(),
|
|
delete: jest.fn(),
|
|
findFirst: jest.fn(),
|
|
findMany: jest.fn(),
|
|
update: jest.fn()
|
|
},
|
|
expense: {
|
|
groupBy: jest.fn()
|
|
},
|
|
expenseCategory: {
|
|
create: jest.fn(),
|
|
delete: jest.fn(),
|
|
findFirst: jest.fn(),
|
|
findMany: jest.fn(),
|
|
update: jest.fn()
|
|
}
|
|
};
|
|
|
|
budgetsService = new BudgetsService(
|
|
prismaService as unknown as PrismaService
|
|
);
|
|
});
|
|
|
|
it('lists budgets for a month and includes spent and remaining amounts', async () => {
|
|
prismaService.budget.findMany.mockResolvedValue([
|
|
{
|
|
account: {
|
|
balance: 0,
|
|
createdAt,
|
|
id: accountId,
|
|
isExcluded: false,
|
|
name: 'Checking',
|
|
updatedAt,
|
|
userId
|
|
},
|
|
accountId,
|
|
amount: 500,
|
|
category: {
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Food shop',
|
|
type: 'EXPENSE',
|
|
updatedAt,
|
|
userId
|
|
},
|
|
{
|
|
account: null,
|
|
accountId: null,
|
|
amount: 200,
|
|
category: {
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: 'budget-2',
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Emergency fund',
|
|
type: 'CASH_SAVINGS',
|
|
updatedAt,
|
|
userId
|
|
}
|
|
]);
|
|
prismaService.expense.groupBy.mockResolvedValue([
|
|
{
|
|
_sum: { amount: 125 },
|
|
categoryId
|
|
}
|
|
]);
|
|
|
|
const response = await budgetsService.getBudgets({
|
|
month: '2026-06',
|
|
userId
|
|
});
|
|
|
|
expect(prismaService.budget.findMany).toHaveBeenCalledWith({
|
|
include: { account: true, category: true },
|
|
orderBy: { name: 'asc' },
|
|
where: {
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
userId
|
|
}
|
|
});
|
|
expect(prismaService.expense.groupBy).toHaveBeenCalledWith({
|
|
by: ['categoryId'],
|
|
where: {
|
|
categoryId: { in: [categoryId] },
|
|
date: {
|
|
gte: new Date('2026-06-01T00:00:00.000Z'),
|
|
lt: new Date('2026-07-01T00:00:00.000Z')
|
|
},
|
|
userId
|
|
},
|
|
_sum: { amount: true }
|
|
});
|
|
expect(response).toEqual({
|
|
budgets: [
|
|
{
|
|
account: {
|
|
balance: 0,
|
|
createdAt,
|
|
id: accountId,
|
|
isExcluded: false,
|
|
name: 'Checking',
|
|
updatedAt,
|
|
userId
|
|
},
|
|
accountId,
|
|
amount: 500,
|
|
category: {
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: '2026-06',
|
|
name: 'Food shop',
|
|
remaining: 375,
|
|
spent: 125,
|
|
type: 'EXPENSE',
|
|
updatedAt
|
|
},
|
|
{
|
|
amount: 200,
|
|
category: {
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: 'budget-2',
|
|
month: '2026-06',
|
|
name: 'Emergency fund',
|
|
remaining: 200,
|
|
spent: 0,
|
|
type: 'CASH_SAVINGS',
|
|
updatedAt
|
|
}
|
|
],
|
|
totalBudgeted: 700,
|
|
totalMonthlySavings: 200,
|
|
totalPlannedSpend: 500,
|
|
totalRemaining: 575,
|
|
totalSpent: 125
|
|
});
|
|
});
|
|
|
|
it('lists expense categories for the current user', async () => {
|
|
prismaService.expenseCategory.findMany.mockResolvedValue([
|
|
{
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt,
|
|
userId
|
|
}
|
|
]);
|
|
|
|
const response = await budgetsService.getCategories({ userId });
|
|
|
|
expect(prismaService.expenseCategory.findMany).toHaveBeenCalledWith({
|
|
orderBy: { name: 'asc' },
|
|
where: { userId }
|
|
});
|
|
expect(response).toEqual([
|
|
{
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('creates an expense category for the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue(null);
|
|
prismaService.expenseCategory.create.mockResolvedValue({
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt,
|
|
userId
|
|
});
|
|
|
|
const response = await budgetsService.createCategory({
|
|
data: {
|
|
color: '#0055aa',
|
|
name: 'Groceries'
|
|
},
|
|
userId
|
|
});
|
|
|
|
expect(prismaService.expenseCategory.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
name: 'Groceries',
|
|
userId
|
|
}
|
|
});
|
|
expect(prismaService.expenseCategory.create).toHaveBeenCalledWith({
|
|
data: {
|
|
color: '#0055aa',
|
|
name: 'Groceries',
|
|
user: { connect: { id: userId } }
|
|
}
|
|
});
|
|
expect(response).toEqual({
|
|
color: '#0055aa',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
updatedAt
|
|
});
|
|
});
|
|
|
|
it('rejects duplicate category names for the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue({
|
|
id: categoryId,
|
|
name: 'Groceries',
|
|
userId
|
|
});
|
|
|
|
await expect(
|
|
budgetsService.createCategory({
|
|
data: {
|
|
name: 'Groceries'
|
|
},
|
|
userId
|
|
})
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('updates only categories owned by the current user', async () => {
|
|
prismaService.expenseCategory.findFirst
|
|
.mockResolvedValueOnce({
|
|
id: categoryId,
|
|
userId
|
|
})
|
|
.mockResolvedValueOnce(null);
|
|
prismaService.expenseCategory.update.mockResolvedValue({
|
|
color: '#aa5500',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Food',
|
|
updatedAt,
|
|
userId
|
|
});
|
|
|
|
const response = await budgetsService.updateCategory({
|
|
data: {
|
|
color: '#aa5500',
|
|
id: categoryId,
|
|
name: 'Food'
|
|
},
|
|
id: categoryId,
|
|
userId
|
|
});
|
|
|
|
expect(prismaService.expenseCategory.findFirst).toHaveBeenNthCalledWith(1, {
|
|
where: {
|
|
id: categoryId,
|
|
userId
|
|
}
|
|
});
|
|
expect(prismaService.expenseCategory.findFirst).toHaveBeenNthCalledWith(2, {
|
|
where: {
|
|
name: 'Food',
|
|
userId
|
|
}
|
|
});
|
|
expect(prismaService.expenseCategory.update).toHaveBeenCalledWith({
|
|
data: {
|
|
color: '#aa5500',
|
|
name: 'Food'
|
|
},
|
|
where: { id: categoryId }
|
|
});
|
|
expect(response).toEqual({
|
|
color: '#aa5500',
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Food',
|
|
updatedAt
|
|
});
|
|
});
|
|
|
|
it('deletes only categories owned by the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue({
|
|
id: categoryId,
|
|
userId
|
|
});
|
|
prismaService.expenseCategory.delete.mockResolvedValue({
|
|
id: categoryId
|
|
});
|
|
|
|
await budgetsService.deleteCategory({ id: categoryId, userId });
|
|
|
|
expect(prismaService.expenseCategory.delete).toHaveBeenCalledWith({
|
|
where: { id: categoryId }
|
|
});
|
|
});
|
|
|
|
it('creates a budget for a category owned by the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue({
|
|
id: categoryId,
|
|
userId
|
|
});
|
|
prismaService.account.findFirst.mockResolvedValue({
|
|
id: accountId,
|
|
userId
|
|
});
|
|
prismaService.budget.create.mockResolvedValue({
|
|
account: {
|
|
balance: 0,
|
|
createdAt,
|
|
id: accountId,
|
|
isExcluded: false,
|
|
name: 'Checking',
|
|
updatedAt,
|
|
userId
|
|
},
|
|
accountId,
|
|
amount: 250,
|
|
category: {
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Transport',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Train pass',
|
|
type: 'EXPENSE',
|
|
updatedAt,
|
|
userId
|
|
});
|
|
|
|
const response = await budgetsService.createBudget({
|
|
data: {
|
|
accountId,
|
|
amount: 250,
|
|
categoryId,
|
|
currency: 'USD',
|
|
month: '2026-06',
|
|
name: 'Train pass',
|
|
type: 'EXPENSE'
|
|
},
|
|
userId
|
|
});
|
|
|
|
expect(prismaService.account.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: accountId,
|
|
userId
|
|
}
|
|
});
|
|
expect(prismaService.budget.create).toHaveBeenCalledWith({
|
|
data: {
|
|
account: {
|
|
connect: {
|
|
id_userId: { id: accountId, userId }
|
|
}
|
|
},
|
|
amount: 250,
|
|
category: { connect: { id: categoryId } },
|
|
currency: 'USD',
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Train pass',
|
|
type: 'EXPENSE',
|
|
user: { connect: { id: userId } }
|
|
},
|
|
include: { account: true, category: true }
|
|
});
|
|
expect(response.remaining).toBe(250);
|
|
expect(response.spent).toBe(0);
|
|
});
|
|
|
|
it('rejects a budget account not owned by the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue({
|
|
id: categoryId,
|
|
userId
|
|
});
|
|
prismaService.account.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
budgetsService.createBudget({
|
|
data: {
|
|
accountId,
|
|
amount: 250,
|
|
categoryId,
|
|
currency: 'USD',
|
|
month: '2026-06',
|
|
name: 'Train pass',
|
|
type: 'EXPENSE'
|
|
},
|
|
userId
|
|
})
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('updates only a budget owned by the current user', async () => {
|
|
prismaService.expenseCategory.findFirst.mockResolvedValue({
|
|
id: categoryId,
|
|
userId
|
|
});
|
|
prismaService.budget.findFirst.mockResolvedValue({
|
|
id: budgetId,
|
|
userId
|
|
});
|
|
prismaService.budget.update.mockResolvedValue({
|
|
account: null,
|
|
accountId: null,
|
|
amount: 300,
|
|
category: {
|
|
createdAt,
|
|
id: categoryId,
|
|
name: 'Transport',
|
|
updatedAt
|
|
},
|
|
categoryId,
|
|
createdAt,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Train pass',
|
|
type: 'EXPENSE',
|
|
updatedAt,
|
|
userId
|
|
});
|
|
|
|
const response = await budgetsService.updateBudget({
|
|
data: {
|
|
amount: 300,
|
|
categoryId,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: '2026-06',
|
|
name: 'Train pass',
|
|
type: 'EXPENSE'
|
|
},
|
|
id: budgetId,
|
|
userId
|
|
});
|
|
|
|
expect(prismaService.budget.update).toHaveBeenCalledWith({
|
|
data: {
|
|
account: { disconnect: true },
|
|
amount: 300,
|
|
category: { connect: { id: categoryId } },
|
|
currency: 'USD',
|
|
month: new Date('2026-06-01T00:00:00.000Z'),
|
|
name: 'Train pass',
|
|
type: 'EXPENSE'
|
|
},
|
|
include: { account: true, category: true },
|
|
where: { id: budgetId }
|
|
});
|
|
expect(response.amount).toBe(300);
|
|
});
|
|
|
|
it('throws when updating a budget not owned by the current user', async () => {
|
|
prismaService.budget.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
budgetsService.updateBudget({
|
|
data: {
|
|
amount: 300,
|
|
categoryId,
|
|
currency: 'USD',
|
|
id: budgetId,
|
|
month: '2026-06',
|
|
name: 'Train pass',
|
|
type: 'EXPENSE'
|
|
},
|
|
id: budgetId,
|
|
userId
|
|
})
|
|
).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('deletes only a budget owned by the current user', async () => {
|
|
prismaService.budget.findFirst.mockResolvedValue({
|
|
id: budgetId,
|
|
userId
|
|
});
|
|
prismaService.budget.delete.mockResolvedValue({
|
|
id: budgetId
|
|
});
|
|
|
|
await budgetsService.deleteBudget({ id: budgetId, userId });
|
|
|
|
expect(prismaService.budget.delete).toHaveBeenCalledWith({
|
|
where: { id: budgetId }
|
|
});
|
|
});
|
|
});
|
|
|