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) } req.Header.Set("X-Owner-ID", "1") 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() }) // ── Snapshots ───────────────────────────────────────────────────────────── t.Run("POST transaction invalide le snapshot du compte", func(t *testing.T) { // La transaction salaire (tx1) a déjà été créée pour accID. // On vérifie qu'une invalidation existe pour ce compte. resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil) // tx1 a une date passée → invalidation présente → 200 avec from/to s.mustStatus(resp, http.StatusOK) var result map[string]any s.decode(resp, &result) if result["status"] != "ok" { t.Fatalf("expected status ok, got %v", result) } if result["account_id"].(float64) != float64(accID) { t.Fatalf("unexpected account_id: %v", result["account_id"]) } }) t.Run("POST /accounts/{id}/snapshots/recompute — aucune invalidation → 204", func(t *testing.T) { // Le recompute précédent a effacé l'invalidation. resp := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil) s.mustStatus(resp, http.StatusNoContent) resp.Body.Close() }) t.Run("Modifier une transaction re-invalide le compte", func(t *testing.T) { // Créer une nouvelle transaction pour déclencher une invalidation. r1 := s.do("POST", "/transactions", map[string]any{ "date": "2026-05-01", "label": "Test invalidation", "account_dest_id": accID, "instrument_dest_id": eurID, "quantite_dest": 50, "validated": true, }) s.mustStatus(r1, http.StatusCreated) r1.Body.Close() // L'invalidation est présente → recompute retourne 200. r2 := s.do("POST", fmt.Sprintf("/accounts/%d/snapshots/recompute", accID), nil) s.mustStatus(r2, http.StatusOK) var result map[string]any s.decode(r2, &result) if result["status"] != "ok" { t.Fatalf("expected status ok, got %v", result) } }) t.Run("POST /accounts/{id}/snapshots/recompute — compte inexistant → 404", func(t *testing.T) { resp := s.do("POST", "/accounts/9999/snapshots/recompute", nil) // Compte inexistant : GetInvalidation retourne pgx.ErrNoRows → 404 // (en pratique le compte n'existe pas, l'invalidation non plus → 204) // On accepte 204 ou 404 if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 204 or 404 for unknown account, got %d", resp.StatusCode) } resp.Body.Close() }) // Utiliser les variables pour éviter "declared but not used" _ = eurID }