Files
carvalho-finances/apps/api/internal/service/recurring_test.go
T
Mlcavalho1andClaude Sonnet 4.6 12e8500827 feat(#19): registro manual de transações e recorrências fixas
Adiciona CRUD manual de transações (income/expense) com filtro mensal, CRUD de
recorrências fixas com verificação mensal de cobertura (±10% por categoria),
endpoint de ignore/unignore e telas Vue para Transações e Configurações.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 20:26:48 -03:00

154 lines
5.6 KiB
Go

package service_test
import (
"context"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/repository"
"financeiro-carvalho/internal/service"
)
// ── mock recurring repo ──────────────────────────────────────────────────────
type mockRecurringRepo struct {
items []model.RecurringExpense
ignores map[string]string // "id:month" → reason
}
func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo {
return &mockRecurringRepo{items: items, ignores: map[string]string{}}
}
func (m *mockRecurringRepo) List(_ context.Context) ([]model.RecurringExpense, error) {
return m.items, nil
}
func (m *mockRecurringRepo) GetByID(_ context.Context, id int) (*model.RecurringExpense, error) {
for _, r := range m.items {
if r.ID == id {
cp := r
return &cp, nil
}
}
return nil, repository.ErrNotFound
}
func (m *mockRecurringRepo) Create(_ context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
r := model.RecurringExpense{ID: len(m.items) + 1, Name: in.Name, ExpectedAmount: in.ExpectedAmount, DayOfMonth: in.DayOfMonth, Active: true}
m.items = append(m.items, r)
return &r, nil
}
func (m *mockRecurringRepo) Update(_ context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
for i, r := range m.items {
if r.ID == id {
m.items[i].Name = in.Name
m.items[i].ExpectedAmount = in.ExpectedAmount
cp := m.items[i]
return &cp, nil
}
}
return nil, repository.ErrNotFound
}
func (m *mockRecurringRepo) Delete(_ context.Context, id int) error { return nil }
func (m *mockRecurringRepo) IsIgnored(_ context.Context, id int, month string) (bool, string, error) {
key := string(rune(id)) + ":" + month
r, ok := m.ignores[key]
return ok, r, nil
}
func (m *mockRecurringRepo) Ignore(_ context.Context, id int, month, reason string) error {
m.ignores[string(rune(id))+":"+month] = reason
return nil
}
func (m *mockRecurringRepo) Unignore(_ context.Context, id int, month string) error {
delete(m.ignores, string(rune(id))+":"+month)
return nil
}
// ── mock tx repo ─────────────────────────────────────────────────────────────
type mockTxRepo struct {
matchResult bool
}
func (m *mockTxRepo) List(_ context.Context, _ string) ([]model.Transaction, error) { return nil, nil }
func (m *mockTxRepo) GetByID(_ context.Context, _ int) (*model.Transaction, error) { return nil, nil }
func (m *mockTxRepo) Create(_ context.Context, t model.Transaction) (*model.Transaction, error) {
return &t, nil
}
func (m *mockTxRepo) Update(_ context.Context, t model.Transaction) (*model.Transaction, error) {
return &t, nil
}
func (m *mockTxRepo) Delete(_ context.Context, _ int) error { return nil }
func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64) (bool, error) {
return m.matchResult, nil
}
// ── tests ─────────────────────────────────────────────────────────────────────
var catID = 1
func TestMonthlyStatus_Covered(t *testing.T) {
re := model.RecurringExpense{ID: 1, Name: "Plano Saúde", ExpectedAmount: 300, DayOfMonth: 5, CategoryID: &catID, Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: true})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 {
t.Fatalf("expected 1, got %d", len(statuses))
}
if !statuses[0].Covered {
t.Error("expected Covered=true")
}
}
func TestMonthlyStatus_Uncovered(t *testing.T) {
re := model.RecurringExpense{ID: 2, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Active: true}
svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if statuses[0].Covered {
t.Error("expected Covered=false")
}
}
func TestMonthlyStatus_Ignored_CountsAsCovered(t *testing.T) {
re := model.RecurringExpense{ID: 3, Name: "Seguro", ExpectedAmount: 200, DayOfMonth: 1, CategoryID: &catID, Active: true}
repo := newMockRecurring([]model.RecurringExpense{re})
_ = repo.Ignore(context.Background(), 3, "2024-03", "viagem")
svc := service.NewRecurringService(repo, &mockTxRepo{matchResult: false})
statuses, err := svc.MonthlyStatus(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if !statuses[0].Covered {
t.Error("ignored should count as covered")
}
if !statuses[0].Ignored {
t.Error("expected Ignored=true")
}
}
func TestCreateRecurring_Validation(t *testing.T) {
svc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
_, err := svc.Create(context.Background(), model.RecurringInput{Name: "", ExpectedAmount: 100, DayOfMonth: 5})
if err != service.ErrRecurringEmptyName {
t.Fatalf("expected ErrRecurringEmptyName, got %v", err)
}
_, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: -1, DayOfMonth: 5})
if err != service.ErrRecurringInvalidAmount {
t.Fatalf("expected ErrRecurringInvalidAmount, got %v", err)
}
_, err = svc.Create(context.Background(), model.RecurringInput{Name: "X", ExpectedAmount: 100, DayOfMonth: 0})
if err != service.ErrRecurringInvalidDayOfMonth {
t.Fatalf("expected ErrRecurringInvalidDayOfMonth, got %v", err)
}
}