Phase 2 P2 (design §4.1). Locks the condition_expr grammar to:
CondExpr := { "flag": "<known_flag>" }
| { "op": "and"|"or", "args": [<CondExpr>, ...] }
Where <known_flag> must exist in paliad.scenario_flag_catalog (today:
with_ccr / with_amend / with_cci; editorial adds via the catalog
table as needed).
Wire-time validation in RuleEditorService.Create and UpdateDraft —
the rule editor surfaces a 400 with a friendly message before the row
hits the DB. Empty / JSON null inputs pass through (the "no gate"
shape; stored as NULL column).
The validator:
* walks the JSON tree once, collecting every leaf flag name
* rejects mutually-exclusive shapes (leaf + composite in one node)
* rejects empty args, bad op values, empty flag strings
* does ONE batch lookup of the collected leaf names against the
catalog (regardless of expression depth)
Tests:
* 9 shape-only unit tests covering every reject path (no DB needed)
* TestValidateConditionExpr_LiveCatalog covers 6 good shapes + 2
unknown-flag cases against the live catalog
* TestConditionExpr_AllLiveRowsValidate runs the validator over
every active+published condition_expr in paliad.sequencing_rules
to enforce the §4.1 invariant on every deploy (today's 18 rows
all conform — verified via Supabase MCP pre-flight)
Live-DB tests skip cleanly when TEST_DATABASE_URL is unset (same
posture as sibling live tests in this package).
Design: docs/design-deadline-system-revision-2026-05-27.md §4.1
(grammar formalisation). t-paliad-331.
167 lines
5.7 KiB
Go
167 lines
5.7 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
// openTestPool returns a sqlx.DB connected via TEST_DATABASE_URL.
|
|
// Returns nil + skips the test when the env var is unset, mirroring
|
|
// the pattern used by sibling live-DB tests in this package.
|
|
func openTestPool(t *testing.T) *sqlx.DB {
|
|
t.Helper()
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
return nil
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// TestValidateConditionExprShapes covers the grammar shapes (leaf,
|
|
// composite, nested composite) and the rejection paths. The catalog
|
|
// lookup is exercised via the live DB in TestValidateConditionExpr_Live18
|
|
// below; here we use json-only shape checks to keep the unit tests
|
|
// independent of database availability.
|
|
func TestValidateConditionExprShapes(t *testing.T) {
|
|
// Bypass the DB-backed flag-existence check by passing nil db with
|
|
// an expression that has no leaves once unmarshalled. Since the
|
|
// grammar walker rejects empty/invalid shapes BEFORE the DB lookup,
|
|
// shape-only assertions work without a pool. For the leaf-flag
|
|
// existence check we'd need a fixture DB — that's the live test.
|
|
ctx := context.Background()
|
|
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
wantError string // empty = success-path placeholder
|
|
wantInvalid bool
|
|
}{
|
|
{name: "empty input", input: ``, wantInvalid: false},
|
|
{name: "JSON null", input: `null`, wantInvalid: false},
|
|
{name: "bad JSON", input: `{flag:`, wantInvalid: true, wantError: "valid JSON"},
|
|
{name: "leaf with empty flag", input: `{"flag":""}`, wantInvalid: true, wantError: "empty flag"},
|
|
{name: "leaf AND op", input: `{"flag":"x","op":"and"}`, wantInvalid: true, wantError: "mutually exclusive"},
|
|
{name: "neither flag nor op", input: `{}`, wantInvalid: true, wantError: "must carry either"},
|
|
{name: "bad op", input: `{"op":"xor","args":[{"flag":"x"}]}`, wantInvalid: true, wantError: "must be 'and' or 'or'"},
|
|
{name: "empty args", input: `{"op":"and","args":[]}`, wantInvalid: true, wantError: "empty args"},
|
|
{name: "nested bad shape", input: `{"op":"and","args":[{"flag":"x"},{"flag":""}]}`, wantInvalid: true, wantError: "empty flag"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateConditionExpr(ctx, nil, json.RawMessage(c.input))
|
|
if c.wantInvalid {
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("error %v is not ErrInvalidInput", err)
|
|
}
|
|
if c.wantError != "" && !strings.Contains(err.Error(), c.wantError) {
|
|
t.Errorf("error %q missing substring %q", err.Error(), c.wantError)
|
|
}
|
|
return
|
|
}
|
|
// success-path: empty/null inputs go through without an err.
|
|
// Anything else hits the DB lookup with nil pool → nil-deref;
|
|
// that path is covered by the live test below.
|
|
if err != nil {
|
|
t.Fatalf("expected no error for %q, got %v", c.input, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateConditionExpr_LiveCatalog runs the validator against the
|
|
// real paliad.scenario_flag_catalog (the 3 seeded flags) using a sample
|
|
// of each grammar shape. Skips when DATABASE_URL isn't set.
|
|
func TestValidateConditionExpr_LiveCatalog(t *testing.T) {
|
|
pool := openTestPool(t)
|
|
if pool == nil {
|
|
t.Skip("DATABASE_URL not set — skipping live-catalog validation")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
good := []string{
|
|
`{"flag":"with_ccr"}`,
|
|
`{"flag":"with_amend"}`,
|
|
`{"flag":"with_cci"}`,
|
|
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
|
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_cci"}]}`,
|
|
`{"op":"and","args":[{"flag":"with_ccr"},{"op":"or","args":[{"flag":"with_amend"},{"flag":"with_cci"}]}]}`,
|
|
}
|
|
for _, g := range good {
|
|
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(g)); err != nil {
|
|
t.Errorf("expected %q to validate, got %v", g, err)
|
|
}
|
|
}
|
|
|
|
bad := []struct{ in, contains string }{
|
|
{`{"flag":"with_nonsense"}`, "unknown flag"},
|
|
{`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"never_seen"}]}`, "unknown flag"},
|
|
}
|
|
for _, b := range bad {
|
|
err := ValidateConditionExpr(ctx, pool, json.RawMessage(b.in))
|
|
if err == nil {
|
|
t.Errorf("expected %q to fail validation", b.in)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), b.contains) {
|
|
t.Errorf("error %q for %q missing substring %q", err.Error(), b.in, b.contains)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConditionExpr_AllLiveRowsValidate exercises the validator on every
|
|
// row currently in paliad.sequencing_rules. Per design §4.1: "all 18
|
|
// existing rows must validate" — this test enforces the invariant on
|
|
// every deploy so a new editorial entry that breaks the grammar fails
|
|
// CI before it lands.
|
|
func TestConditionExpr_AllLiveRowsValidate(t *testing.T) {
|
|
pool := openTestPool(t)
|
|
if pool == nil {
|
|
t.Skip("DATABASE_URL not set — skipping live-rows test")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
rows, err := pool.QueryContext(ctx,
|
|
`SELECT id, condition_expr::text
|
|
FROM paliad.sequencing_rules
|
|
WHERE condition_expr IS NOT NULL
|
|
AND is_active = true
|
|
AND lifecycle_state = 'published'`)
|
|
if err != nil {
|
|
t.Fatalf("load condition_expr rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
count := 0
|
|
for rows.Next() {
|
|
var id, expr string
|
|
if err := rows.Scan(&id, &expr); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
count++
|
|
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(expr)); err != nil {
|
|
t.Errorf("rule %s carries non-conforming condition_expr %s: %v", id, expr, err)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows err: %v", err)
|
|
}
|
|
if count == 0 {
|
|
t.Skip("no condition_expr rows in DB — nothing to validate")
|
|
}
|
|
t.Logf("validated %d live condition_expr rows", count)
|
|
}
|