mise en place test
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
33682eafb6
commit
c73021a14f
2 changed files with 510 additions and 1 deletions
2
Makefile
2
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:
|
||||
|
|
|
|||
509
cmd/api/integration_test.go
Normal file
509
cmd/api/integration_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue