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) } }