From 3490dd13d45281bed374668fa288d504e56d4702 Mon Sep 17 00:00:00 2001 From: samantha42 Date: Sat, 21 Mar 2026 15:47:40 +0100 Subject: [PATCH] better end points and better tests --- internal/database/actual-repo.go | 30 +++++- internal/database/report.go | 144 ++++++++++++++++++++++++++++ internal/handler/actuals.go | 32 +++++++ internal/handler/report.go | 40 ++++++++ internal/model/model.go | 54 +++++++++++ internal/service/report-service.go | 82 ++++++++++++++++ main.go | 18 +++- tests/actual_test.go | 77 ++++++++++----- tests/budget_test.go | 4 +- tests/internal/testutil/testutil.go | 35 +++++++ 10 files changed, 482 insertions(+), 34 deletions(-) create mode 100644 internal/database/report.go create mode 100644 internal/handler/report.go create mode 100644 internal/service/report-service.go diff --git a/internal/database/actual-repo.go b/internal/database/actual-repo.go index 0292f26..707bb81 100644 --- a/internal/database/actual-repo.go +++ b/internal/database/actual-repo.go @@ -38,20 +38,28 @@ func (r *ActualsRepo) Ingest(ctx context.Context, req model.IngestActualsRequest } a := &model.Actual{} + var ingestedAt string + err = r.db.QueryRowContext(ctx, ` - SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id, - amount, currency, source, ingested_at - FROM actuals - WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`, + SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id, + amount, currency, source, ingested_at + FROM actuals + WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`, req.FiscalYear, req.FiscalPeriod, deptID, glID, ).Scan( &a.ID, &a.FiscalYear, &a.FiscalPeriod, &a.DepartmentID, &a.GLAccountID, - &a.Amount, &a.Currency, &a.Source, &a.IngestedAt, + &a.Amount, &a.Currency, &a.Source, &ingestedAt, ) if err != nil { return nil, fmt.Errorf("fetch ingested actual: %w", err) } + + a.IngestedAt, err = parseTime(ingestedAt) + if err != nil { + return nil, fmt.Errorf("parse ingested_at %q: %w", ingestedAt, err) + } + return a, nil } @@ -82,3 +90,15 @@ func (r *ActualsRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod } return result, rows.Err() } + +func (r *ActualsRepo) IngestBatch(ctx context.Context, reqs []model.IngestActualsRequest) ([]*model.Actual, error) { + out := make([]*model.Actual, 0, len(reqs)) + for _, req := range reqs { + a, err := r.Ingest(ctx, req) + if err != nil { + return nil, fmt.Errorf("IngestBatch: record (dept=%s gl=%s): %w", req.DeptCode, req.GLCode, err) + } + out = append(out, a) + } + return out, nil +} diff --git a/internal/database/report.go b/internal/database/report.go new file mode 100644 index 0000000..a29c28b --- /dev/null +++ b/internal/database/report.go @@ -0,0 +1,144 @@ +package database + +import ( + "Engine/internal/model" + "context" + "database/sql" + "fmt" +) + +type ReportRepo struct { + db *sql.DB +} + +func NewReportRepo(db *sql.DB) *ReportRepo { + return &ReportRepo{db: db} +} + +// getAmountsByGLType is the shared query for both actuals and budget tables, +// filtered to a single GL account type, period, and department. +func (rr *ReportRepo) getActualAmountsByGLType( + ctx context.Context, + accountType model.GLAccountType, + fiscalYear, fiscalPeriod int, + deptCode string, +) ([]model.GLAmountRow, error) { + const q = ` + SELECT g.code, + g.description, + SUM(a.amount), + a.currency + FROM actuals a + JOIN gl_accounts g ON g.id = a.gl_account_id + JOIN departments d ON d.id = a.department_id + WHERE g.type = ? + AND g.active = 1 + AND a.fiscal_year = ? + AND a.fiscal_period = ? + AND d.code = ? + GROUP BY g.id, a.currency + ORDER BY g.code` + + rows, err := rr.db.QueryContext(ctx, q, + string(accountType), fiscalYear, fiscalPeriod, deptCode) + if err != nil { + return nil, fmt.Errorf("getActualAmountsByGLType(%s): %w", accountType, err) + } + defer rows.Close() + + var out []model.GLAmountRow + for rows.Next() { + var r model.GLAmountRow + if err := rows.Scan(&r.GLCode, &r.Description, &r.Amount, &r.Currency); err != nil { + return nil, fmt.Errorf("getActualAmountsByGLType(%s): scan: %w", accountType, err) + } + out = append(out, r) + } + return out, rows.Err() +} + +func (rr *ReportRepo) getBudgetAmountsByGLType( + ctx context.Context, + accountType model.GLAccountType, + fiscalYear, fiscalPeriod int, + deptCode string, + version model.BudgetVersion, +) ([]model.RevenueAmounts, error) { + const q = ` + SELECT g.code, + g.description, + SUM(b.amount), + b.currency + FROM budgets b + JOIN gl_accounts g ON g.id = b.gl_account_id + JOIN departments d ON d.id = b.department_id + WHERE g.type = ? + AND g.active = 1 + AND b.fiscal_year = ? + AND b.fiscal_period = ? + AND d.code = ? + AND b.version = ? + GROUP BY g.id, b.currency + ORDER BY g.code` + + rows, err := rr.db.QueryContext(ctx, q, + string(accountType), fiscalYear, fiscalPeriod, deptCode, string(version)) + if err != nil { + return nil, fmt.Errorf("getBudgetAmountsByGLType(%s): %w", accountType, err) + } + defer rows.Close() + + var out []model.RevenueAmounts + for rows.Next() { + var r model.RevenueAmounts + if err := rows.Scan(&r.GLCode, &r.Description, &r.Amount, &r.Currency); err != nil { + return nil, fmt.Errorf("getBudgetAmountsByGLType(%s): scan: %w", accountType, err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// ── Public surface ──────────────────────────────────────────────────────────── + +func (rr *ReportRepo) GetGLRevenueActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) { + return rr.getActualAmountsByGLType(ctx, model.GLRevenue, fiscalYear, fiscalPeriod, deptCode) +} + +func (rr *ReportRepo) GetGLRevenueBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) { + return rr.getBudgetAmountsByGLType(ctx, model.GLRevenue, fiscalYear, fiscalPeriod, deptCode, version) +} + +// Same pair for the other types — all delegate to the shared private methods. + +func (rr *ReportRepo) GetGLCOGSActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) { + return rr.getActualAmountsByGLType(ctx, model.GLCOGS, fiscalYear, fiscalPeriod, deptCode) +} + +func (rr *ReportRepo) GetGLCOGSBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) { + return rr.getBudgetAmountsByGLType(ctx, model.GLCOGS, fiscalYear, fiscalPeriod, deptCode, version) +} + +func (rr *ReportRepo) GetGLOpexActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) { + return rr.getActualAmountsByGLType(ctx, model.GLOpex, fiscalYear, fiscalPeriod, deptCode) +} + +func (rr *ReportRepo) GetGLOpexBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) { + return rr.getBudgetAmountsByGLType(ctx, model.GLOpex, fiscalYear, fiscalPeriod, deptCode, version) +} + +func (rr *ReportRepo) GetGLCapexActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) { + return rr.getActualAmountsByGLType(ctx, model.GLCapex, fiscalYear, fiscalPeriod, deptCode) +} + +func (rr *ReportRepo) GetGLCapexBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) { + return rr.getBudgetAmountsByGLType(ctx, model.GLCapex, fiscalYear, fiscalPeriod, deptCode, version) +} + +func (rr *ReportRepo) GetGLHeadcountActuals(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) ([]model.GLAmountRow, error) { + return rr.getActualAmountsByGLType(ctx, model.GLHeadcount, fiscalYear, fiscalPeriod, deptCode) +} + +func (rr *ReportRepo) GetGLHeadcountBudget(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.RevenueAmounts, error) { + return rr.getBudgetAmountsByGLType(ctx, model.GLHeadcount, fiscalYear, fiscalPeriod, deptCode, version) +} diff --git a/internal/handler/actuals.go b/internal/handler/actuals.go index 05f7230..0ddfce5 100644 --- a/internal/handler/actuals.go +++ b/internal/handler/actuals.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "strings" "Engine/internal/database" "Engine/internal/model" @@ -23,6 +24,12 @@ func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) { return } + errs := req.Valid() + if len(errs) > 0 { + writeError(w, http.StatusBadRequest, strings.Join(errs, "; ")) + return + } + actual, err := h.repo.Ingest(r.Context(), req) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -31,3 +38,28 @@ func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, actual) } + +func (h *ActualsHandler) IngestBatch(w http.ResponseWriter, r *http.Request) { + var req []model.IngestActualsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + var errs []string + for _, atual := range req { + errs = append(errs, atual.Valid()...) + } + + if len(errs) > 0 { + writeError(w, http.StatusBadRequest, strings.Join(errs, "; ")) + return + } + + actual, err := h.repo.IngestBatch(r.Context(), req) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, actual) +} diff --git a/internal/handler/report.go b/internal/handler/report.go new file mode 100644 index 0000000..50f615d --- /dev/null +++ b/internal/handler/report.go @@ -0,0 +1,40 @@ +package handler + +import ( + "Engine/internal/model" + "Engine/internal/service" + "context" + "encoding/json" + "net/http" +) + +type ReportHandler struct { + svc *service.ReportService +} + +func NewReportHandler(svc *service.ReportService) *ReportHandler { + return &ReportHandler{svc: svc} +} + +func (report *ReportHandler) PnL(w http.ResponseWriter, r *http.Request) { + var req model.PnLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + for _, year := range req.FiscalYears { + for _, period := range req.FiscalPeriods { + for _, Department := range req.DeptCodes { + report.svc.CreatePnL(context.TODO(), service.PnLFilter{ + FiscalYear: year, + FiscalPeriod: period, + DeptCode: Department, + Version: req.Version, + }) + + } + } + } + +} diff --git a/internal/model/model.go b/internal/model/model.go index b65321c..df9fdec 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -276,3 +276,57 @@ type BudgetRow struct { Amount float64 Currency string } + +// GLAmountRow is a single GL line returned by the ReportRepo amount queries. +// Used as the building block for PnLSection lines. +type GLAmountRow struct { + GLCode string `json:"gl_code"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// RevenueAmounts holds the per-GL amounts for revenue accounts in a single period. +// Used as input for P&L rollup and reforecast calculations. +type RevenueAmounts struct { + GLCode string + Description string + Amount float64 + Currency string +} + +// PnLSection is one block in the P&L (Revenue, COGS, Opex, etc.) +// Lines are the individual GL rows; Total is their sum. +type PnLSection struct { + Lines []GLAmountRow `json:"lines"` + Total float64 `json:"total"` +} + +// PnLReport is the full P&L for a single period/dept/version. +// GrossProfit = Revenue - COGS +// EBIT = GrossProfit - Opex - Headcount +// NetIncome = EBIT - Capex +type PnLReport struct { + Department string `json:"department"` + FiscalYear int `json:"fiscal_year"` + FiscalPeriod int `json:"fiscal_period"` + Version BudgetVersion `json:"version"` + Currency string `json:"currency"` + + Revenue PnLSection `json:"revenue"` + COGS PnLSection `json:"cogs"` + Opex PnLSection `json:"opex"` + Headcount PnLSection `json:"headcount"` + Capex PnLSection `json:"capex"` + + GrossProfit float64 `json:"gross_profit"` // Revenue - COGS + EBIT float64 `json:"ebit"` // GrossProfit - Opex - Headcount + NetIncome float64 `json:"net_income"` // EBIT - Capex +} + +type PnLRequest struct { + FiscalYears []int + FiscalPeriods []int + DeptCodes []string + Version BudgetVersion +} diff --git a/internal/service/report-service.go b/internal/service/report-service.go new file mode 100644 index 0000000..84aa271 --- /dev/null +++ b/internal/service/report-service.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "fmt" + + "Engine/internal/database" + "Engine/internal/model" +) + +type ReportService struct { + repo *database.ReportRepo +} + +func NewReportService(repo *database.ReportRepo) *ReportService { + return &ReportService{repo: repo} +} + +type PnLFilter struct { + FiscalYear int + FiscalPeriod int + DeptCode string + Version model.BudgetVersion +} + +func (s *ReportService) CreatePnL(ctx context.Context, f PnLFilter) (*model.PnLReport, error) { + // fetch all five GL type buckets in parallel would be nicer, but keeping + // it simple and sequential matches the rest of your codebase style. + + revenue, err := s.repo.GetGLRevenueActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode) + if err != nil { + return nil, fmt.Errorf("CreatePnL: revenue actuals: %w", err) + } + + cogs, err := s.repo.GetGLCOGSActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode) + if err != nil { + return nil, fmt.Errorf("CreatePnL: cogs actuals: %w", err) + } + + opex, err := s.repo.GetGLOpexActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode) + if err != nil { + return nil, fmt.Errorf("CreatePnL: opex actuals: %w", err) + } + + headcount, err := s.repo.GetGLHeadcountActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode) + if err != nil { + return nil, fmt.Errorf("CreatePnL: headcount actuals: %w", err) + } + + capex, err := s.repo.GetGLCapexActuals(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode) + if err != nil { + return nil, fmt.Errorf("CreatePnL: capex actuals: %w", err) + } + + report := &model.PnLReport{ + Department: f.DeptCode, + FiscalYear: f.FiscalYear, + FiscalPeriod: f.FiscalPeriod, + Version: f.Version, + Currency: "DKK", + Revenue: toSection(revenue), + COGS: toSection(cogs), + Opex: toSection(opex), + Headcount: toSection(headcount), + Capex: toSection(capex), + } + + report.GrossProfit = report.Revenue.Total - report.COGS.Total + report.EBIT = report.GrossProfit - report.Opex.Total - report.Headcount.Total + report.NetIncome = report.EBIT - report.Capex.Total + + return report, nil +} + +// toSection sums the rows and packages them into a PnLSection. +func toSection(rows []model.GLAmountRow) model.PnLSection { + s := model.PnLSection{Lines: rows} + for _, r := range rows { + s.Total += r.Amount + } + return s +} diff --git a/main.go b/main.go index b556da8..3298e22 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,9 @@ func main() { budgetH := handler.NewBudgetHandler(budgetSvc) actualsH := handler.NewActualsHandler(actualsRepo) varianceH := handler.NewVarianceHandler(varianceSvc) + reportRepo := database.NewReportRepo(db) + reportSvc := service.NewReportService(reportRepo) + reportH := handler.NewReportHandler(reportSvc) mux := http.NewServeMux() // Reference endpoints @@ -56,20 +59,25 @@ func main() { mux.HandleFunc("GET /api/v1/department/actual", referenceH.ListDepartments) mux.HandleFunc("POST /api/v1/gl-account/create", referenceH.CreateGLAccount) - mux.HandleFunc("DELETE /api/v1/gl-accounts/delete", referenceH.DeleteGLAccount) + mux.HandleFunc("DELETE /api/v1/gl-account/delete", referenceH.DeleteGLAccount) mux.HandleFunc("GET /api/v1/gl-account/list", referenceH.ListGLAccounts) - mux.HandleFunc("GET /api/v1/gl-accounts/bugdet", referenceH.ListDepartments) - mux.HandleFunc("GET /api/v1/gl-accounts/actual", referenceH.ListDepartments) + mux.HandleFunc("GET /api/v1/gl-account/bugdet", referenceH.ListDepartments) + mux.HandleFunc("GET /api/v1/gl-account/actual", referenceH.ListDepartments) // Budget endpoints mux.HandleFunc("POST /api/v1/budget/create", budgetH.Create) - mux.HandleFunc("PUT /api/v1/budgets/update", budgetH.Update) - mux.HandleFunc("DELETE /api/v1/budgets/delete", budgetH.Delete) + mux.HandleFunc("PUT /api/v1/budget/update", budgetH.Update) + mux.HandleFunc("DELETE /api/v1/budget/delete", budgetH.Delete) // Actuals + variance mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest) + mux.HandleFunc("POST /api/v1/actuals/ingest/batch", actualsH.IngestBatch) mux.HandleFunc("GET /api/v1/variance", varianceH.Report) mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts) + mux.HandleFunc("GET /api/v1/variance/reforecast", varianceH.Alerts) + + //reports + mux.HandleFunc("GET /api/v1/reports/pnl", reportH.PnL) mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { if err := db.PingContext(r.Context()); err != nil { diff --git a/tests/actual_test.go b/tests/actual_test.go index c7235d6..1f84590 100644 --- a/tests/actual_test.go +++ b/tests/actual_test.go @@ -8,6 +8,7 @@ import ( "Engine/internal/database" "Engine/internal/handler" + "Engine/internal/model" "Engine/internal/service" "Engine/tests/internal/testutil" ) @@ -39,18 +40,15 @@ func newFullServer(t *testing.T) *httptest.Server { 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", + "dept_code": "TEST", + "gl_code": "TEST", + "fiscal_year": 2024, + "fiscal_period": 1, "amount": 1234.56, + "currency": "USD", "source": "csv_import", } } @@ -58,43 +56,72 @@ func validActual() map[string]any { // ── Actuals: Ingest ─────────────────────────────────────────────────────────── func TestIngestActuals_SingleRecord(t *testing.T) { - h := newActualsHandler(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{validActual()}) + // 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) { - h := newActualsHandler(t) + db := testutil.NewTestDB(t) + testutil.SeedFixtures(t, db) + h := handler.NewActualsHandler(database.NewActualsRepo(db)) 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"}, + 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.Ingest), http.MethodPost, "/", records) + 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) { - h := newActualsHandler(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) { - h := newActualsHandler(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) { - h := newActualsHandler(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}) @@ -102,7 +129,10 @@ func TestIngestActuals_MissingPeriod(t *testing.T) { } func TestIngestActuals_NegativeAmount(t *testing.T) { - h := newActualsHandler(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}) @@ -111,7 +141,10 @@ func TestIngestActuals_NegativeAmount(t *testing.T) { } func TestIngestActuals_Idempotent(t *testing.T) { - h := newActualsHandler(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()}) diff --git a/tests/budget_test.go b/tests/budget_test.go index 96f3289..68ec69c 100644 --- a/tests/budget_test.go +++ b/tests/budget_test.go @@ -26,8 +26,8 @@ func newBudgetServer(t *testing.T) *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("POST /api/v1/budget/create", h.Create) - mux.HandleFunc("PUT /api/v1/budgets/update", h.Update) - mux.HandleFunc("DELETE /api/v1/budgets/delete", h.Delete) + mux.HandleFunc("PUT /api/v1/budget/update", h.Update) + mux.HandleFunc("DELETE /api/v1/budget/delete", h.Delete) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) diff --git a/tests/internal/testutil/testutil.go b/tests/internal/testutil/testutil.go index 8782ae1..85bc5a2 100644 --- a/tests/internal/testutil/testutil.go +++ b/tests/internal/testutil/testutil.go @@ -131,3 +131,38 @@ func DecodeJSON(t *testing.T, w *httptest.ResponseRecorder, dst any) { t.Fatalf("DecodeJSON: %v\n\tbody: %s", err, w.Body.String()) } } + +// SeedFixtures inserts one department and one GL account into db and returns +// their IDs. Call this at the top of any test that needs FK-valid actuals or +// budget rows. +// +// Usage: +// +// deptID, glID := testutil.SeedFixtures(t, db) +func SeedFixtures(t *testing.T, db *sql.DB) (deptID int, glAccountID int) { + t.Helper() + + res, err := db.Exec(` + INSERT INTO departments (code, name, cost_center, active) + VALUES ('TEST', 'Test Department', 'CC-TEST', 1) + ON CONFLICT(code) DO UPDATE SET name = excluded.name + `) + if err != nil { + t.Fatalf("SeedFixtures: insert department: %v", err) + } + id, _ := res.LastInsertId() + deptID = int(id) + + res, err = db.Exec(` + INSERT INTO gl_accounts (code, description, type, favour_high, active) + VALUES ('TEST', 'Test Revenue', 'revenue', 1, 1) + ON CONFLICT(code) DO UPDATE SET description = excluded.description + `) + if err != nil { + t.Fatalf("SeedFixtures: insert gl_account: %v", err) + } + id, _ = res.LastInsertId() + glAccountID = int(id) + + return deptID, glAccountID +}