Files
paliad/internal/services/deadline_search_service_test.go
m b7470d7d77 fix(t-paliad-136): Phase A — filter narrowing carries (concept, proc) tuples
The v3 B1 decision tree filter collapsed each leaf's
(concept_id, proceeding_type_code) tuple list down to a flat concept_id
slice in EventCategoryService.ConceptIDsForSlug, dropping the per-leaf
proceeding constraint. The search service then loaded pills by
concept_id only, so picking a UPC-specific leaf still surfaced DE/EPA/
DPMA pills for any shared concept (Klageerwiderung, Replik, Duplik,
Berufungsschrift). m's repro: choosing CMS-Eingang → Gegenseite →
UPC Verletzung leaked national submissions.

Confirmed via DB: at least 25 leaves were over-broad pre-fix.

Fix carries the tuple set end-to-end via a new subtreeFilter type with
parallel uuid[] / text[] arrays. The matview SQL now uses
unnest($cids, $procs) AS t(cid, pcode) to match each row against the
allowed tuples — a junction row with NULL proc encodes "any proc for
this concept" (used by cross-cutting concepts like Wiedereinsetzung).

EventCategoryService gains AllOutcomes() for browse-all so the root
view also respects junction tuples. allMappedConceptIDs is gone.

Tests: added 5 v4 subtests under TestDeadlineSearch covering m's
repro slug, multi-tuple narrowing, trigger-pill cross-cutting,
forum AND-narrowing, plus an invariant regression gate that walks
every leaf with non-NULL proc and asserts no pill leaks. Skipped
when TEST_DATABASE_URL is unset; existing v3 assertions unchanged.

No schema change. No migration. Ships independently of Phases B/C.
2026-05-05 13:02:09 +02:00

514 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}
// 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, UPC_REV, UPC_PI,
// UPC_DAMAGES, UPC_DISCOVERY, DE_INF, DE_NULL, EPA_OPP, DPMA_OPP).
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, "UPC_INF", "DE_INF", "DE_NULL", "EPA_OPP", "DPMA_OPP")
})
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 4 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 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
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}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
}
}
})
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 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 or UPC_APP — 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 for this leaf:
// defence-to-counterclaim-for-revocation, application-to-amend,
// reply-to-defence. Every pill must be UPC_INF.
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
if p.Proceeding == nil || p.Proceeding.Code != "UPC_INF" {
code := "(nil)"
if p.Proceeding != nil {
code = p.Proceeding.Code
}
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-UPC_INF pill on %q: proc=%s",
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 subtree and add a forum chip that excludes
// UPC_INF — 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
}