536 lines
21 KiB
Go
536 lines
21 KiB
Go
// 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 1–3 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 1–5 of 5") {
|
||
t.Errorf("the all state must total every row it shows, got: %s", all)
|
||
}
|
||
}
|