269 lines
8.2 KiB
Go
269 lines
8.2 KiB
Go
package test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"Engine/internal/database"
|
|
"Engine/internal/handler"
|
|
"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
|
|
}
|
|
|
|
func newActualsHandler(t *testing.T) *handler.ActualsHandler {
|
|
t.Helper()
|
|
return handler.NewActualsHandler(database.NewActualsRepo(testutil.NewTestDB(t)))
|
|
}
|
|
|
|
// validActual returns one well-formed actual record.
|
|
func validActual() map[string]any {
|
|
return map[string]any{
|
|
"department_id": 1,
|
|
"gl_account_id": 1,
|
|
"period": "2024-01",
|
|
"amount": 1234.56,
|
|
"source": "csv_import",
|
|
}
|
|
}
|
|
|
|
// ── Actuals: Ingest ───────────────────────────────────────────────────────────
|
|
|
|
func TestIngestActuals_SingleRecord(t *testing.T) {
|
|
h := newActualsHandler(t)
|
|
|
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/",
|
|
[]any{validActual()})
|
|
|
|
testutil.AssertStatus(t, w, http.StatusCreated)
|
|
}
|
|
|
|
func TestIngestActuals_MultipleRecords(t *testing.T) {
|
|
h := newActualsHandler(t)
|
|
|
|
records := []any{
|
|
map[string]any{"department_id": 1, "gl_account_id": 1, "period": "2024-01", "amount": 100.00, "source": "csv"},
|
|
map[string]any{"department_id": 1, "gl_account_id": 2, "period": "2024-01", "amount": 200.00, "source": "csv"},
|
|
map[string]any{"department_id": 2, "gl_account_id": 1, "period": "2024-01", "amount": 300.00, "source": "csv"},
|
|
}
|
|
|
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", records)
|
|
testutil.AssertStatus(t, w, http.StatusCreated)
|
|
}
|
|
|
|
func TestIngestActuals_EmptyList(t *testing.T) {
|
|
h := newActualsHandler(t)
|
|
|
|
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) {
|
|
h := newActualsHandler(t)
|
|
w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", nil)
|
|
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestIngestActuals_MissingPeriod(t *testing.T) {
|
|
h := newActualsHandler(t)
|
|
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) {
|
|
h := newActualsHandler(t)
|
|
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) {
|
|
h := newActualsHandler(t)
|
|
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)
|
|
}
|
|
}
|