Files
member-console/internal/db/partitions_test.go
T
cgalo5758 efe3f1528d Restrict member surfaces to published products
Add lifecycle_status = 'published' to the public-catalog queries
(plans and add-ons listings) and reject checkout before any Stripe
call unless the product behind the price clears the shared member
gate (published + active + public). The currently-enrolled ladder
rung stays renderable even if its product is later drafted or
retired, fetched directly so members keep seeing what they are on.

Introduce a single evaluateMemberGate definition shared by the
catalog paths and the operator readiness panel so the surfaces
cannot disagree about what is publishable for members.
2026-08-22 12:58:05 -05:00

184 lines
7.7 KiB
Go

package db_test
// DB-backed idempotency test for EnsureWebhookEventPartitions
// (webhook-partition-maintenance, design.md D3/D4). Runs against
// TEST_DATABASE_URL under the same member_console role the running app and
// its boot pass use (D4: per-schema roles are in force, and this proves the
// role can CREATE in schema core rather than assuming it).
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
)
// newPartitionsTestDB opens the test DB and runs core's migrations —
// core.webhook_events and its baseline partitions (0..2 months ahead) live
// entirely in the core schema, so db.BaseSources() is sufficient (mirrors
// internal/systemtenant/systemtenant_test.go's newTestDB).
func newPartitionsTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := db.RunMigrations(database, db.BaseSources()); err != nil {
t.Fatalf("migrations: %v", err)
}
return database
}
// partitionTableName mirrors EnsureWebhookEventPartitions' and the baseline
// migration's naming exactly (core.webhook_events_YYYY_MM).
func partitionTableName(monthStart time.Time) string {
return fmt.Sprintf("core.webhook_events_%04d_%02d", monthStart.Year(), int(monthStart.Month()))
}
// insertWebhookEvent inserts a minimal row timestamped at receivedAt,
// returning whatever error the partitioned table's routing produces (nil on
// success).
func insertWebhookEvent(ctx context.Context, database *sql.DB, providerEventID string, receivedAt time.Time) error {
_, err := database.ExecContext(ctx, `
INSERT INTO core.webhook_events (provider, provider_event_id, event_type, received_at)
VALUES ('test-provider', $1, 'test.event', $2)`,
providerEventID, receivedAt)
return err
}
// tableExists reports whether the given core.<name> table exists.
func tableExists(t *testing.T, ctx context.Context, database *sql.DB, name string) bool {
t.Helper()
var exists bool
err := database.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'core' AND table_name = $1
)`, name).Scan(&exists)
if err != nil {
t.Fatalf("check table existence for %s: %v", name, err)
}
return exists
}
// TestEnsureWebhookEventPartitions_ClosesTheGapAndIsIdempotent proves the
// bug (an insert into an unpartitioned-for month fails), that Ensure closes
// it, and that a second Ensure run is a no-op — the three scenarios in
// openspec/changes/purchase-path-blockers/specs/webhook-partition-maintenance/spec.md.
func TestEnsureWebhookEventPartitions_ClosesTheGapAndIsIdempotent(t *testing.T) {
database := newPartitionsTestDB(t)
ctx := context.Background()
// A synthetic "now" far outside any real calendar month a concurrently
// running test suite, or a prior un-reset run of this same test, could
// have already ensured partitions for — the shared TEST_DATABASE_URL
// database is reset between `make test` runs but not between bare
// `go test` reruns, and this test must be able to prove "fails before,
// succeeds after" regardless of that history.
now := time.Date(2119, time.June, 15, 12, 0, 0, 0, time.UTC)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
monthsAhead := db.DefaultWebhookPartitionMonthsAhead // 3
targetMonth := monthStart.AddDate(0, monthsAhead, 0)
targetPartition := partitionTableName(targetMonth)
// Postgres table name only, without the "core." schema qualifier, for
// information_schema lookups.
targetPartitionUnqualified := strings.TrimPrefix(targetPartition, "core.")
// Guarantee a clean slate for the "before" assertion: drop the target
// partition (and, defensively, the whole synthetic month range) if a
// previous unreset run left it behind.
dropSyntheticPartitions := func() {
for i := 0; i <= monthsAhead; i++ {
name := partitionTableName(monthStart.AddDate(0, i, 0))
if _, err := database.ExecContext(ctx, "DROP TABLE IF EXISTS "+name); err != nil {
t.Fatalf("drop synthetic partition %s: %v", name, err)
}
}
}
dropSyntheticPartitions()
t.Cleanup(dropSyntheticPartitions)
// --- "Missing future partitions" scenario, negative half: before Ensure
// runs, the target month (now + monthsAhead) has no partition, so the
// insert fails with Postgres' partition-routing error. This is the bug
// this change fixes: nothing but the baseline migration's initial
// current+2 loop had ever created partitions.
if err := insertWebhookEvent(ctx, database, "before-ensure", targetMonth.Add(time.Hour)); err == nil {
t.Fatalf("insert into %s succeeded before Ensure ran; the partition gap this change fixes was not reproduced", targetPartition)
} else if !strings.Contains(strings.ToLower(err.Error()), "partition") {
t.Fatalf("insert into %s failed with an unexpected error (want a partition-routing error): %v", targetPartition, err)
}
if tableExists(t, ctx, database, targetPartitionUnqualified) {
t.Fatalf("partition %s already exists before Ensure ran", targetPartition)
}
// --- Run Ensure: creates the current synthetic month through +monthsAhead.
if err := db.EnsureWebhookEventPartitions(ctx, database, now, monthsAhead); err != nil {
t.Fatalf("EnsureWebhookEventPartitions (first run): %v", err)
}
for i := 0; i <= monthsAhead; i++ {
name := partitionTableName(monthStart.AddDate(0, i, 0))
unqualified := strings.TrimPrefix(name, "core.")
if !tableExists(t, ctx, database, unqualified) {
t.Errorf("expected partition %s to exist after Ensure, it does not", name)
}
}
// --- "Missing future partitions" scenario, positive half: the insert
// that failed above now succeeds, in the correct partition.
if err := insertWebhookEvent(ctx, database, "after-ensure", targetMonth.Add(time.Hour)); err != nil {
t.Fatalf("insert into %s failed after Ensure ran: %v", targetPartition, err)
}
var rowCount int
if err := database.QueryRowContext(ctx, "SELECT count(*) FROM "+targetPartition+" WHERE provider_event_id = 'after-ensure'").
Scan(&rowCount); err != nil {
t.Fatalf("count rows landed in %s: %v", targetPartition, err)
}
if rowCount != 1 {
t.Errorf("row landed in %s = %d, want 1 (the row must be routed to the correct monthly partition)", targetPartition, rowCount)
}
// --- "Ensure is idempotent" scenario: a second run against the same
// `now` succeeds and creates nothing new.
var tableCountBefore int
if err := database.QueryRowContext(ctx, `
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'core' AND table_name LIKE 'webhook_events_%'`).Scan(&tableCountBefore); err != nil {
t.Fatalf("count webhook_events partitions before second Ensure: %v", err)
}
if err := db.EnsureWebhookEventPartitions(ctx, database, now, monthsAhead); err != nil {
t.Fatalf("EnsureWebhookEventPartitions (second run): %v", err)
}
var tableCountAfter int
if err := database.QueryRowContext(ctx, `
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'core' AND table_name LIKE 'webhook_events_%'`).Scan(&tableCountAfter); err != nil {
t.Fatalf("count webhook_events partitions after second Ensure: %v", err)
}
if tableCountAfter != tableCountBefore {
t.Errorf("second Ensure run changed the partition count: %d -> %d, want no-op", tableCountBefore, tableCountAfter)
}
// The insert-succeeds behavior still holds after the no-op second run.
if err := insertWebhookEvent(ctx, database, "after-second-ensure", targetMonth.Add(2*time.Hour)); err != nil {
t.Fatalf("insert into %s failed after second Ensure run: %v", targetPartition, err)
}
}