0036b55618
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The /_gm console gains a Catalog editor — the source of truth for products. Create / edit / archive-unarchive products, their atom composition and per-rail prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and hard-delete only a never-transacted product (an order or ledger reference forces archive-only, backed by the FK RESTRICT). The archived flag reuses the existing product.active. Activation revalidates the sellable shape — a pack (the chips atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price); a tournament-bearing product is composable but never sellable yet. Backed by payments AdminCatalog / CreateProduct / UpdateProduct / SetProductActive / DeleteProduct + a pure validateProduct. Tests: validateProduct (pack / value / tournament / duplicate / shape); the console editor end to end (create, edit, archive, delete-if-clean, refuse a transacted delete).
98 lines
4.0 KiB
Go
98 lines
4.0 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
|
t.Helper()
|
|
all, err := pay.AdminCatalog(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("admin catalog: %v", err)
|
|
}
|
|
for _, p := range all {
|
|
if p.Title == title {
|
|
return p
|
|
}
|
|
}
|
|
t.Fatalf("product %q not found", title)
|
|
return payments.AdminProduct{}
|
|
}
|
|
|
|
// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
|
|
// a pack are created and edited; an invalid active product is refused; archive hides it; a
|
|
// never-transacted product deletes; a transacted product is refused deletion (archive only).
|
|
func TestConsoleCatalogEditor(t *testing.T) {
|
|
ctx := context.Background()
|
|
srv, _, pay := bannerServer(t)
|
|
h := srv.Handler()
|
|
const origin = "http://admin.test"
|
|
const catalog = "http://admin.test/_gm/catalog"
|
|
|
|
// CSRF: a create without the origin header is refused.
|
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
|
|
t.Fatalf("create without origin = %d, want 403", code)
|
|
}
|
|
|
|
// Create a value product: 5 hints for 50 chips, active.
|
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
|
t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
|
|
}
|
|
val := productByTitle(t, pay, "cat-hints-5")
|
|
if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
|
|
t.Fatalf("created value = %+v", val)
|
|
}
|
|
|
|
// An invalid active pack (chips, no money price) is refused.
|
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
|
}
|
|
if _, err := pay.AdminCatalog(ctx); err != nil {
|
|
t.Fatalf("catalog: %v", err)
|
|
}
|
|
|
|
// Edit the value: raise to 8 hints.
|
|
base := catalog + "/" + val.ID.String()
|
|
if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
|
|
t.Fatalf("edit = %d", code)
|
|
}
|
|
if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
|
|
t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
|
|
}
|
|
|
|
// Archive it → hidden from the storefront (the user Catalog filters active).
|
|
if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
|
|
t.Fatalf("archive = %d", code)
|
|
}
|
|
if productByTitle(t, pay, "cat-hints-8").Active {
|
|
t.Error("product still active after archive")
|
|
}
|
|
|
|
// Delete the never-transacted product.
|
|
if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
|
|
t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
|
|
}
|
|
|
|
// A transacted product cannot be deleted — only archived.
|
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
|
|
t.Fatal("create pack failed")
|
|
}
|
|
pack := productByTitle(t, pay, "cat-pack")
|
|
if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
|
|
t.Fatalf("create order: %v", err)
|
|
}
|
|
if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
|
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
|
}
|
|
}
|