removal of python, uses golang testing
This commit is contained in:
268
tests/actual_test.go
Normal file
268
tests/actual_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
259
tests/budget_test.go
Normal file
259
tests/budget_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"Engine/internal/database"
|
||||
"Engine/internal/handler"
|
||||
"Engine/internal/model"
|
||||
"Engine/internal/service"
|
||||
"Engine/tests/internal/testutil"
|
||||
)
|
||||
|
||||
// ── wire helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func newBudgetServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
db := testutil.NewTestDB(t)
|
||||
repo := database.NewBudgetRepo(db)
|
||||
h := handler.NewBudgetHandler(service.NewBudgetService(repo))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/budgets", h.Create)
|
||||
mux.HandleFunc("PUT /api/v1/budgets/{id}", h.Update)
|
||||
mux.HandleFunc("DELETE /api/v1/budgets/{id}", h.Delete)
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newBudgetHandler(t *testing.T) *handler.BudgetHandler {
|
||||
t.Helper()
|
||||
return handler.NewBudgetHandler(service.NewBudgetService(database.NewBudgetRepo(testutil.NewTestDB(t))))
|
||||
}
|
||||
|
||||
func validBudget() map[string]any {
|
||||
return map[string]any{
|
||||
"fiscal_year": 2024,
|
||||
"fiscal_period": 1,
|
||||
"version": "original", // adjust to match your BudgetVersion values
|
||||
"department_id": 1,
|
||||
"gl_account_id": 1,
|
||||
"amount": 5000.00,
|
||||
"currency": "USD",
|
||||
"notes": "",
|
||||
"created_by": "test",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateBudget_OK(t *testing.T) {
|
||||
h := newBudgetHandler(t)
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", validBudget())
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got model.Budget
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if got.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if got.Amount != 5000.00 {
|
||||
t.Errorf("amount: got %v, want 5000.00", got.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBudget_InvalidJSON(t *testing.T) {
|
||||
h := newBudgetHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateBudget_MissingPeriod(t *testing.T) {
|
||||
h := newBudgetHandler(t)
|
||||
body := validBudget()
|
||||
delete(body, "fiscal_period")
|
||||
w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", body)
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateBudget_ZeroAmount(t *testing.T) {
|
||||
h := newBudgetHandler(t)
|
||||
body := validBudget()
|
||||
body["amount"] = 0
|
||||
w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", body)
|
||||
// Whether 0 is rejected or accepted depends on your business rule — adjust to match
|
||||
t.Logf("zero amount response: %d — verify against your handler", w.Code)
|
||||
}
|
||||
|
||||
func TestCreateBudget_NegativeAmount(t *testing.T) {
|
||||
h := newBudgetHandler(t)
|
||||
body := validBudget()
|
||||
body["amount"] = -100
|
||||
w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", body)
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateBudget_OK(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
client := srv.Client()
|
||||
|
||||
// Create first
|
||||
resp, err := client.Post(srv.URL+"/api/v1/budgets", "application/json",
|
||||
mustJSON(t, validBudget()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created model.Budget
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
// Update amount
|
||||
updated := validBudget()
|
||||
updated["amount"] = 9999.99
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/budgets/%d", srv.URL, created.ID),
|
||||
mustJSON(t, updated))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("update: got %d, want 200 or 204", resp2.StatusCode)
|
||||
}
|
||||
|
||||
if resp2.StatusCode == http.StatusOK {
|
||||
var got model.Budget
|
||||
json.NewDecoder(resp2.Body).Decode(&got)
|
||||
if got.Amount != 9999.99 {
|
||||
t.Errorf("updated amount: got %v, want 9999.99", got.Amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBudget_NotFound(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/v1/budgets/9999",
|
||||
mustJSON(t, validBudget()))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("update non-existent: got %d, want 404 or 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBudget_InvalidJSON(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
|
||||
// Create one first so the ID exists
|
||||
resp, _ := srv.Client().Post(srv.URL+"/api/v1/budgets", "application/json",
|
||||
mustJSON(t, validBudget()))
|
||||
var created model.Budget
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/budgets/%d", srv.URL, created.ID),
|
||||
bytes.NewBufferString("not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("invalid JSON update: got %d, want 400", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteBudget_OK(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
client := srv.Client()
|
||||
|
||||
resp, err := client.Post(srv.URL+"/api/v1/budgets", "application/json",
|
||||
mustJSON(t, validBudget()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created model.Budget
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete,
|
||||
srv.URL+"/api/v1/budgets/"+strconv.Itoa(created.ID), nil)
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusNoContent && resp2.StatusCode != http.StatusOK {
|
||||
t.Errorf("delete: got %d, want 200 or 204", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBudget_NotFound(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/budgets/9999", nil)
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete non-existent: got %d, want 404 or 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBudget_DoubleDelete(t *testing.T) {
|
||||
srv := newBudgetServer(t)
|
||||
client := srv.Client()
|
||||
|
||||
resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json",
|
||||
mustJSON(t, validBudget()))
|
||||
var created model.Budget
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
url := srv.URL + "/api/v1/budgets/" + strconv.Itoa(created.ID)
|
||||
|
||||
req1, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
resp1, _ := client.Do(req1)
|
||||
resp1.Body.Close()
|
||||
|
||||
// Second delete — should not panic, should return 404 or 204
|
||||
req2, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusNotFound && resp2.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("double delete: got %d, want 404 or 204", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
133
tests/internal/testutil/testutil.go
Normal file
133
tests/internal/testutil/testutil.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package testutil
|
||||
|
||||
// Package testutil provides test helpers for handler and integration tests.
|
||||
// It wires up an in-memory SQLite database, a lightweight HTTP request helper,
|
||||
// and common assertion utilities.
|
||||
|
||||
import (
|
||||
"Engine/internal/database"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver, no CGO required
|
||||
)
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// NewTestDB opens a fresh in-memory SQLite database, runs your schema
|
||||
// migrations, and registers a t.Cleanup to close it when the test ends.
|
||||
//
|
||||
// Each call gets its own isolated database — tests never share state.
|
||||
func NewTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
// "file::memory:?cache=shared" would share across connections;
|
||||
// the plain ":memory:" gives a fully isolated DB per open call.
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("testutil.NewTestDB: open: %v", err)
|
||||
}
|
||||
|
||||
// SQLite in-memory works best with a single connection.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("testutil.NewTestDB: migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Do fires an HTTP request directly at handler h and returns the recorded
|
||||
// response. body is marshalled to JSON; pass nil to send no body.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", payload)
|
||||
func Do(t *testing.T, h http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("testutil.Do: marshal body: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// MustJSON marshals v to JSON and returns it as an io.Reader.
|
||||
// Intended for use with http.Client.Post in full-server integration tests.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json", testutil.MustJSON(t, payload))
|
||||
func MustJSON(t *testing.T, v any) io.Reader {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("testutil.MustJSON: %v", err)
|
||||
}
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
// ── Assertions ────────────────────────────────────────────────────────────────
|
||||
|
||||
// AssertStatus fails the test if the recorder's status code does not match want.
|
||||
func AssertStatus(t *testing.T, w *httptest.ResponseRecorder, want int) {
|
||||
t.Helper()
|
||||
if w.Code != want {
|
||||
t.Errorf("status: got %d, want %d\n\tbody: %s", w.Code, want, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// AssertJSONField decodes the recorder's body as a JSON object and checks that
|
||||
// the top-level key equals the expected string value. Useful for quick smoke
|
||||
// checks without defining a full response struct.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// testutil.AssertJSONField(t, w, "status", "ok")
|
||||
func AssertJSONField(t *testing.T, w *httptest.ResponseRecorder, key, want string) {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&m); err != nil {
|
||||
t.Fatalf("AssertJSONField: decode body: %v", err)
|
||||
}
|
||||
got, ok := m[key]
|
||||
if !ok {
|
||||
t.Errorf("AssertJSONField: key %q not found in response", key)
|
||||
return
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("AssertJSONField: %q = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeJSON decodes the recorder's body into dst.
|
||||
// Fails the test immediately if decoding errors.
|
||||
func DecodeJSON(t *testing.T, w *httptest.ResponseRecorder, dst any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(w.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("DecodeJSON: %v\n\tbody: %s", err, w.Body.String())
|
||||
}
|
||||
}
|
||||
334
tests/refrence_test.go
Normal file
334
tests/refrence_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"Engine/internal/database"
|
||||
"Engine/internal/handler"
|
||||
"Engine/internal/model"
|
||||
"Engine/tests/internal/testutil"
|
||||
)
|
||||
|
||||
// ── wire helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func newReferenceHandler(t *testing.T) *handler.ReferenceHandler {
|
||||
t.Helper()
|
||||
return handler.NewReferenceHandler(database.NewReferenceRepo(testutil.NewTestDB(t)))
|
||||
}
|
||||
|
||||
// newReferenceServer spins up a real httptest.Server with the production
|
||||
// mux routes. Use this for delete/path-param tests that need {id} routing.
|
||||
func newReferenceServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
h := newReferenceHandler(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/departments", h.CreateDepartment)
|
||||
mux.HandleFunc("GET /api/v1/departments", h.ListDepartments)
|
||||
mux.HandleFunc("DELETE /api/v1/departments/{id}", h.DeleteDepartment)
|
||||
mux.HandleFunc("POST /api/v1/gl-accounts", h.CreateGLAccount)
|
||||
mux.HandleFunc("GET /api/v1/gl-accounts", h.ListGLAccounts)
|
||||
mux.HandleFunc("DELETE /api/v1/gl-accounts/{id}", h.DeleteGLAccount)
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ── Department: Create ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateDepartment_OK(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/",
|
||||
map[string]any{"code": "ENG", "name": "Engineering", "active": true})
|
||||
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got database.Department
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if got.Code != "ENG" {
|
||||
t.Errorf("code: got %q, want %q", got.Code, "ENG")
|
||||
}
|
||||
if got.ID == 0 {
|
||||
t.Error("expected non-zero ID in response")
|
||||
}
|
||||
if !got.Active {
|
||||
t.Error("expected active=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDepartment_DefaultsActiveTrue(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
|
||||
// active field omitted — handler must default it to true
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/",
|
||||
map[string]any{"code": "MKT", "name": "Marketing"})
|
||||
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got database.Department
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if !got.Active {
|
||||
t.Error("expected active to default to true when omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDepartment_MissingCode(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/",
|
||||
map[string]any{"name": "Engineering"})
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateDepartment_MissingName(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/",
|
||||
map[string]any{"code": "ENG"})
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateDepartment_WhitespaceOnly(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/",
|
||||
map[string]any{"code": " ", "name": " "})
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateDepartment_InvalidJSON(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
// nil body → empty body → JSON decode fails → 400
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateDepartment), http.MethodPost, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateDepartment_Upsert(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
fn := http.HandlerFunc(h.CreateDepartment)
|
||||
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "FIN", "name": "Finance"})
|
||||
|
||||
w := testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "FIN", "name": "Finance Updated"})
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got database.Department
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if got.Name != "Finance Updated" {
|
||||
t.Errorf("upsert name: got %q, want %q", got.Name, "Finance Updated")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Department: List ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestListDepartments_Empty(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.ListDepartments), http.MethodGet, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusOK)
|
||||
|
||||
var got []database.Department
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDepartments_ReturnAll(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
fn := http.HandlerFunc(h.CreateDepartment)
|
||||
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "HR", "name": "Human Resources"})
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "IT", "name": "Information Technology"})
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "OPS", "name": "Operations"})
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.ListDepartments), http.MethodGet, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusOK)
|
||||
|
||||
var got []database.Department
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("expected 3 departments, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Department: Delete ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteDepartment_OK(t *testing.T) {
|
||||
srv := newReferenceServer(t)
|
||||
client := srv.Client()
|
||||
|
||||
resp, err := client.Post(srv.URL+"/api/v1/departments", "application/json",
|
||||
mustJSON(t, map[string]any{"code": "OPS", "name": "Operations"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created database.Department
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/departments/"+strconv.Itoa(created.ID), nil)
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusNoContent && resp2.StatusCode != http.StatusOK {
|
||||
t.Errorf("delete: got %d, want 200 or 204", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDepartment_NotFound(t *testing.T) {
|
||||
srv := newReferenceServer(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/departments/9999", nil)
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Accept 404 or 204 — adjust to match your handler's behaviour
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete non-existent: got %d, want 404 or 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL Account: Create ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateGLAccount_OK(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateGLAccount), http.MethodPost, "/",
|
||||
map[string]any{"code": "5001", "name": "Travel Expenses", "type": "expense"})
|
||||
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got database.GLAccount
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if got.Code != "5001" {
|
||||
t.Errorf("code: got %q, want %q", got.Code, "5001")
|
||||
}
|
||||
if got.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGLAccount_MissingCode(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateGLAccount), http.MethodPost, "/",
|
||||
map[string]any{"name": "Travel Expenses"})
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateGLAccount_MissingName(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.CreateGLAccount), http.MethodPost, "/",
|
||||
map[string]any{"code": "5001"})
|
||||
testutil.AssertStatus(t, w, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestCreateGLAccount_Upsert(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
fn := http.HandlerFunc(h.CreateGLAccount)
|
||||
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "4001", "name": "Revenue"})
|
||||
|
||||
w := testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "4001", "name": "Revenue Updated"})
|
||||
testutil.AssertStatus(t, w, http.StatusCreated)
|
||||
|
||||
var got model.GLAccount
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if got.Code != "Revenue Updated" {
|
||||
t.Errorf("upsert name: got %q, want %q", got.Code, "Revenue Updated")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL Account: List ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestListGLAccounts_Empty(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
w := testutil.Do(t, http.HandlerFunc(h.ListGLAccounts), http.MethodGet, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusOK)
|
||||
|
||||
var got []database.GLAccount
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGLAccounts_ReturnAll(t *testing.T) {
|
||||
h := newReferenceHandler(t)
|
||||
fn := http.HandlerFunc(h.CreateGLAccount)
|
||||
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "4001", "name": "Revenue"})
|
||||
testutil.Do(t, fn, http.MethodPost, "/", map[string]any{"code": "5001", "name": "COGS"})
|
||||
|
||||
w := testutil.Do(t, http.HandlerFunc(h.ListGLAccounts), http.MethodGet, "/", nil)
|
||||
testutil.AssertStatus(t, w, http.StatusOK)
|
||||
|
||||
var got []database.GLAccount
|
||||
testutil.DecodeJSON(t, w, &got)
|
||||
if len(got) != 2 {
|
||||
t.Errorf("expected 2 GL accounts, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL Account: Delete ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteGLAccount_OK(t *testing.T) {
|
||||
srv := newReferenceServer(t)
|
||||
client := srv.Client()
|
||||
|
||||
resp, err := client.Post(srv.URL+"/api/v1/gl-accounts", "application/json",
|
||||
mustJSON(t, map[string]any{"code": "6001", "name": "Rent"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var created database.GLAccount
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/gl-accounts/"+strconv.Itoa(created.ID), nil)
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusNoContent && resp2.StatusCode != http.StatusOK {
|
||||
t.Errorf("delete: got %d, want 200 or 204", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGLAccount_NotFound(t *testing.T) {
|
||||
srv := newReferenceServer(t)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/v1/gl-accounts/9999", nil)
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete non-existent: got %d, want 404 or 204", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ── local helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func mustJSON(t *testing.T, v any) *bytes.Reader {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
Reference in New Issue
Block a user