Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
187 lines
7.8 KiB
Go
187 lines
7.8 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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)
|
|
}
|
|
}
|