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 lates map[string]bool // "id:month" → true } func newMockRecurring(items []model.RecurringExpense) *mockRecurringRepo { return &mockRecurringRepo{items: items, ignores: map[string]string{}, lates: map[string]bool{}} } 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, Type: in.Type, 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 } func (m *mockRecurringRepo) IsLate(_ context.Context, id int, month string) (bool, error) { return m.lates[string(rune(id))+":"+month], nil } func (m *mockRecurringRepo) MarkLate(_ context.Context, id int, month string) error { m.lates[string(rune(id))+":"+month] = true return nil } func (m *mockRecurringRepo) UnmarkLate(_ context.Context, id int, month string) error { delete(m.lates, 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) DeleteByMonth(_ context.Context, _ string) (int, error) { return 0, nil } func (m *mockTxRepo) HasMatchingTransaction(_ context.Context, _ *int, _ string, _ float64, _ string) (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, Type: "expense", 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, Type: "expense", 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, Type: "expense", 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 TestMonthlyStatus_IncomeConfirmed(t *testing.T) { re := model.RecurringExpense{ID: 4, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", 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("income with matching transaction should be covered") } } func TestMonthlyStatus_IncomeLate(t *testing.T) { re := model.RecurringExpense{ID: 5, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true} repo := newMockRecurring([]model.RecurringExpense{re}) _ = repo.MarkLate(context.Background(), 5, "2024-03") 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("late income should NOT be covered") } if !statuses[0].Late { t.Error("expected Late=true") } } func TestConfirmIncome_CreatesTransaction(t *testing.T) { re := model.RecurringExpense{ID: 6, Name: "Salário", ExpectedAmount: 3200, DayOfMonth: 5, CategoryID: &catID, Type: "income", Active: true} svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{}) err := svc.ConfirmIncome(context.Background(), 6, "2024-03") if err != nil { t.Fatalf("unexpected error: %v", err) } } func TestConfirmIncome_RejectsExpenseType(t *testing.T) { re := model.RecurringExpense{ID: 7, Name: "Netflix", ExpectedAmount: 55, DayOfMonth: 10, CategoryID: &catID, Type: "expense", Active: true} svc := service.NewRecurringService(newMockRecurring([]model.RecurringExpense{re}), &mockTxRepo{}) err := svc.ConfirmIncome(context.Background(), 7, "2024-03") if err != service.ErrRecurringNotIncome { t.Fatalf("expected ErrRecurringNotIncome, got %v", err) } } 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) } }