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]>
154 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|