Files
member-console/internal/server/operator_billing_environment_db_test.go
T

536 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// The operator billing views' Stripe environment filter
// (stripe-environment-stamp D7, task 6.8): each of the four views shows
// only the rows whose mapping records the environment the API key is in,
// or none; the absence line names what it is holding back and carries the
// switch; env=all shows everything with a badge on the other
// environment's rows and survives a search, a facet change and a page;
// and the member's own invoices are filtered with no switch at all.
//
// DB-backed via TEST_DATABASE_URL. The shared database accumulates rows
// across runs, so every assertion is scoped to the fixture's own marker.
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alexedwards/scs/v2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
)
// billingEnvEnv is one org whose billing rows are split across the two
// Stripe environments, plus an operator session and the handler under
// test wired to a chosen key mode.
type billingEnvEnv struct {
t *testing.T
database *sql.DB
mux *http.ServeMux
operator context.Context
member context.Context
marker string
orgID string
accountID string
}
func newBillingEnvEnv(t *testing.T, keyMode string) *billingEnvEnv {
t.Helper()
database := testDB(t)
ctx := context.Background()
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
Database: database,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
AuthConfig: authCfg,
BillingQ: billing.New(database),
EntitlementsQ: entitlements.New(database),
OrgQ: organization.New(database),
StripeQ: stripedb.New(database),
StripeConfigured: true,
StripeMode: keyMode,
})
if err != nil {
t.Fatalf("NewOperatorPartialsHandler: %v", err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
opCtx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(opCtx, "authenticated", true)
sm.Put(opCtx, "roles", []string{server.OperatorRole})
marker := fmt.Sprintf("be%d", time.Now().UnixNano())
scan := func(query string, args ...any) string {
var id string
if err := database.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+marker)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Billing Env "+marker, marker+"@example.test")
orgType := "be" + marker[len(marker)-12:]
if _, err := database.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name) VALUES ($1, $1)`, orgType); err != nil {
t.Fatalf("fixture org type: %v", err)
}
orgID := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
"BillingEnvOrg "+marker, orgType, personID)
accountID := scan(`INSERT INTO core.accounts (org_id, name, status) VALUES ($1,$2,'active') RETURNING billing_account_id`,
orgID, "BillingEnvAccount "+marker)
memberCtx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(memberCtx, "authenticated", true)
sm.Put(memberCtx, "org_id", orgID)
return &billingEnvEnv{
t: t, database: database, mux: mux,
operator: opCtx, member: memberCtx,
marker: marker, orgID: orgID, accountID: accountID,
}
}
func (e *billingEnvEnv) exec(query string, args ...any) {
e.t.Helper()
if _, err := e.database.ExecContext(context.Background(), query, args...); err != nil {
e.t.Fatalf("fixture %q: %v", query, err)
}
}
func (e *billingEnvEnv) scan(query string, args ...any) string {
e.t.Helper()
var id string
if err := e.database.QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
e.t.Fatalf("fixture %q: %v", query, err)
}
return id
}
// seedInvoice writes one invoice and its mapping with the given stamp.
// livemode nil leaves the column NULL, the unverified state.
func (e *billingEnvEnv) seedInvoice(number string, livemode *bool) string {
e.t.Helper()
id := e.scan(
`INSERT INTO core.invoices (billing_account_id, status, amount_due, amount_paid, currency, invoice_number)
VALUES ($1,'open',1000,0,'usd',$2) RETURNING invoice_id`,
e.accountID, number)
e.exec(`INSERT INTO stripe.invoice_mappings (invoice_id, stripe_invoice_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "in_"+id, nullBool(livemode))
return id
}
func (e *billingEnvEnv) seedPayment(invoiceID string, livemode *bool) {
e.t.Helper()
id := e.scan(
`INSERT INTO core.payments (billing_account_id, invoice_id, amount, currency, status)
VALUES ($1,$2,1000,'usd','succeeded') RETURNING payment_id`,
e.accountID, invoiceID)
e.exec(`INSERT INTO stripe.payment_mappings (payment_id, stripe_payment_intent_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "pi_"+id, nullBool(livemode))
}
func (e *billingEnvEnv) seedSubscription(livemode *bool) {
e.t.Helper()
id := e.scan(
`INSERT INTO core.subscriptions (billing_account_id, status) VALUES ($1,'active') RETURNING subscription_id`,
e.accountID)
e.exec(`INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, id, "sub_"+id, nullBool(livemode))
}
// seedAccountMapping stamps the fixture's own billing account, the row the
// accounts view filters on.
func (e *billingEnvEnv) seedAccountMapping(livemode *bool) {
e.t.Helper()
e.exec(`INSERT INTO stripe.customer_mappings (billing_account_id, stripe_customer_id, sync_status, livemode)
VALUES ($1,$2,'synced',$3)`, e.accountID, "cus_"+e.accountID, nullBool(livemode))
}
func (e *billingEnvEnv) get(ctx context.Context, target string) string {
e.t.Helper()
req := httptest.NewRequestWithContext(ctx, http.MethodGet, target, nil)
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
e.t.Fatalf("GET %s: status %d", target, rec.Code)
}
return rec.Body.String()
}
// TestBillingViewsFilterToTheKeyEnvironment pins the operator-billing-views
// scenarios "A billing view shows the key's environment", "An unverified
// row shows under either key", "The absence line names what is not shown
// and switches" and "The all state names itself and badges the other
// environment".
func TestBillingViewsFilterToTheKeyEnvironment(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
liveInvoice := e.seedInvoice("LIVE-"+e.marker, &live)
testInvoice := e.seedInvoice("TEST-"+e.marker, &test)
e.seedInvoice("NULL-"+e.marker, nil)
e.seedPayment(liveInvoice, &live)
e.seedPayment(testInvoice, &test)
e.seedSubscription(&live)
e.seedSubscription(&test)
e.seedAccountMapping(&test)
t.Run("invoices hide the other environment and count what is hidden", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker)
if !strings.Contains(out, "LIVE-"+e.marker) {
t.Error("the live invoice must render under a live key")
}
if !strings.Contains(out, "NULL-"+e.marker) {
t.Error("an unverified invoice must render under either key")
}
if strings.Contains(out, "TEST-"+e.marker) {
t.Error("the test invoice must not render under a live key")
}
if !strings.Contains(out, "from test mode is not shown.") &&
!strings.Contains(out, "from test mode are not shown.") {
t.Errorf("absence line missing, got: %s", out)
}
if !strings.Contains(out, "Show all") {
t.Error("the absence line must carry the switch")
}
if !strings.Contains(out, "env=all") {
t.Error("the switch must link to the same view with env=all")
}
})
t.Run("the all state names itself and badges the other environment", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker+"&env=all")
for _, want := range []string{"LIVE-" + e.marker, "TEST-" + e.marker, "NULL-" + e.marker} {
if !strings.Contains(out, want) {
t.Errorf("the all state must render %q", want)
}
}
if !strings.Contains(out, "Showing all environments.") {
t.Error("the all state's line missing")
}
if !strings.Contains(out, "Show live only") {
t.Error("the all state must offer the way back under a live key")
}
if !strings.Contains(out, ">Test</span>") {
t.Error("a row from the other environment must carry the Test badge")
}
})
t.Run("the switch survives a search, the facet and a page", func(t *testing.T) {
for _, target := range []string{
"/operator/billing/invoices?q=" + e.marker + "&env=all&status=open",
"/operator/billing/invoices?q=" + e.marker + "&env=all&page=1",
"/operator/billing/invoices?env=all&per=10",
} {
out := e.get(e.operator, target)
if !strings.Contains(out, "Showing all environments.") {
t.Errorf("%s: dropped the all state", target)
}
if !strings.Contains(out, "env=all") {
t.Errorf("%s: the scaffold's URLs dropped env=all", target)
}
}
})
t.Run("payments, subscriptions and accounts filter the same way", func(t *testing.T) {
for _, view := range []struct{ path, noun string }{
{"/operator/billing/payments", "payment"},
{"/operator/billing/subscriptions", "subscription"},
{"/operator/billing/accounts", "billing account"},
} {
out := e.get(e.operator, view.path+"?q="+e.marker)
if !strings.Contains(out, view.noun) {
t.Errorf("%s: absence line missing its noun %q, got: %s", view.path, view.noun, out)
}
if !strings.Contains(out, "from test mode") {
t.Errorf("%s: absence line must name the other environment", view.path)
}
if !strings.Contains(out, "Show all") {
t.Errorf("%s: absence line must carry the switch", view.path)
}
}
// The accounts view's own row is stamped test, so under a live key
// it is hidden altogether.
if out := e.get(e.operator, "/operator/billing/accounts?q="+e.marker); strings.Contains(out, "BillingEnvAccount "+e.marker) {
t.Error("a billing account recorded in test must not render under a live key")
}
})
}
// TestMemberInvoicesFilterWithoutASwitch pins the operator-billing-views
// scenario "A member's invoices are filtered without a switch": the
// member's own view drops the other environment's rows and offers no line,
// no switch and no badge.
func TestMemberInvoicesFilterWithoutASwitch(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("LIVE-"+e.marker, &live)
e.seedInvoice("TEST-"+e.marker, &test)
e.seedInvoice("NULL-"+e.marker, nil)
handler, err := server.NewMemberInvoicesHandler(server.MemberInvoicesConfig{
BillingQ: billing.New(e.database),
StripeQ: stripedb.New(e.database),
AuthConfig: &auth.Config{SessionManager: scs.New()},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
StripeMode: "live",
})
if err != nil {
t.Fatalf("NewMemberInvoicesHandler: %v", err)
}
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// The member handler reads its own session manager, so drive it with a
// session that manager issued.
sm := scs.New()
authCfg := &auth.Config{SessionManager: sm}
handler.AuthConfig = authCfg
ctx, err := sm.Load(context.Background(), "")
if err != nil {
t.Fatalf("session load: %v", err)
}
sm.Put(ctx, "authenticated", true)
sm.Put(ctx, "org_id", e.orgID)
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/partials/member/invoices", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
out := rec.Body.String()
if !strings.Contains(out, "LIVE-"+e.marker) || !strings.Contains(out, "NULL-"+e.marker) {
t.Error("the member must see the live and the unverified invoice")
}
if strings.Contains(out, "TEST-"+e.marker) {
t.Error("the member must not see the other environment's invoice")
}
for _, unwanted := range []string{"env=all", "Show all", "Showing all environments", ">Test</span>"} {
if strings.Contains(out, unwanted) {
t.Errorf("the member view must carry no environment switch or badge, found %q", unwanted)
}
}
}
// TestSyncProductToStripeCreatesAgainWhenUnreachable pins the
// product-management scenarios "Sync action is a no-op when already pending
// (in-flight) or synced" and "Sync creates the object again when the mapping
// is unreachable" (task 3.4): the refusal fires only while the mapping
// agrees and is not stale; otherwise the mappings are rewritten pending and
// both creates are enqueued, so the executor's new mapping replaces the
// unreachable id.
func TestSyncProductToStripeCreatesAgainWhenUnreachable(t *testing.T) {
live, test := true, false
cases := []struct {
name string
livemode *bool
syncStatus string
wantRefusal bool
}{
{"recorded in the key's own environment refuses", &live, "synced", true},
{"an unverified mapping refuses", nil, "synced", true},
{"recorded in test under a live key creates again", &test, "synced", false},
{"a stale mapping creates again", &live, "stale", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newBillingEnvEnv(t, "live")
productID := e.scan(
`INSERT INTO core.products (name, lifecycle_status, is_active, is_public)
VALUES ($1,'published',true,true) RETURNING product_id`,
"SyncEnv Plan "+e.marker)
priceID := e.scan(
`INSERT INTO core.prices (product_id, currency, unit_amount, recurring_interval, is_active, is_default)
VALUES ($1,'usd',1000,'month',true,true) RETURNING price_id`, productID)
e.exec(`INSERT INTO stripe.product_mappings (product_id, stripe_product_id, sync_status, livemode)
VALUES ($1,$2,$3,$4)`, productID, "prod_"+productID, tc.syncStatus, nullBool(tc.livemode))
e.exec(`INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode)
VALUES ($1,$2,$3,$4)`, priceID, "price_"+priceID, tc.syncStatus, nullBool(tc.livemode))
req := httptest.NewRequestWithContext(e.operator, http.MethodPost,
"/partials/operator/products/"+productID+"/sync-stripe", nil)
rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
// The refusal is a toast, so it rides the HX-Trigger header
// rather than the re-rendered body.
out := rec.Header().Get("HX-Trigger")
enqueued := e.outboxCount(productID)
if tc.wantRefusal {
if !strings.Contains(out, "This price is already synced to Stripe.") {
t.Errorf("want the already-synced refusal, got: %s", out)
}
if enqueued != 0 {
t.Errorf("outbox entries = %d, want 0: a refusal enqueues nothing", enqueued)
}
return
}
if strings.Contains(out, "This price is already synced to Stripe.") {
t.Error("an id the key cannot reach must not be called already synced")
}
if enqueued != 2 {
t.Errorf("outbox entries = %d, want 2 (product and price creates)", enqueued)
}
// The unreachable ids are cleared and both mappings are pending,
// so the executors' new ids land in their place.
if got := e.mappingState(`SELECT sync_status FROM stripe.price_mappings WHERE price_id=$1`, priceID); got != "pending" {
t.Errorf("price mapping sync_status = %q, want pending", got)
}
if got := e.mappingState(`SELECT coalesce(stripe_price_id,'') FROM stripe.price_mappings WHERE price_id=$1`, priceID); got != "" {
t.Errorf("stripe_price_id = %q, want the unreachable id cleared", got)
}
if got := e.mappingState(`SELECT coalesce(stripe_product_id,'') FROM stripe.product_mappings WHERE product_id=$1`, productID); got != "" {
t.Errorf("stripe_product_id = %q, want the unreachable id cleared", got)
}
})
}
}
// outboxCount counts the Stripe catalog-sync entries enqueued for one
// product, the evidence the action did or did not re-drive the sync.
func (e *billingEnvEnv) outboxCount(productID string) int {
e.t.Helper()
var n int
if err := e.database.QueryRowContext(context.Background(),
`SELECT count(*) FROM core.outbox
WHERE provider = 'stripe'
AND (payload->>'product_id' = $1
OR (payload->>'price_id')::uuid IN (SELECT price_id FROM core.prices WHERE product_id = $1::uuid))`,
productID).Scan(&n); err != nil {
e.t.Fatalf("count outbox: %v", err)
}
return n
}
func (e *billingEnvEnv) mappingState(query, arg string) string {
e.t.Helper()
var s string
if err := e.database.QueryRowContext(context.Background(), query, arg).Scan(&s); err != nil {
e.t.Fatalf("read mapping state: %v", err)
}
return s
}
// TestAbsenceLineCountsUnderTheSearch pins what the absence line's number
// means: the rows this view would have shown had the environment filter not
// applied, under the search in force. The line is about what the operator
// is looking at, so a search that matches none of the other environment's
// rows renders no line at all, and one that matches three names three.
func TestAbsenceLineCountsUnderTheSearch(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("VIS-"+e.marker, &live)
e.seedInvoice("HID-"+e.marker+"-1", &test)
e.seedInvoice("HID-"+e.marker+"-2", &test)
e.seedInvoice("HID-"+e.marker+"-3", &test)
t.Run("a search matching none of the hidden rows renders no line", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=VIS-"+e.marker)
if !strings.Contains(out, "VIS-"+e.marker) {
t.Fatal("the search must still find the live invoice")
}
if strings.Contains(out, "from test mode") {
t.Error("nothing this search would have shown is hidden, so no line renders")
}
if strings.Contains(out, "Show all") {
t.Error("no line means no switch")
}
})
t.Run("a search matching some of them names their count", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=HID-"+e.marker)
if !strings.Contains(out, "3 invoices from test mode are not shown.") {
t.Errorf("want the count under the search, got: %s", out)
}
})
t.Run("a search matching one names it in the singular", func(t *testing.T) {
out := e.get(e.operator, "/operator/billing/invoices?q=HID-"+e.marker+"-2")
if !strings.Contains(out, "1 invoice from test mode is not shown.") {
t.Errorf("want the singular line, got: %s", out)
}
})
}
// TestAllStateAlwaysOffersTheWayBack pins the one state the no-line rule
// does not cover: env=all renders its line whether or not anything is out
// of mode, because the line is the only way back to the key's own
// environment and an operator who switched and then searched would
// otherwise be stranded there.
func TestAllStateAlwaysOffersTheWayBack(t *testing.T) {
live := true
e := newBillingEnvEnv(t, "live")
e.seedInvoice("ONLYLIVE-"+e.marker, &live)
if out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker); strings.Contains(out, "from test mode") {
t.Error("the default state hides nothing here, so it renders no line")
}
out := e.get(e.operator, "/operator/billing/invoices?q="+e.marker+"&env=all")
if !strings.Contains(out, "Showing all environments.") {
t.Errorf("the all state must name itself with nothing out of mode, got: %s", out)
}
if !strings.Contains(out, "Show live only") {
t.Error("the all state must always carry the way back under a live key")
}
}
// TestInvoicesPagerTotalCountsOnlyShownRows pins operator-billing-views'
// "the pager's total SHALL count only the rows shown": the exclusion is a
// predicate on the paged query, so count(*) OVER() never counts a row the
// filter holds back, and the all state counts every row it renders.
func TestInvoicesPagerTotalCountsOnlyShownRows(t *testing.T) {
live, test := true, false
e := newBillingEnvEnv(t, "live")
e.seedInvoice("PGR-"+e.marker+"-1", &live)
e.seedInvoice("PGR-"+e.marker+"-2", &live)
e.seedInvoice("PGR-"+e.marker+"-3", nil)
e.seedInvoice("PGR-"+e.marker+"-4", &test)
e.seedInvoice("PGR-"+e.marker+"-5", &test)
out := e.get(e.operator, "/operator/billing/invoices?q=PGR-"+e.marker)
if !strings.Contains(out, "Showing 13 of 3") {
t.Errorf("the filtered pager must total the three rows it shows, got: %s", out)
}
all := e.get(e.operator, "/operator/billing/invoices?q=PGR-"+e.marker+"&env=all")
if !strings.Contains(all, "Showing 15 of 5") {
t.Errorf("the all state must total every row it shows, got: %s", all)
}
}