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 ────────────────────────────────────────────────────────────── func newBudgetServer(t *testing.T) (*httptest.Server, int, int) { t.Helper() db := testutil.NewTestDB(t) deptID, glID := testutil.SeedFixtures(t, db) repo := database.NewBudgetRepo(db) h := handler.NewBudgetHandler(service.NewBudgetService(repo)) mux := http.NewServeMux() mux.HandleFunc("POST /api/v1/budget/create", h.Create) 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) return srv, deptID, glID } func newBudgetHandler(t *testing.T) (*handler.BudgetHandler, int, int) { t.Helper() db := testutil.NewTestDB(t) deptID, glID := testutil.SeedFixtures(t, db) return handler.NewBudgetHandler(service.NewBudgetService(database.NewBudgetRepo(db))), deptID, glID } func validBudget(deptID, glID int) map[string]any { return map[string]any{ "fiscal_year": 2024, "fiscal_period": 1, "version": "original", "department_id": deptID, "gl_account_id": glID, "amount": 5000.00, "currency": "DKK", "notes": "", "created_by": "test", } } // ── Create ──────────────────────────────────────────────────────────────────── func TestCreateBudget_OK(t *testing.T) { h, deptID, glID := newBudgetHandler(t) w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", validBudget(deptID, glID)) 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, deptID, glID := newBudgetHandler(t) body := validBudget(deptID, glID) delete(body, "fiscal_period") w := testutil.Do(t, http.HandlerFunc(h.Create), http.MethodPost, "/", body) testutil.AssertStatus(t, w, http.StatusBadRequest) } func TestCreateBudget_NegativeAmount(t *testing.T) { h, deptID, glID := newBudgetHandler(t) body := validBudget(deptID, glID) 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, deptID, glID := newBudgetServer(t) client := srv.Client() resp, err := client.Post( srv.URL+"/api/v1/budget/create", "application/json", mustJSON(t, validBudget(deptID, glID)), ) if err != nil { t.Fatal(err) } defer resp.Body.Close() var created model.Budget if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { t.Fatalf("decode created budget: %v", err) } if created.ID == 0 { t.Fatal("expected a non-zero ID from create") } // Update only the fields the endpoint is meant to change wantAmount := 9999.99 wantNotes := "updated in test" updateReq := model.UpdateBudgetRequest{ ID: created.ID, Amount: &wantAmount, Notes: &wantNotes, ChangedBy: created.CreatedBy, } req, err := http.NewRequest( http.MethodPut, srv.URL+"/api/v1/budget/update", mustJSON(t, updateReq), ) if err != nil { t.Fatal(err) } 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 { t.Fatalf("update: got %d, want 200", resp2.StatusCode) } var got model.Budget if err := json.NewDecoder(resp2.Body).Decode(&got); err != nil { t.Fatalf("decode updated budget: %v", err) } if got.Amount != wantAmount { t.Errorf("amount: got %v, want %v", got.Amount, wantAmount) } if got.Notes != wantNotes { t.Errorf("notes: got %q, want %q", got.Notes, wantNotes) } if got.ID != created.ID { t.Errorf("ID changed after update: got %v, want %v", got.ID, created.ID) } // Fields not included in the update should be unchanged if got.FiscalYear != created.FiscalYear { t.Errorf("fiscal_year changed unexpectedly: got %v, want %v", got.FiscalYear, created.FiscalYear) } if got.DepartmentID != created.DepartmentID { t.Errorf("department_id changed unexpectedly: got %v, want %v", got.DepartmentID, created.DepartmentID) } } func TestUpdateBudget_BadRequest(t *testing.T) { srv, _, _ := newBudgetServer(t) // bad request... wantNotes := "updated in test" updateReq := model.UpdateBudgetRequest{ Notes: &wantNotes, ChangedBy: "idk", } req, err := http.NewRequest( http.MethodPut, srv.URL+"/api/v1/budget/update", mustJSON(t, updateReq), ) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusNoContent { t.Errorf("update non-existent: got %d, want 400 or 204", resp.StatusCode) } } // ── Delete ──────────────────────────────────────────────────────────────────── func TestDeleteBudget_OK(t *testing.T) { srv, deptID, glID := newBudgetServer(t) client := srv.Client() resp, err := client.Post( srv.URL+"/api/v1/budget/create", "application/json", mustJSON(t, validBudget(deptID, glID)), ) if err != nil { t.Fatal(err) } var created model.Budget json.NewDecoder(resp.Body).Decode(&created) resp.Body.Close() if created.ID == 0 { t.Fatal("expected non-zero ID from create") } req, err := http.NewRequest( http.MethodDelete, srv.URL+"/api/v1/budget/delete", mustJSON(t, model.DeleteBudgetRequest{ID: created.ID}), ) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") 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/budget/delete", mustJSON(t, model.DeleteBudgetRequest{ID: 9999}), ) 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("delete non-existent: got %d, want 404 or 204", resp.StatusCode) } } func TestDeleteBudget_DoubleDelete(t *testing.T) { srv, deptID, glID := newBudgetServer(t) client := srv.Client() resp, err := client.Post( srv.URL+"/api/v1/budget/create", "application/json", mustJSON(t, validBudget(deptID, glID)), ) if err != nil { t.Fatal(err) } var created model.Budget json.NewDecoder(resp.Body).Decode(&created) resp.Body.Close() if created.ID == 0 { t.Fatal("expected non-zero ID from create") } deleteBody := model.DeleteBudgetRequest{ID: created.ID} req1, _ := http.NewRequest( http.MethodDelete, srv.URL+"/api/v1/budget/delete", mustJSON(t, deleteBody), ) req1.Header.Set("Content-Type", "application/json") resp1, _ := client.Do(req1) resp1.Body.Close() // Second delete — should not panic, should return 404 or 204 req2, err := http.NewRequest( http.MethodDelete, srv.URL+"/api/v1/budget/delete", mustJSON(t, deleteBody), ) if err != nil { t.Fatal(err) } req2.Header.Set("Content-Type", "application/json") 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) } }