Files
FPandA-Engine/tests/actual_test.go
2026-03-21 15:47:40 +01:00

302 lines
9.3 KiB
Go

package test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"Engine/internal/database"
"Engine/internal/handler"
"Engine/internal/model"
"Engine/internal/service"
"Engine/tests/internal/testutil"
)
// ── wire helpers ──────────────────────────────────────────────────────────────
type testServer struct {
srv *httptest.Server
db interface{ Close() error }
}
func newFullServer(t *testing.T) *httptest.Server {
t.Helper()
db := testutil.NewTestDB(t)
actualsRepo := database.NewActualsRepo(db)
actualsH := handler.NewActualsHandler(actualsRepo)
budgetRepo := database.NewBudgetRepo(db)
varianceH := handler.NewVarianceHandler(service.NewVarianceService(budgetRepo, actualsRepo))
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest)
mux.HandleFunc("GET /api/v1/variance", varianceH.Report)
mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// validActual returns one well-formed actual record.
func validActual() map[string]any {
return map[string]any{
"dept_code": "TEST",
"gl_code": "TEST",
"fiscal_year": 2024,
"fiscal_period": 1,
"amount": 1234.56,
"currency": "USD",
"source": "csv_import",
}
}
// ── Actuals: Ingest ───────────────────────────────────────────────────────────
func TestIngestActuals_SingleRecord(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
// makes sire the dept and gl entries exists for testing
testutil.SeedFixtures(t, db) // seld error handling/return
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", validActual())
testutil.AssertStatus(t, w, http.StatusCreated)
var got model.Actual
testutil.DecodeJSON(t, w, &got)
if got.ID == 0 {
t.Error("expected non-zero ID")
}
if got.Amount != 1234.56 {
t.Errorf("amount: got %v, want 1234.56", got.Amount)
}
}
func TestIngestActuals_MultipleRecords(t *testing.T) {
db := testutil.NewTestDB(t)
testutil.SeedFixtures(t, db)
h := handler.NewActualsHandler(database.NewActualsRepo(db))
records := []any{
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 1, "amount": 100.00, "currency": "DKK", "source": "csv"},
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 2, "amount": 200.00, "currency": "DKK", "source": "csv"},
map[string]any{"dept_code": "TEST", "gl_code": "TEST", "fiscal_year": 2024, "fiscal_period": 3, "amount": 300.00, "currency": "DKK", "source": "csv"},
}
w := testutil.Do(t, http.HandlerFunc(h.IngestBatch), http.MethodPost, "/", records)
testutil.AssertStatus(t, w, http.StatusCreated)
var got []model.Actual
testutil.DecodeJSON(t, w, &got)
if len(got) != 3 {
t.Errorf("expected 3 results, got %d", len(got))
}
}
func TestIngestActuals_EmptyList(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{})
// Depending on your handler: 201 with 0 rows ingested, or 400
t.Logf("empty ingest: %d — verify against your handler", w.Code)
}
func TestIngestActuals_InvalidJSON(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", nil)
testutil.AssertStatus(t, w, http.StatusBadRequest)
}
func TestIngestActuals_MissingPeriod(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
record := validActual()
delete(record, "period")
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
testutil.AssertStatus(t, w, http.StatusBadRequest)
}
func TestIngestActuals_NegativeAmount(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
record := validActual()
record["amount"] = -500.00
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", []any{record})
// Adjust to match your validation rule
testutil.AssertStatus(t, w, http.StatusBadRequest)
}
func TestIngestActuals_Idempotent(t *testing.T) {
t.Helper()
db := testutil.NewTestDB(t)
repo := database.NewActualsRepo(db)
h := handler.NewActualsHandler(repo)
fn := http.HandlerFunc(h.Ingest)
testutil.Do(t, fn, http.MethodPost, "/", []any{validActual()})
// Ingesting the same record twice should not panic or 500
w := testutil.Do(t, fn, http.MethodPost, "/", []any{validActual()})
if w.Code >= 500 {
t.Errorf("duplicate ingest returned %d, expected non-5xx", w.Code)
}
}
// ── Variance: Report ──────────────────────────────────────────────────────────
func TestVarianceReport_Empty(t *testing.T) {
srv := newFullServer(t)
resp, err := srv.Client().Get(srv.URL + "/api/v1/variance?period=2024-01")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("variance report: got %d, want 200", resp.StatusCode)
}
}
func TestVarianceReport_WithData(t *testing.T) {
srv := newFullServer(t)
client := srv.Client()
// Seed a budget
budgetPayload := map[string]any{
"department_id": 1, "gl_account_id": 1, "period": "2024-01", "amount": 10000.00,
}
resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json",
mustJSON(t, budgetPayload))
resp.Body.Close()
// Ingest an actual — under budget
actualPayload := []any{
map[string]any{"department_id": 1, "gl_account_id": 1, "period": "2024-01", "amount": 7500.00, "source": "test"},
}
resp2, _ := client.Post(srv.URL+"/api/v1/actuals/ingest", "application/json",
mustJSON(t, actualPayload))
resp2.Body.Close()
// Get variance report
resp3, err := client.Get(srv.URL + "/api/v1/variance?period=2024-01")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusOK {
t.Errorf("variance report: got %d, want 200", resp3.StatusCode)
}
}
func TestVarianceReport_MissingPeriodParam(t *testing.T) {
srv := newFullServer(t)
resp, err := srv.Client().Get(srv.URL + "/api/v1/variance")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// Should return 400 if period is required, or 200 with all periods
t.Logf("variance without period param: %d — verify against your handler", resp.StatusCode)
}
// ── Variance: Alerts ──────────────────────────────────────────────────────────
func TestVarianceAlerts_NoAlerts(t *testing.T) {
srv := newFullServer(t)
resp, err := srv.Client().Get(srv.URL + "/api/v1/variance/alerts?period=2024-01")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("alerts: got %d, want 200", resp.StatusCode)
}
}
func TestVarianceAlerts_OverBudgetTriggersAlert(t *testing.T) {
srv := newFullServer(t)
client := srv.Client()
// Budget: 1000
budgetPayload := map[string]any{
"department_id": 1, "gl_account_id": 1, "period": "2024-02", "amount": 1000.00,
}
resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json",
mustJSON(t, budgetPayload))
resp.Body.Close()
// Actual: 1500 — 50% over budget
actualPayload := []any{
map[string]any{"department_id": 1, "gl_account_id": 1, "period": "2024-02", "amount": 1500.00, "source": "test"},
}
resp2, _ := client.Post(srv.URL+"/api/v1/actuals/ingest", "application/json",
mustJSON(t, actualPayload))
resp2.Body.Close()
resp3, err := client.Get(srv.URL + "/api/v1/variance/alerts?period=2024-02")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusOK {
t.Errorf("alerts: got %d, want 200", resp3.StatusCode)
}
// Decode whatever shape your alerts response is
var alerts any
if err := json.NewDecoder(resp3.Body).Decode(&alerts); err != nil {
t.Fatalf("decode alerts: %v", err)
}
t.Logf("alerts payload: %+v", alerts)
}
func TestVarianceAlerts_UnderBudgetNoAlert(t *testing.T) {
srv := newFullServer(t)
client := srv.Client()
// Budget: 5000, Actual: 1000 — well under, no alert expected
budgetPayload := map[string]any{
"department_id": 2, "gl_account_id": 2, "period": "2024-03", "amount": 5000.00,
}
resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json",
mustJSON(t, budgetPayload))
resp.Body.Close()
actualPayload := []any{
map[string]any{"department_id": 2, "gl_account_id": 2, "period": "2024-03", "amount": 1000.00, "source": "test"},
}
resp2, _ := client.Post(srv.URL+"/api/v1/actuals/ingest", "application/json",
mustJSON(t, actualPayload))
resp2.Body.Close()
resp3, err := client.Get(srv.URL + "/api/v1/variance/alerts?period=2024-03")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusOK {
t.Errorf("alerts: got %d, want 200", resp3.StatusCode)
}
}