b6c2598710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with its price. Move /offer/ off the static landing container to the render sidecar: it splices the live catalog price list (§4.4) into the owner-edited ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one renderer, no drift, always matching the current catalog with no redeploy. - backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list) projects the active catalog into two markdown tables — chip packs priced per rail (roubles / VK votes / Telegram Stars) and chip-priced values — through payments.Money so no float reaches the page. Cached in memory: warmed at boot, marked stale on every catalog mutation, so a served render issues no query. - renderer: GET /offer/ fetches the tables and substitutes them at the <#pricing_template#> marker, then renders; offer_ru.md is baked into the image and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed. - caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/ handlers and the vite emit-offer plugin. - offer: fill §4.3 (the chip-payment wording) and drop the in-page back link. - landing footer: a feedback link (the offer's Telegram contact) beside the offer link. - docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe, unit + integration + node tests.
156 lines
5.2 KiB
Go
156 lines
5.2 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// methodPrice is one per-method money price for a seeded chip pack.
|
|
type methodPrice struct {
|
|
method string
|
|
currency string
|
|
amount int64
|
|
}
|
|
|
|
// seedPackProduct creates an active chip pack: a product carrying the chips atom and a money price
|
|
// per method. It returns the product id.
|
|
func seedPackProduct(t *testing.T, chips int, prices ...methodPrice) uuid.UUID {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
prod := uuid.New()
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test pack',true)`, prod); err != nil {
|
|
t.Fatalf("seed pack product: %v", err)
|
|
}
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'chips',$2)`, prod, chips); err != nil {
|
|
t.Fatalf("seed chips item: %v", err)
|
|
}
|
|
for _, p := range prices {
|
|
if _, err := testDB.ExecContext(ctx,
|
|
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
|
|
prod, p.method, p.currency, p.amount); err != nil {
|
|
t.Fatalf("seed pack price %s: %v", p.method, err)
|
|
}
|
|
}
|
|
return prod
|
|
}
|
|
|
|
// findCatalogProduct returns the projected storefront product for id in the context (the catalog
|
|
// is global, so tests assert by id rather than count — testDB is shared).
|
|
func findCatalogProduct(t *testing.T, svc *payments.Service, cxt payments.Context, id uuid.UUID) (payments.CatalogProduct, bool) {
|
|
t.Helper()
|
|
view, err := svc.Catalog(context.Background(), cxt)
|
|
if err != nil {
|
|
t.Fatalf("catalog: %v", err)
|
|
}
|
|
for _, p := range view.Products {
|
|
if p.ProductID == id.String() {
|
|
return p, true
|
|
}
|
|
}
|
|
return payments.CatalogProduct{}, false
|
|
}
|
|
|
|
// TestPaymentsCatalogByContext verifies the storefront projects a value at its chip price in every
|
|
// context and a chip pack at the context method's money price, over Postgres.
|
|
func TestPaymentsCatalogByContext(t *testing.T) {
|
|
svc := newPaymentsService()
|
|
value := seedValueProduct(t, 500, 250, 0)
|
|
pack := seedPackProduct(t, 100,
|
|
methodPrice{"vk", "VOTE", 20},
|
|
methodPrice{"telegram", "XTR", 25},
|
|
methodPrice{"direct", "RUB", 14900},
|
|
)
|
|
|
|
for _, kind := range []string{"vk", "telegram", "direct"} {
|
|
v, ok := findCatalogProduct(t, svc, payments.NewContext(kind, "web"), value)
|
|
if !ok {
|
|
t.Fatalf("value missing in %s context", kind)
|
|
}
|
|
if v.Kind != "value" || v.Chips != 500 || v.MoneyCurrency != "" {
|
|
t.Errorf("value in %s = %+v, want kind=value chips=500 no money", kind, v)
|
|
}
|
|
}
|
|
|
|
cases := []struct {
|
|
kind string
|
|
amount int64
|
|
currency string
|
|
}{
|
|
{"vk", 20, "VOTE"},
|
|
{"telegram", 25, "XTR"},
|
|
{"direct", 14900, "RUB"},
|
|
}
|
|
for _, tc := range cases {
|
|
p, ok := findCatalogProduct(t, svc, payments.NewContext(tc.kind, "web"), pack)
|
|
if !ok {
|
|
t.Fatalf("pack missing in %s context", tc.kind)
|
|
}
|
|
if p.Kind != "pack" || p.Chips != 0 || p.MoneyAmount != tc.amount || p.MoneyCurrency != tc.currency {
|
|
t.Errorf("pack in %s = %+v, want kind=pack amount=%d currency=%s", tc.kind, p, tc.amount, tc.currency)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPaymentsCatalogExcludesDeactivated verifies a soft-deleted product drops out of the storefront.
|
|
func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
|
svc := newPaymentsService()
|
|
prod := seedValueProduct(t, 100, 10, 0)
|
|
if _, err := testDB.ExecContext(context.Background(),
|
|
`UPDATE payments.product SET active=false WHERE product_id=$1`, prod); err != nil {
|
|
t.Fatalf("deactivate: %v", err)
|
|
}
|
|
if _, ok := findCatalogProduct(t, svc, payments.NewContext("direct", "web"), prod); ok {
|
|
t.Error("deactivated product must not appear in the storefront")
|
|
}
|
|
}
|
|
|
|
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
|
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
|
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
|
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
|
svc := newPaymentsService()
|
|
ctx := context.Background()
|
|
title := "OfferTest " + uuid.NewString()
|
|
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
|
Title: title,
|
|
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
|
Prices: []payments.PriceLine{
|
|
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
|
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
|
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
|
},
|
|
}, true)
|
|
if err != nil {
|
|
t.Fatalf("create pack: %v", err)
|
|
}
|
|
|
|
md, err := svc.OfferPricing(ctx)
|
|
if err != nil {
|
|
t.Fatalf("offer pricing: %v", err)
|
|
}
|
|
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
|
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
|
}
|
|
|
|
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
|
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
md, err = svc.OfferPricing(ctx)
|
|
if err != nil {
|
|
t.Fatalf("offer pricing after archive: %v", err)
|
|
}
|
|
if strings.Contains(md, title) {
|
|
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
|
}
|
|
}
|