From c73021a14fe25eb1fe5a53017d7d0b2eaee3afa8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 14:45:25 +0200 Subject: [PATCH] mise en place test Signed-off-by: GnomeZworc --- Makefile | 2 +- cmd/api/integration_test.go | 509 ++++++++++++++++++++++++++++++++++++ 2 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 cmd/api/integration_test.go diff --git a/Makefile b/Makefile index 4e65695..ac753aa 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ run: go run ./cmd/api test: - go test ./... + go test ./cmd/api/ -v -run TestAPI -timeout 30s ## Docker docker-up: diff --git a/cmd/api/integration_test.go b/cmd/api/integration_test.go new file mode 100644 index 0000000..b89d427 --- /dev/null +++ b/cmd/api/integration_test.go @@ -0,0 +1,509 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "git.g3e.fr/H6N/account/internal/server" + "github.com/jackc/pgx/v5/pgxpool" +) + +const testDBURL = "postgres://account:account@localhost:5432/account?sslmode=disable" + +// ts wraps a live httptest.Server + helpers for the integration suite. +type ts struct { + *testing.T + srv *httptest.Server +} + +func (s *ts) do(method, path string, body any) *http.Response { + s.Helper() + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + s.Fatalf("marshal: %v", err) + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, s.srv.URL+path, r) + if err != nil { + s.Fatalf("new request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func (s *ts) decode(resp *http.Response, out any) { + s.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + s.Fatalf("decode: %v", err) + } +} + +func (s *ts) mustStatus(resp *http.Response, want int) { + s.Helper() + if resp.StatusCode != want { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + s.Fatalf("expected HTTP %d, got %d: %s", want, resp.StatusCode, body) + } +} + +func setupSuite(t *testing.T) *ts { + t.Helper() + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + dbURL = testDBURL + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Skipf("cannot connect to test DB: %v", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skipf("test DB unreachable: %v", err) + } + + // Nettoyer les données avant chaque run (ordre FK) + _, err = pool.Exec(ctx, ` + TRUNCATE TABLE transaction, recurring_rule, account_snapshot, + position_snapshot, price_history, instrument_ticker_cache, + account, instrument RESTART IDENTITY CASCADE + `) + if err != nil { + t.Fatalf("truncate: %v", err) + } + // Réinsérer EUR + _, err = pool.Exec(ctx, `INSERT INTO instrument (type, code, name, devise_cotation) VALUES ('devise','EUR','Euro','EUR')`) + if err != nil { + t.Fatalf("seed EUR: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := server.New(nil, pool, logger) + hs := httptest.NewServer(srv) + + t.Cleanup(func() { + hs.Close() + pool.Close() + }) + + return &ts{T: t, srv: hs} +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +func TestAPI(t *testing.T) { + s := setupSuite(t) + + // IDs partagés entre sous-tests + var ( + eurID, etfID int32 + accID, peaID int32 + envID int32 + tx1, tx2 int64 + ) + + // ── Instruments ────────────────────────────────────────────────────────── + + t.Run("GET /instruments retourne EUR", func(t *testing.T) { + resp := s.do("GET", "/instruments", nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 1 || list[0]["code"] != "EUR" { + t.Fatalf("expected [EUR], got %v", list) + } + eurID = int32(list[0]["id"].(float64)) + }) + + t.Run("POST /instruments crée un ETF", func(t *testing.T) { + resp := s.do("POST", "/instruments", map[string]any{ + "type": "etf", "code": "LU1681043599", + "name": "Amundi MSCI World", "devise_cotation": "EUR", + }) + s.mustStatus(resp, http.StatusCreated) + var inst map[string]any + s.decode(resp, &inst) + if inst["code"] != "LU1681043599" { + t.Fatalf("unexpected instrument: %v", inst) + } + etfID = int32(inst["id"].(float64)) + }) + + t.Run("GET /instruments/{id}", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/instruments/%d", etfID), nil) + s.mustStatus(resp, http.StatusOK) + var inst map[string]any + s.decode(resp, &inst) + if inst["name"] != "Amundi MSCI World" { + t.Fatalf("unexpected name: %v", inst["name"]) + } + }) + + t.Run("PUT /instruments/{id}", func(t *testing.T) { + resp := s.do("PUT", fmt.Sprintf("/instruments/%d", etfID), map[string]any{ + "type": "etf", "code": "LU1681043599", + "name": "Amundi MSCI World ETF", "devise_cotation": "EUR", + }) + s.mustStatus(resp, http.StatusOK) + var inst map[string]any + s.decode(resp, &inst) + if inst["name"] != "Amundi MSCI World ETF" { + t.Fatalf("PUT did not update name: %v", inst) + } + }) + + t.Run("GET /instruments/{id} 404", func(t *testing.T) { + resp := s.do("GET", "/instruments/9999", nil) + s.mustStatus(resp, http.StatusNotFound) + resp.Body.Close() + }) + + // ── Accounts ───────────────────────────────────────────────────────────── + + t.Run("POST /accounts crée Livret A", func(t *testing.T) { + resp := s.do("POST", "/accounts", map[string]any{ + "nom": "Livret A", "type": "livret", "plafond": 22950, + }) + s.mustStatus(resp, http.StatusCreated) + var acc map[string]any + s.decode(resp, &acc) + if acc["nom"] != "Livret A" { + t.Fatalf("unexpected account: %v", acc) + } + if acc["devise_reference"] != "EUR" { + t.Fatalf("devise_reference should default to EUR, got %v", acc["devise_reference"]) + } + accID = int32(acc["id"].(float64)) + }) + + t.Run("POST /accounts crée PEA", func(t *testing.T) { + resp := s.do("POST", "/accounts", map[string]any{ + "nom": "PEA", "type": "pea", + }) + s.mustStatus(resp, http.StatusCreated) + var acc map[string]any + s.decode(resp, &acc) + peaID = int32(acc["id"].(float64)) + }) + + t.Run("GET /accounts liste les comptes maîtres", func(t *testing.T) { + resp := s.do("GET", "/accounts", nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(list)) + } + }) + + t.Run("GET /accounts/{id} inclut les enveloppes vides", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d", accID), nil) + s.mustStatus(resp, http.StatusOK) + var acc map[string]any + s.decode(resp, &acc) + if acc["envelopes"] == nil { + t.Fatal("envelopes field missing") + } + }) + + t.Run("PUT /accounts/{id} préserve devise_reference", func(t *testing.T) { + resp := s.do("PUT", fmt.Sprintf("/accounts/%d", accID), map[string]any{ + "nom": "Livret A CIC", "type": "livret", "plafond": 22950, + }) + s.mustStatus(resp, http.StatusOK) + var acc map[string]any + s.decode(resp, &acc) + if acc["devise_reference"] != "EUR" { + t.Fatalf("devise_reference lost after PUT: %v", acc["devise_reference"]) + } + if acc["nom"] != "Livret A CIC" { + t.Fatalf("nom not updated: %v", acc["nom"]) + } + }) + + t.Run("GET /accounts/{id} 404", func(t *testing.T) { + resp := s.do("GET", "/accounts/9999", nil) + s.mustStatus(resp, http.StatusNotFound) + resp.Body.Close() + }) + + // ── Envelopes ───────────────────────────────────────────────────────────── + + t.Run("POST /accounts/{id}/envelopes crée une enveloppe", func(t *testing.T) { + resp := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", accID), map[string]any{ + "nom": "Vacances", "objectif": "Road trip USA", + }) + s.mustStatus(resp, http.StatusCreated) + var env map[string]any + s.decode(resp, &env) + if env["type"] != "livret" { + t.Fatalf("envelope should inherit type from master, got %v", env["type"]) + } + if env["devise_reference"] != "EUR" { + t.Fatalf("envelope should inherit devise, got %v", env["devise_reference"]) + } + if int32(env["master_account_id"].(float64)) != accID { + t.Fatalf("master_account_id mismatch") + } + envID = int32(env["id"].(float64)) + }) + + t.Run("GET /accounts/{id}/envelopes", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d/envelopes", accID), nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 1 { + t.Fatalf("expected 1 envelope, got %d", len(list)) + } + }) + + t.Run("GET /accounts/{id} inclut l'enveloppe", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d", accID), nil) + s.mustStatus(resp, http.StatusOK) + var acc map[string]any + s.decode(resp, &acc) + envs := acc["envelopes"].([]any) + if len(envs) != 1 { + t.Fatalf("expected 1 envelope in account, got %d", len(envs)) + } + }) + + t.Run("PUT /envelopes/{id}", func(t *testing.T) { + resp := s.do("PUT", fmt.Sprintf("/envelopes/%d", envID), map[string]any{ + "nom": "Vacances 2027", "objectif": "Japon", + }) + s.mustStatus(resp, http.StatusOK) + var env map[string]any + s.decode(resp, &env) + if env["objectif"] != "Japon" { + t.Fatalf("objectif not updated: %v", env["objectif"]) + } + }) + + t.Run("POST chaînage d'enveloppe interdit", func(t *testing.T) { + resp := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", envID), map[string]any{ + "nom": "sous-enveloppe", + }) + s.mustStatus(resp, http.StatusBadRequest) + resp.Body.Close() + }) + + // ── Transactions ────────────────────────────────────────────────────────── + + t.Run("POST flux entrant (salaire)", func(t *testing.T) { + resp := s.do("POST", "/transactions", map[string]any{ + "date": "2026-06-01", "label": "Salaire juin", + "tiers": "Employeur", "categorie": "salaire", + "account_dest_id": accID, "instrument_dest_id": eurID, + "quantite_dest": 2800, "validated": true, + }) + s.mustStatus(resp, http.StatusCreated) + var tx map[string]any + s.decode(resp, &tx) + tx1 = int64(tx["id"].(float64)) + }) + + t.Run("POST flux sortant (dépense)", func(t *testing.T) { + resp := s.do("POST", "/transactions", map[string]any{ + "date": "2026-06-05", "label": "Courses", + "categorie": "alimentation", + "account_source_id": accID, "instrument_source_id": eurID, + "quantite_source": 85.50, "validated": true, + }) + s.mustStatus(resp, http.StatusCreated) + var tx map[string]any + s.decode(resp, &tx) + tx2 = int64(tx["id"].(float64)) + }) + + t.Run("POST virement (Livret A → PEA)", func(t *testing.T) { + resp := s.do("POST", "/transactions", map[string]any{ + "date": "2026-06-10", "label": "Versement PEA", + "account_source_id": accID, "instrument_source_id": eurID, "quantite_source": 500, + "account_dest_id": peaID, "instrument_dest_id": etfID, "quantite_dest": 1.35, + "validated": true, + }) + s.mustStatus(resp, http.StatusCreated) + resp.Body.Close() + }) + + t.Run("POST transaction prévisionnelle", func(t *testing.T) { + resp := s.do("POST", "/transactions", map[string]any{ + "date": "2026-05-15", "label": "Loyer mai", + "categorie": "logement", + "account_source_id": accID, "instrument_source_id": eurID, + "quantite_source": 950, "validated": false, + }) + s.mustStatus(resp, http.StatusCreated) + resp.Body.Close() + }) + + t.Run("GET /accounts/{id}/transactions vue signée", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions", accID), nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 4 { + t.Fatalf("expected 4 transactions, got %d", len(list)) + } + // Premier résultat = date la plus récente (virement) + if list[0]["montant"].(float64) != -500 { + t.Fatalf("virement should be -500, got %v", list[0]["montant"]) + } + if list[0]["contrepartie_account_id"] == nil { + t.Fatalf("contrepartie_account_id should be set for virement") + } + // Salaire doit être positif + var salaire map[string]any + for _, tx := range list { + if tx["label"] == "Salaire juin" { + salaire = tx + break + } + } + if salaire == nil || salaire["montant"].(float64) != 2800 { + t.Fatalf("salaire montant should be +2800, got %v", salaire) + } + }) + + t.Run("GET /accounts/{id}/transactions?pending=true", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?pending=true", accID), nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 1 || list[0]["label"] != "Loyer mai" { + t.Fatalf("expected 1 pending transaction (Loyer mai), got %v", list) + } + }) + + t.Run("GET /accounts/{id}/transactions?validated=true", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?validated=true", accID), nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 3 { + t.Fatalf("expected 3 validated transactions, got %d", len(list)) + } + }) + + t.Run("GET /accounts/{id}/transactions?from&to", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/accounts/%d/transactions?from=2026-06-05&to=2026-06-10", accID), nil) + s.mustStatus(resp, http.StatusOK) + var list []map[string]any + s.decode(resp, &list) + if len(list) != 2 { + t.Fatalf("expected 2 transactions in range, got %d", len(list)) + } + }) + + t.Run("GET /transactions/{id} vue brute", func(t *testing.T) { + resp := s.do("GET", fmt.Sprintf("/transactions/%d", tx1), nil) + s.mustStatus(resp, http.StatusOK) + var tx map[string]any + s.decode(resp, &tx) + if tx["quantite_dest"].(float64) != 2800 { + t.Fatalf("unexpected quantite_dest: %v", tx["quantite_dest"]) + } + }) + + t.Run("PUT /transactions/{id}", func(t *testing.T) { + resp := s.do("PUT", fmt.Sprintf("/transactions/%d", tx2), map[string]any{ + "date": "2026-06-05", "label": "Courses Monoprix", + "categorie": "alimentation", + "account_source_id": accID, "instrument_source_id": eurID, + "quantite_source": 92.30, "validated": true, + }) + s.mustStatus(resp, http.StatusOK) + var tx map[string]any + s.decode(resp, &tx) + if tx["quantite_source"].(float64) != 92.30 { + t.Fatalf("PUT did not update quantite_source: %v", tx["quantite_source"]) + } + }) + + t.Run("PATCH /transactions/{id}/validate — valider", func(t *testing.T) { + // Créer une transaction non validée à valider + resp := s.do("POST", "/transactions", map[string]any{ + "date": "2026-06-12", "label": "À valider", + "account_dest_id": accID, "instrument_dest_id": eurID, + "quantite_dest": 100, "validated": false, + }) + s.mustStatus(resp, http.StatusCreated) + var created map[string]any + s.decode(resp, &created) + id := int64(created["id"].(float64)) + + resp2 := s.do("PATCH", fmt.Sprintf("/transactions/%d/validate", id), map[string]any{"validated": true}) + s.mustStatus(resp2, http.StatusOK) + var tx map[string]any + s.decode(resp2, &tx) + if tx["validated"] != true { + t.Fatalf("expected validated=true, got %v", tx["validated"]) + } + }) + + t.Run("PATCH /transactions/{id}/validate — dévalider", func(t *testing.T) { + resp := s.do("PATCH", fmt.Sprintf("/transactions/%d/validate", tx1), map[string]any{"validated": false}) + s.mustStatus(resp, http.StatusOK) + var tx map[string]any + s.decode(resp, &tx) + if tx["validated"] != false { + t.Fatalf("expected validated=false, got %v", tx["validated"]) + } + }) + + t.Run("DELETE /transactions/{id}", func(t *testing.T) { + resp := s.do("DELETE", fmt.Sprintf("/transactions/%d", tx2), nil) + s.mustStatus(resp, http.StatusNoContent) + resp.Body.Close() + + resp2 := s.do("GET", fmt.Sprintf("/transactions/%d", tx2), nil) + s.mustStatus(resp2, http.StatusNotFound) + resp2.Body.Close() + }) + + t.Run("DELETE /accounts/{id} supprime les enveloppes en cascade", func(t *testing.T) { + // Créer un compte temporaire avec une enveloppe + r1 := s.do("POST", "/accounts", map[string]any{"nom": "Temp", "type": "courant"}) + s.mustStatus(r1, http.StatusCreated) + var acc map[string]any + s.decode(r1, &acc) + tmpID := int32(acc["id"].(float64)) + + r2 := s.do("POST", fmt.Sprintf("/accounts/%d/envelopes", tmpID), map[string]any{"nom": "Env Temp"}) + s.mustStatus(r2, http.StatusCreated) + r2.Body.Close() + + r3 := s.do("DELETE", fmt.Sprintf("/accounts/%d", tmpID), nil) + s.mustStatus(r3, http.StatusNoContent) + r3.Body.Close() + + r4 := s.do("GET", fmt.Sprintf("/accounts/%d", tmpID), nil) + s.mustStatus(r4, http.StatusNotFound) + r4.Body.Close() + }) + + // Utiliser les variables pour éviter "declared but not used" + _ = eurID +}