feat(schema): M4.1 — tenant_registry schema + migrate binary
PLATFORM_ARCHITECTURE.md §5c schema as one initial migration: 6 tables + 4 enums + updated_at triggers. cmd/migrate binary (golang-migrate library, embedded SQL). testcontainers round-trip + seed + slug-constraint tests. Refs: M4.1
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,5 +1,21 @@
|
||||
-- M4.1 down — reverse of 0001_init.up.sql.
|
||||
-- Forward-only in prod (column drops require two releases); the down
|
||||
-- migration exists for testcontainers round-trips + dev tear-downs.
|
||||
|
||||
DROP TRIGGER IF EXISTS tenant_idp_config_touch_updated_at ON tenant_idp_config;
|
||||
DROP TRIGGER IF EXISTS tenant_products_touch_updated_at ON tenant_products;
|
||||
DROP TRIGGER IF EXISTS tenant_projects_touch_updated_at ON tenant_projects;
|
||||
DROP TRIGGER IF EXISTS tenants_touch_updated_at ON tenants;
|
||||
DROP FUNCTION IF EXISTS touch_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS tenant_idp_config;
|
||||
DROP TABLE IF EXISTS tenant_products;
|
||||
DROP TABLE IF EXISTS tenant_projects;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
DROP TYPE IF EXISTS tenant_kind;
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
DROP TYPE IF EXISTS tenant_project_status;
|
||||
DROP TYPE IF EXISTS idp_kind;
|
||||
DROP TYPE IF EXISTS tenant_kind;
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
|
||||
+175
-30
@@ -1,52 +1,197 @@
|
||||
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c).
|
||||
-- The skeleton uses an in-memory store; this file lands the table shape
|
||||
-- the real M4.1 PR will use, so the schema review can happen alongside
|
||||
-- the rest of the boot scaffolding.
|
||||
-- M4.1 — initial tenant_registry schema.
|
||||
-- Source of truth: PLATFORM_ARCHITECTURE.md §5c.
|
||||
-- Forward-only per IMPLEMENTATION_PLAN.md §1.7.
|
||||
|
||||
-- enums --------------------------------------------------------------------
|
||||
-- =========================================================================
|
||||
-- enums
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo');
|
||||
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal');
|
||||
CREATE TYPE tenant_status AS ENUM (
|
||||
'demo', -- shared demo tenant; reset nightly; no billing
|
||||
'trial', -- real customer in their N-day evaluation window
|
||||
'active', -- paid; contract or self-serve plan
|
||||
'frozen', -- read-only after cancel / non-payment (30d grace)
|
||||
'archived' -- data export window closed; only audit log retained
|
||||
);
|
||||
|
||||
-- tenants ------------------------------------------------------------------
|
||||
CREATE TYPE tenant_kind AS ENUM (
|
||||
'customer', -- real paying / trialing customer
|
||||
'demo' -- shared demo tenant; never billed
|
||||
);
|
||||
|
||||
CREATE TYPE idp_kind AS ENUM (
|
||||
'oidc',
|
||||
'saml'
|
||||
);
|
||||
|
||||
CREATE TYPE tenant_project_status AS ENUM (
|
||||
'active',
|
||||
'archived'
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- tenants — the root entity. tenants.id ↔ Keycloak org_id 1:1.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'),
|
||||
name TEXT NOT NULL,
|
||||
status tenant_status NOT NULL DEFAULT 'trial',
|
||||
kind tenant_kind NOT NULL DEFAULT 'customer',
|
||||
plan TEXT NOT NULL DEFAULT 'starter',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
trial_ends_at TIMESTAMPTZ
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
|
||||
name TEXT NOT NULL,
|
||||
status tenant_status NOT NULL DEFAULT 'trial',
|
||||
kind tenant_kind NOT NULL DEFAULT 'customer',
|
||||
plan TEXT NOT NULL DEFAULT 'starter',
|
||||
|
||||
-- External system references (one-to-one per §5c "Links")
|
||||
erp_customer_id TEXT UNIQUE,
|
||||
stripe_cust_id TEXT UNIQUE,
|
||||
|
||||
-- Lifecycle dates
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
contract_start DATE,
|
||||
contract_end DATE,
|
||||
|
||||
-- CRM ownership (ERPNext sales_owner equivalent)
|
||||
sales_owner TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX tenants_status_idx ON tenants (status);
|
||||
CREATE INDEX tenants_kind_idx ON tenants (kind);
|
||||
CREATE INDEX tenants_trial_ends_idx ON tenants (trial_ends_at) WHERE trial_ends_at IS NOT NULL;
|
||||
|
||||
-- tenant ↔ product entitlements -------------------------------------------
|
||||
-- =========================================================================
|
||||
-- tenant_projects — OPTIONAL sub-tenancy (GCP-Project-style).
|
||||
-- Customers without need operate as a single implicit "default" project.
|
||||
-- Products opt in via manifest.supports_projects=true.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$'),
|
||||
status tenant_project_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX tenant_projects_tenant_idx ON tenant_projects (tenant_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- tenant_products — entitlement matrix: which tenant has which product.
|
||||
-- config holds product-specific knobs (litellm_url, max_seats, modules_enabled…).
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_products (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, product)
|
||||
);
|
||||
|
||||
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------
|
||||
CREATE INDEX tenant_products_product_idx ON tenant_products (product) WHERE enabled = TRUE;
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id),
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
-- =========================================================================
|
||||
-- tenant_idp_config — external identity provider per tenant (enterprise SSO).
|
||||
-- metadata holds OIDC discovery URL + client_id, or SAML cert + entity_id.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE tenant_idp_config (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
type idp_kind NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ip INET,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, type)
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX tenant_idp_config_tenant_idx ON tenant_idp_config (tenant_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- api_keys — portal-owned. Single source of truth across all products.
|
||||
-- hash is bcrypt/argon2 of the raw key; the plaintext is shown ONCE on create.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
product TEXT, -- nullable = applies to all products
|
||||
name TEXT NOT NULL, -- human-readable label
|
||||
scopes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
hash TEXT NOT NULL, -- argon2id encoded hash
|
||||
prefix TEXT NOT NULL, -- first 8 chars of the raw key, for UI display
|
||||
created_by TEXT, -- Keycloak user_id
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX api_keys_tenant_idx ON api_keys (tenant_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||
|
||||
-- =========================================================================
|
||||
-- audit_log — every state-changing action across portal + products.
|
||||
-- Retraced-compatible shape (PRODUCT_INTEGRATION_SPEC.md §8.4) so we can
|
||||
-- swap implementations without changing producers.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
project_id UUID REFERENCES tenant_projects(id) ON DELETE SET NULL,
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
actor_type TEXT, -- user | service | system
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
target_name TEXT,
|
||||
product TEXT, -- which product emitted this (NULL = portal/tenant-registry)
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ip INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX audit_log_product_idx ON audit_log (product, created_at DESC) WHERE product IS NOT NULL;
|
||||
CREATE INDEX audit_log_actor_idx ON audit_log (actor_id, created_at DESC) WHERE actor_id IS NOT NULL;
|
||||
CREATE INDEX audit_log_action_idx ON audit_log (action);
|
||||
CREATE INDEX audit_log_tenant_action_idx ON audit_log (tenant_id, action, created_at DESC);
|
||||
|
||||
-- =========================================================================
|
||||
-- update timestamp trigger — applied to every table with an updated_at.
|
||||
-- =========================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tenants_touch_updated_at
|
||||
BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_projects_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_projects
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_products_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_products
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
CREATE TRIGGER tenant_idp_config_touch_updated_at
|
||||
BEFORE UPDATE ON tenant_idp_config
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package migrations exposes the SQL migration files as an embed.FS so the
|
||||
// migrate binary doesn't have to ship them as loose files at runtime.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds every *.sql file in this directory at build time.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,291 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // pgx stdlib driver for database/sql
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
)
|
||||
|
||||
// startPostgres spins a fresh postgres:16-alpine container and returns its
|
||||
// DSN + a cleanup func. Skips the test if Docker is unreachable.
|
||||
func startPostgres(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pgc, err := tcpostgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
tcpostgres.WithDatabase("tenant_registry_test"),
|
||||
tcpostgres.WithUsername("test"),
|
||||
tcpostgres.WithPassword("test"),
|
||||
tcpostgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker unreachable (%v)", err)
|
||||
}
|
||||
|
||||
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = pgc.Terminate(context.Background())
|
||||
t.Fatalf("dsn: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = pgc.Terminate(c)
|
||||
}
|
||||
return dsn, cleanup
|
||||
}
|
||||
|
||||
func newMigrator(t *testing.T, dsn string) *migrate.Migrate {
|
||||
t.Helper()
|
||||
src, err := iofs.New(FS, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = m.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestMigrate_upDownRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
// Schema assertions — every table the spec requires must exist.
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
wantTables := []string{
|
||||
"tenants",
|
||||
"tenant_projects",
|
||||
"tenant_products",
|
||||
"tenant_idp_config",
|
||||
"api_keys",
|
||||
"audit_log",
|
||||
}
|
||||
for _, table := range wantTables {
|
||||
var exists bool
|
||||
err := db.QueryRow(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=$1)",
|
||||
table,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
t.Fatalf("query for table %s: %v", table, err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("table %s missing after migrate up", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Enum assertions.
|
||||
wantEnums := map[string][]string{
|
||||
"tenant_status": {"demo", "trial", "active", "frozen", "archived"},
|
||||
"tenant_kind": {"customer", "demo"},
|
||||
"idp_kind": {"oidc", "saml"},
|
||||
"tenant_project_status": {"active", "archived"},
|
||||
}
|
||||
for enum, values := range wantEnums {
|
||||
rows, err := db.Query(
|
||||
"SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid WHERE t.typname = $1 ORDER BY e.enumsortorder",
|
||||
enum,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query enum %s: %v", enum, err)
|
||||
}
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, v)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if fmt.Sprint(got) != fmt.Sprint(values) {
|
||||
t.Errorf("enum %s = %v, want %v", enum, got, values)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip: down all, then up again — must succeed without leftover state.
|
||||
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("down: %v", err)
|
||||
}
|
||||
var afterDown int
|
||||
err = db.QueryRow(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY($1)",
|
||||
wantTables,
|
||||
).Scan(&afterDown)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterDown != 0 {
|
||||
t.Errorf("after down: %d tables still present, want 0", afterDown)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up after down: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeed_canInsertAndQuery is the lightweight happy-path: insert a tenant,
|
||||
// give it a project + a product + an api_key + an audit record, query back.
|
||||
// Catches schema-level mistakes (NOT NULL, FK direction, enum cast) that
|
||||
// table-existence checks miss.
|
||||
func TestSeed_canInsertAndQuery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
ctx := context.Background()
|
||||
|
||||
var tid string
|
||||
err = db.QueryRowContext(ctx,
|
||||
`INSERT INTO tenants (slug, name, plan, status, kind)
|
||||
VALUES ($1, $2, 'professional', 'active', 'customer')
|
||||
RETURNING id`,
|
||||
"acme", "Acme Inc.").Scan(&tid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tenant: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO tenant_projects (tenant_id, name, slug) VALUES ($1, $2, $3)`,
|
||||
tid, "Production", "prod"); err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO tenant_products (tenant_id, product, config) VALUES ($1, 'certifai', '{"max_seats":10}'::jsonb)`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert product: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO api_keys (tenant_id, name, hash, prefix, scopes)
|
||||
VALUES ($1, 'ci-bot', 'argon2-hash', 'bp_12345', ARRAY['certifai:read'])`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert api_key: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (tenant_id, action, actor_id, actor_name, metadata)
|
||||
VALUES ($1, 'tenant.created', 'sys', 'system', '{"source":"test"}'::jsonb)`,
|
||||
tid); err != nil {
|
||||
t.Fatalf("insert audit: %v", err)
|
||||
}
|
||||
|
||||
// Round-trip read.
|
||||
var slug, status string
|
||||
err = db.QueryRowContext(ctx, `SELECT slug, status::text FROM tenants WHERE id = $1`, tid).Scan(&slug, &status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slug != "acme" || status != "active" {
|
||||
t.Errorf("tenant readback: slug=%q status=%q", slug, status)
|
||||
}
|
||||
|
||||
// FK cascade — delete tenant, projects/products/keys/audit_log handling.
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM tenants WHERE id = $1`, tid); err != nil {
|
||||
t.Fatalf("delete tenant: %v", err)
|
||||
}
|
||||
var nProjects, nProducts, nKeys int
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_projects WHERE tenant_id = $1`, tid).Scan(&nProjects)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_products WHERE tenant_id = $1`, tid).Scan(&nProducts)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM api_keys WHERE tenant_id = $1`, tid).Scan(&nKeys)
|
||||
if nProjects != 0 || nProducts != 0 || nKeys != 0 {
|
||||
t.Errorf("FK cascade incomplete: projects=%d products=%d keys=%d", nProjects, nProducts, nKeys)
|
||||
}
|
||||
|
||||
// audit_log uses ON DELETE SET NULL — tenant_id becomes NULL but row stays
|
||||
var nAudit, nAuditNullTenant int
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&nAudit)
|
||||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log WHERE tenant_id IS NULL`).Scan(&nAuditNullTenant)
|
||||
if nAudit != 1 || nAuditNullTenant != 1 {
|
||||
t.Errorf("audit_log SET NULL: total=%d null=%d, want 1/1", nAudit, nAuditNullTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugConstraint(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test under -short")
|
||||
}
|
||||
dsn, stop := startPostgres(t)
|
||||
defer stop()
|
||||
|
||||
m := newMigrator(t, dsn)
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
t.Fatalf("up: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
cases := []struct {
|
||||
slug string
|
||||
wantErr bool
|
||||
}{
|
||||
{"acme", false},
|
||||
{"a-c-m-e", false},
|
||||
{"a1b2c3", false},
|
||||
{"a", true}, // too short
|
||||
{"-acme", true}, // leading dash
|
||||
{"acme-", true}, // trailing dash
|
||||
{"AcMe", true}, // uppercase
|
||||
{"a_b", true}, // underscore
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := db.Exec(`INSERT INTO tenants (slug, name) VALUES ($1, 'X')`, c.slug)
|
||||
gotErr := err != nil
|
||||
if gotErr != c.wantErr {
|
||||
t.Errorf("slug %q: gotErr=%v wantErr=%v (err=%v)", c.slug, gotErr, c.wantErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user