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) }