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.
184 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|