Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 / EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the forum-bucket chip selection by design — every chip combination returned all five rows. m/paliad#97: chip the chips through to triggers via legal_source inference. - mig 123 backfills the missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because mig 092 dropped event_deadlines before that path was seeded) and rebuilds paliad.deadline_search with a LEFT JOIN on deadline_rules so cross-cutting trigger pills carry their structured legal_source. - DeadlineSearchService gains ForumToLegalSourcePrefixes (10 buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ) paralleling ForumToProceedingCodes. Rule pills still narrow by proceeding_code; trigger pills now narrow by legal_source LIKE prefix. Multiple chips union the prefix allow-list as expected. - Live golden-table test gains a Wiedereinsetzung×forum matrix plus a multi-chip union case, and the existing 4-pill assertion is updated to the now-5-pill state (mig 063 added trigger 207). Branch: mai/hermes/gitster-event-type-modal.
648 lines
20 KiB
Go
648 lines
20 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/jmoiron/sqlx"
|
||
_ "github.com/lib/pq"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/db"
|
||
)
|
||
|
||
// TestFormatLegalSourceDisplay covers the structured-form → display-form
|
||
// conversion the search API exposes alongside legal_source.
|
||
func TestFormatLegalSourceDisplay(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"UPC.RoP.23.1", "UPC RoP R.23(1)"},
|
||
{"UPC.RoP.139", "UPC RoP R.139"},
|
||
{"UPC.RoP.220.1", "UPC RoP R.220(1)"},
|
||
{"DE.PatG.82.1", "PatG §82(1)"},
|
||
{"DE.PatG.111.1", "PatG §111(1)"},
|
||
{"DE.PatG.59.3", "PatG §59(3)"},
|
||
{"DE.ZPO.276.1", "ZPO §276(1)"},
|
||
{"DE.ZPO.517", "ZPO §517"},
|
||
{"EU.EPÜ.108", "EPÜ Art.108"},
|
||
{"EU.EPÜ.112a", "EPÜ Art.112a"},
|
||
{"EU.EPC-R.79.1", "EPC R.79(1)"},
|
||
{"EU.RPBA.12.1.c", "RPBA Art.12(1)(c)"},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := FormatLegalSourceDisplay(c.in)
|
||
if got != c.want {
|
||
t.Errorf("FormatLegalSourceDisplay(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
|
||
// permalink mapping. Only the UPC corpus has a youpc home today;
|
||
// DE/EPA/EU bodies fall through to the empty string and the renderer
|
||
// shows display text without a link.
|
||
func TestBuildLegalSourceURL(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
|
||
{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
|
||
{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
|
||
{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
|
||
{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
|
||
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
|
||
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
|
||
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
|
||
{"DE.PatG.82.1", ""},
|
||
{"DE.ZPO.276.1", ""},
|
||
{"EU.EPÜ.108", ""},
|
||
{"EU.EPC-R.79.1", ""},
|
||
{"EU.RPBA.12.1.c", ""},
|
||
{"UPC.RoP", ""},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := BuildLegalSourceURL(c.in)
|
||
if got != c.want {
|
||
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeQuery covers the input-side legal-prefix stripping that
|
||
// keeps "§ 82" / "Art. 108" findable against structured legal_source
|
||
// values that don't carry the prefix.
|
||
func TestNormalizeQuery(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"§ 82", "82"},
|
||
{"§82", "82"},
|
||
{"Art. 108", "108"},
|
||
{"art.108", "108"},
|
||
{"Section 276", "276"},
|
||
{" Wiedereinsetzung ", "Wiedereinsetzung"},
|
||
{"RoP 23", "RoP 23"}, // RoP is meaningful, kept verbatim
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := normalizeQuery(c.in)
|
||
if got != c.want {
|
||
t.Errorf("normalizeQuery(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDeadlineSearch is the Phase C golden table — the binding spec for
|
||
// what the search backend must answer for a fixed set of HLC vocabulary
|
||
// queries. Skipped when TEST_DATABASE_URL is unset, mirroring the other
|
||
// live-DB tests in this package.
|
||
func TestDeadlineSearch(t *testing.T) {
|
||
url := os.Getenv("TEST_DATABASE_URL")
|
||
if url == "" {
|
||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||
}
|
||
if err := db.ApplyMigrations(url); err != nil {
|
||
t.Fatalf("apply migrations: %v", err)
|
||
}
|
||
pool, err := sqlx.Connect("postgres", url)
|
||
if err != nil {
|
||
t.Fatalf("connect: %v", err)
|
||
}
|
||
defer pool.Close()
|
||
|
||
ctx := context.Background()
|
||
if err := RefreshSearchView(ctx, pool); err != nil {
|
||
t.Fatalf("refresh search view: %v", err)
|
||
}
|
||
|
||
svc := NewDeadlineSearchService(pool)
|
||
|
||
t.Run("Klageerwiderung returns the statement-of-defence concept with multi-jurisdiction pills", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
|
||
// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
|
||
// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
|
||
// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
|
||
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
|
||
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
|
||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
|
||
mustHaveLegalSource(t, card, "DE.PatG.59.3")
|
||
mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
|
||
})
|
||
|
||
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "RoP 23", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) == 0 {
|
||
t.Fatalf("expected at least one card for 'RoP 23', got 0")
|
||
}
|
||
// Statement-of-defence is the canonical R.23 concept; it must be
|
||
// among the cards (not necessarily rank 1 because rule_code-based
|
||
// trigram only weighs 0.9, and several rules with adjacent codes
|
||
// also hit).
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
|
||
})
|
||
|
||
t.Run("§ 82 returns the BPatG nullity-defence card", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "§ 82", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// PatG §82(1) is the BPatG Klageerwiderung anchor — should fall
|
||
// under the statement-of-defence concept.
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||
})
|
||
|
||
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
||
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
|
||
// 200..203 from mig 046 plus 207 from mig 063.
|
||
triggerIDs := []int64{}
|
||
for _, p := range card.Pills {
|
||
if p.Kind != "trigger" {
|
||
t.Errorf("Wiedereinsetzung card has non-trigger pill: kind=%q proc=%v", p.Kind, p.Proceeding)
|
||
}
|
||
if p.TriggerEventID != nil {
|
||
triggerIDs = append(triggerIDs, *p.TriggerEventID)
|
||
}
|
||
}
|
||
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
|
||
if len(triggerIDs) != 5 {
|
||
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
|
||
}
|
||
for _, id := range triggerIDs {
|
||
if !want[id] {
|
||
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
|
||
}
|
||
}
|
||
})
|
||
|
||
// t-paliad-266 / m/paliad#97 — court-system filter narrows
|
||
// cross-cutting trigger pills via legal_source inference.
|
||
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
|
||
// Each pair is (forum slug, expected trigger_event_ids).
|
||
cases := []struct {
|
||
name string
|
||
forum string
|
||
wantTrigIDs []int64
|
||
}{
|
||
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
|
||
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
|
||
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
|
||
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
|
||
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
|
||
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
|
||
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
|
||
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
|
||
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
|
||
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||
Forums: []string{tc.forum},
|
||
Limit: 12,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||
got := map[int64]bool{}
|
||
for _, p := range card.Pills {
|
||
if p.TriggerEventID != nil {
|
||
got[*p.TriggerEventID] = true
|
||
}
|
||
}
|
||
want := map[int64]bool{}
|
||
for _, id := range tc.wantTrigIDs {
|
||
want[id] = true
|
||
}
|
||
for id := range got {
|
||
if !want[id] {
|
||
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
|
||
}
|
||
}
|
||
for id := range want {
|
||
if !got[id] {
|
||
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
|
||
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
|
||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
|
||
Forums: []string{"upc_cfi", "de_lg"},
|
||
Limit: 12,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||
got := map[int64]bool{}
|
||
for _, p := range card.Pills {
|
||
if p.TriggerEventID != nil {
|
||
got[*p.TriggerEventID] = true
|
||
}
|
||
}
|
||
want := map[int64]bool{201: true, 207: true}
|
||
for id := range got {
|
||
if !want[id] {
|
||
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
|
||
}
|
||
}
|
||
for id := range want {
|
||
if !got[id] {
|
||
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
|
||
// No forum chips = all 5 triggers stay visible.
|
||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||
count := 0
|
||
for _, p := range card.Pills {
|
||
if p.Kind == "trigger" {
|
||
count++
|
||
}
|
||
}
|
||
if count != 5 {
|
||
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
|
||
}
|
||
})
|
||
|
||
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Statement-of-defence is filed by the defendant. Filtering
|
||
// party=claimant should NOT drop the concept entirely — the
|
||
// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
|
||
// is owed by the patentee/claimant). At least it must not
|
||
// return any card with EVERY pill on defendant side.
|
||
for _, c := range resp.Cards {
|
||
allDefendant := true
|
||
for _, p := range c.Pills {
|
||
if p.Party != "defendant" {
|
||
allDefendant = false
|
||
break
|
||
}
|
||
}
|
||
if allDefendant {
|
||
t.Errorf("party=claimant filter returned a card whose every pill is defendant: %q", c.Concept.Slug)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("source filter narrows to UPC.RoP-prefixed pills only", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Source: "UPC.RoP", Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.LegalSource != nil && !startsWith(*p.LegalSource, "UPC.RoP") {
|
||
t.Errorf("source=UPC.RoP filter leaked a non-UPC.RoP pill: %q", *p.LegalSource)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("empty query returns empty cards (browse surface is Phase D)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) != 0 {
|
||
t.Errorf("empty q should return 0 cards, got %d", len(resp.Cards))
|
||
}
|
||
})
|
||
|
||
t.Run("limit is capped at MaxLimit", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 1000, MaxLimit: 5})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) > 5 {
|
||
t.Errorf("limit cap not enforced: got %d cards, want ≤ 5", len(resp.Cards))
|
||
}
|
||
})
|
||
|
||
t.Run("legal_source_display is rendered for rule pills with a legal_source", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
for _, p := range card.Pills {
|
||
if p.LegalSource == nil {
|
||
continue
|
||
}
|
||
if p.LegalSourceDisplay == nil || *p.LegalSourceDisplay == "" {
|
||
t.Errorf("rule pill with legal_source=%q is missing legal_source_display", *p.LegalSource)
|
||
}
|
||
}
|
||
})
|
||
|
||
// v4 (t-paliad-136): event-category narrowing must apply per-leaf
|
||
// (concept_id, proceeding_type_code) tuples, not just concept_id. The
|
||
// v3 implementation collapsed tuples to flat concept_ids and surfaced
|
||
// pills for every proceeding the matview had a row for. m's repro:
|
||
// picking "CMS-Eingang → Gegenseite → UPC Verletzung" leaked DE/EPA/
|
||
// DPMA pills.
|
||
ec := NewEventCategoryService(pool)
|
||
svc.SetEventCategoryService(ec)
|
||
|
||
t.Run("v4 event_category_slug narrows pills to per-leaf proceeding (UPC infringement subtree)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Every rule pill must be a UPC proceeding. The seed maps every
|
||
// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
|
||
// DE/EPA/DPMA codes should leak.
|
||
allowedRulePrefix := []string{"upc."}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
if p.Proceeding == nil {
|
||
t.Errorf("rule pill on %q has no proceeding", c.Concept.Slug)
|
||
continue
|
||
}
|
||
ok := false
|
||
for _, prefix := range allowedRulePrefix {
|
||
if strings.HasPrefix(p.Proceeding.Code, prefix) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
t.Errorf("subtree narrowing leaked non-UPC pill on %q: proc=%s rule=%s",
|
||
c.Concept.Slug, p.Proceeding.Code, p.RuleLocalCode)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("v4 event_category_slug honours per-tuple narrowing (klageerwiderung-mit-ccr leaf)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr",
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Junction maps three concepts × upc.inf.cfi for this leaf:
|
||
// defence-to-counterclaim-for-revocation, application-to-amend,
|
||
// reply-to-defence. Every pill must be upc.inf.cfi.
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
|
||
code := "(nil)"
|
||
if p.Proceeding != nil {
|
||
code = p.Proceeding.Code
|
||
}
|
||
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
|
||
CodeUPCInfringement, c.Concept.Slug, code)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("v4 trigger-only concept under leaf with NULL proc surfaces", func(t *testing.T) {
|
||
// frist-verpasst.epa maps wiedereinsetzung and weiterbehandlung
|
||
// with NULL proceeding_type_code (cross-cutting). Both must
|
||
// appear and both must surface their trigger pills.
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "frist-verpasst.epa",
|
||
Limit: 50,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
mustHaveCard := func(slug string) ConceptCard {
|
||
for _, c := range resp.Cards {
|
||
if c.Concept.Slug == slug {
|
||
return c
|
||
}
|
||
}
|
||
t.Fatalf("missing card %q under frist-verpasst.epa; got %v", slug, conceptSlugs(resp.Cards))
|
||
return ConceptCard{}
|
||
}
|
||
wcard := mustHaveCard("wiedereinsetzung")
|
||
// Trigger pills (no proceeding) must be present.
|
||
hasTrigger := false
|
||
for _, p := range wcard.Pills {
|
||
if p.Kind == "trigger" {
|
||
hasTrigger = true
|
||
break
|
||
}
|
||
}
|
||
if !hasTrigger {
|
||
t.Errorf("wiedereinsetzung card under frist-verpasst.epa has no trigger pills")
|
||
}
|
||
})
|
||
|
||
t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
|
||
// Pick the upc.inf.cfi subtree and add a forum chip that excludes
|
||
// upc.inf.cfi — the result must be empty (the user contradicted
|
||
// themselves; empty is the correct UX).
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
|
||
Forums: []string{"epa_opp"},
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind == "rule" {
|
||
t.Errorf("AND-narrowing produced a rule pill where forum + subtree contradict: %q proc=%v",
|
||
c.Concept.Slug, p.Proceeding)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// Invariant test: walk every leaf with at least one non-NULL
|
||
// proceeding_type_code in the junction and assert that the search
|
||
// result for that leaf only surfaces pills whose (concept, proc)
|
||
// is authorised by an outcome row. This is the regression gate
|
||
// that would have caught the v3 bug at PR time.
|
||
t.Run("v4 invariant: per-leaf pills are authorised by junction tuples", func(t *testing.T) {
|
||
leafRows, err := pool.QueryxContext(ctx, `
|
||
SELECT DISTINCT ec.slug
|
||
FROM paliad.event_categories ec
|
||
JOIN paliad.event_category_concepts ecc ON ecc.event_category_id = ec.id
|
||
WHERE ec.is_active AND ec.is_leaf
|
||
AND ecc.proceeding_type_code IS NOT NULL
|
||
`)
|
||
if err != nil {
|
||
t.Fatalf("list leaves: %v", err)
|
||
}
|
||
var slugs []string
|
||
for leafRows.Next() {
|
||
var s string
|
||
if err := leafRows.Scan(&s); err != nil {
|
||
leafRows.Close()
|
||
t.Fatalf("scan leaf: %v", err)
|
||
}
|
||
slugs = append(slugs, s)
|
||
}
|
||
leafRows.Close()
|
||
if len(slugs) == 0 {
|
||
t.Fatalf("expected at least one leaf with non-NULL proc; got 0")
|
||
}
|
||
|
||
for _, slug := range slugs {
|
||
outcomes, err := ec.ConceptsForSlug(ctx, slug)
|
||
if err != nil {
|
||
t.Fatalf("ConceptsForSlug(%q): %v", slug, err)
|
||
}
|
||
// Build tuple validator.
|
||
isAllowed := func(conceptID, proc string) bool {
|
||
for _, o := range outcomes {
|
||
if o.ConceptID != conceptID {
|
||
continue
|
||
}
|
||
if o.ProceedingTypeCode == nil {
|
||
return true
|
||
}
|
||
if *o.ProceedingTypeCode == proc {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: slug,
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search %q: %v", slug, err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
proc := ""
|
||
if p.Proceeding != nil {
|
||
proc = p.Proceeding.Code
|
||
}
|
||
if !isAllowed(c.Concept.ID, proc) {
|
||
t.Errorf("leaf %s leaked unauthorised pill: concept=%s proc=%s rule=%s",
|
||
slug, c.Concept.Slug, proc, p.RuleLocalCode)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func findCardBySlug(t *testing.T, resp *SearchResponse, slug string) ConceptCard {
|
||
t.Helper()
|
||
for _, c := range resp.Cards {
|
||
if c.Concept.Slug == slug {
|
||
return c
|
||
}
|
||
}
|
||
t.Fatalf("expected concept slug %q in cards, got: %v", slug, conceptSlugs(resp.Cards))
|
||
return ConceptCard{}
|
||
}
|
||
|
||
func conceptSlugs(cards []ConceptCard) []string {
|
||
out := make([]string, len(cards))
|
||
for i, c := range cards {
|
||
out[i] = c.Concept.Slug
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mustHaveLegalSource(t *testing.T, card ConceptCard, want string) {
|
||
t.Helper()
|
||
for _, p := range card.Pills {
|
||
if p.LegalSource != nil && *p.LegalSource == want {
|
||
return
|
||
}
|
||
}
|
||
t.Errorf("concept %q card missing pill with legal_source=%q. Got: %v", card.Concept.Slug, want, pillSources(card.Pills))
|
||
}
|
||
|
||
func mustHaveProceedingCodes(t *testing.T, card ConceptCard, codes ...string) {
|
||
t.Helper()
|
||
have := map[string]bool{}
|
||
for _, p := range card.Pills {
|
||
if p.Proceeding != nil {
|
||
have[p.Proceeding.Code] = true
|
||
}
|
||
}
|
||
for _, c := range codes {
|
||
if !have[c] {
|
||
t.Errorf("concept %q card missing proceeding pill %q. Got: %v", card.Concept.Slug, c, mapKeys(have))
|
||
}
|
||
}
|
||
}
|
||
|
||
func pillSources(pills []Pill) []string {
|
||
out := []string{}
|
||
for _, p := range pills {
|
||
if p.LegalSource != nil {
|
||
out = append(out, *p.LegalSource)
|
||
} else {
|
||
out = append(out, "(null)")
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mapKeys(m map[string]bool) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func startsWith(s, prefix string) bool {
|
||
if len(s) < len(prefix) {
|
||
return false
|
||
}
|
||
return s[:len(prefix)] == prefix
|
||
}
|