account/cmd/api/integration_test.go
GnomeZworc 0f2d7126eb
ajoute snapshot
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-13 22:52:13 +02:00

565 lines
18 KiB
Go

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()
})
// ── 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
}