//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(), true) 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, true); 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")) } } // TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and // includes the archived ones under ?all=1 (the active/all toggle). func TestConsoleCatalogActiveToggle(t *testing.T) { srv, _, _ := bannerServer(t) h := srv.Handler() const origin = "http://admin.test" const catalog = "http://admin.test/_gm/catalog" // An active value and an archived one (created without the active flag). if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK { t.Fatal("create active failed") } if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK { t.Fatal("create archived failed") } // Default view: active only. if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") { t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)", strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived")) } // ?all=1: active and archived. if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") { t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)", strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived")) } } // TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the // payout and caps, the page pre-fills the current values, and a negative value is refused. func TestConsoleRewardConfig(t *testing.T) { srv, _, pay := bannerServer(t) h := srv.Handler() const origin = "http://admin.test" const reward = "http://admin.test/_gm/catalog/reward" // Set the payout and caps. if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") { t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved")) } if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 { t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err) } // The catalog page renders the form pre-filled with the current payout. if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) { t.Error("catalog page did not render the reward form with the current payout") } // A negative value is refused (the service validates non-negativity). if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") { t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative")) } }