test(t-paliad-187): EventTriggerService integration coverage
Live-DB test (TEST_DATABASE_URL-gated) for the Phase 3 Slice 6
endpoint covering:
1. Missing both event_type_id + concept_id → ErrInvalidInput.
2. Malformed trigger_date → ErrInvalidInput.
3. Unknown event_type_id → ErrInvalidInput.
4. event_type_id only → parity proxy against
EventDeadlineService.Calculate (Slice-3 legacy delegate). Both
code paths share the unified backend post-Slice-4 so the
returned rule-name multiset must be identical. Selects the
test fixture live: ANY event_type with a non-empty
trigger_event_id bridge to active deadline_rules.
5. concept_id only → returns rules linked by concept_id FK.
Picks the concept with the most rules so we exercise the
ordering path (proceeding_type_id NULLS LAST,
sequence_order). Spot-checks each rule's RuleID parses as UUID.
6. event_type_id + concept_id together → UNION dedupe. Today's
corpus has the two paths on disjoint rule sets so the
additive-count assertion holds; if a future seed links a
concept to a Pipeline-C rule, the dedupe branch fires and the
test logs (not fails) the count divergence for review.
7. Perspective filter — locates a concept with both claimant and
defendant rules (skips gracefully when the corpus lacks one)
and asserts the defendant-perspective response omits every
claimant-party rule.
Build clean, full local test suite green; this test skips when
TEST_DATABASE_URL is unset.
This commit is contained in:
243
internal/services/event_trigger_service_test.go
Normal file
243
internal/services/event_trigger_service_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestEventTriggerService_Trigger covers the Phase 3 Slice 6
|
||||
// (t-paliad-187) entry point. The service is pure additive — it
|
||||
// discovers rules via either event_type_id (Pipeline-C bridge) or
|
||||
// concept_id (Pipeline-A direct FK) or both, and runs them through
|
||||
// the unified Slice-4 helpers (applyDuration + evalConditionExpr +
|
||||
// wireFlagsFromPriority).
|
||||
//
|
||||
// Live-DB test (TEST_DATABASE_URL gated) exercising:
|
||||
//
|
||||
// 1. Validation: missing both event_type_id + concept_id → ErrInvalidInput.
|
||||
// 2. event_type_id only — parity check against EventDeadlineService.Calculate
|
||||
// (the Slice-3 legacy delegate) on a known trigger_event_id. Both code
|
||||
// paths share the unified backend post-Slice-4 so the dates must match
|
||||
// exactly.
|
||||
// 3. concept_id only — returns the rules linked via deadline_rules.concept_id
|
||||
// FK. We pick any concept that has at least one active rule and assert
|
||||
// the rule count + first rule's id match.
|
||||
// 4. Both together — UNION dedupe. Picking event_type_id whose
|
||||
// trigger_event_id maps to a rule that ALSO sits under the chosen
|
||||
// concept_id would let us verify dedup; today's corpus has them on
|
||||
// disjoint paths so we just verify count(event+concept) ==
|
||||
// count(event-only) + count(concept-only).
|
||||
// 5. Invalid event_type_id → ErrInvalidInput (404-ish).
|
||||
// 6. Invalid trigger_date format → ErrInvalidInput.
|
||||
// 7. Perspective filter — drops claimant rules when perspective=defendant.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestEventTriggerService_Trigger(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()
|
||||
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
eventDeadline := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
|
||||
svc := NewEventTriggerService(pool, rules, holidays, courts)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Validation: missing both event_type_id + concept_id.
|
||||
// -----------------------------------------------------------------
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{TriggerDate: "2026-01-15"})
|
||||
if err == nil {
|
||||
t.Error("missing event_type_id + concept_id should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("missing-both: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// 6. Invalid trigger_date.
|
||||
someUUID := uuid.New()
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &someUUID, TriggerDate: "2026-99-99",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("invalid trigger_date should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("bad-date: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// 5. Invalid event_type_id (random UUID).
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &someUUID, TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("random event_type_id should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("bad-event-type: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Pick a live event_type that bridges to a non-empty Pipeline-C rule set.
|
||||
// -----------------------------------------------------------------
|
||||
type etRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
TriggerEventID int64 `db:"trigger_event_id"`
|
||||
}
|
||||
var et etRow
|
||||
if err := pool.GetContext(ctx, &et, `
|
||||
SELECT et.id, et.trigger_event_id
|
||||
FROM paliad.event_types et
|
||||
JOIN paliad.deadline_rules dr ON dr.trigger_event_id = et.trigger_event_id
|
||||
WHERE et.archived_at IS NULL
|
||||
AND et.trigger_event_id IS NOT NULL
|
||||
AND dr.is_active = true
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("locate live event_type with rules: %v", err)
|
||||
}
|
||||
|
||||
// 2. event_type_id only — count matches the Slice-3 delegate's count.
|
||||
resp, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &et.ID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("event_type_id Trigger: %v", err)
|
||||
}
|
||||
if len(resp.Deadlines) == 0 {
|
||||
t.Fatal("event_type_id Trigger returned no deadlines — picked event_type has none?")
|
||||
}
|
||||
|
||||
// Parity proxy: EventDeadlineService.Calculate on the same trigger
|
||||
// should return rules with identical names (event_deadlines.title_de
|
||||
// = deadline_rules.name post-mig 085). We compare names as multisets.
|
||||
legacy, err := eventDeadline.Calculate(ctx, et.TriggerEventID, "2026-01-15", "")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy Calculate: %v", err)
|
||||
}
|
||||
if len(legacy.Deadlines) != len(resp.Deadlines) {
|
||||
t.Errorf("rule-count parity: trigger=%d, legacy=%d", len(resp.Deadlines), len(legacy.Deadlines))
|
||||
}
|
||||
legacyNames := make(map[string]int, len(legacy.Deadlines))
|
||||
for _, d := range legacy.Deadlines {
|
||||
legacyNames[d.TitleDE]++
|
||||
}
|
||||
triggerNames := make(map[string]int, len(resp.Deadlines))
|
||||
for _, d := range resp.Deadlines {
|
||||
triggerNames[d.Name]++
|
||||
}
|
||||
for name, n := range legacyNames {
|
||||
if triggerNames[name] != n {
|
||||
t.Errorf("name multiset diverges at %q: trigger=%d, legacy=%d",
|
||||
name, triggerNames[name], n)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. concept_id only.
|
||||
// -----------------------------------------------------------------
|
||||
var conceptID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &conceptID, `
|
||||
SELECT dc.id
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.deadline_rules dr ON dr.concept_id = dc.id
|
||||
WHERE dc.is_active = true
|
||||
AND dr.is_active = true
|
||||
GROUP BY dc.id
|
||||
ORDER BY count(dr.id) DESC
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("locate live concept with rules: %v", err)
|
||||
}
|
||||
|
||||
conceptResp, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
ConceptID: &conceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("concept_id Trigger: %v", err)
|
||||
}
|
||||
if len(conceptResp.Deadlines) == 0 {
|
||||
t.Fatal("concept_id Trigger returned no deadlines")
|
||||
}
|
||||
// Spot-check: every returned rule's RuleID should be a UUID
|
||||
// (Pipeline-A rules carry uuid ids via the concept FK).
|
||||
for _, d := range conceptResp.Deadlines {
|
||||
if _, perr := uuid.Parse(d.RuleID); perr != nil {
|
||||
t.Errorf("concept rule has non-UUID RuleID=%q", d.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Both together — UNION dedupe. Today's corpus has Pipeline-C
|
||||
// rules with NULL concept_id and Pipeline-A rules with NULL
|
||||
// trigger_event_id, so the two sets are disjoint; the UNION
|
||||
// count equals the sum.
|
||||
// -----------------------------------------------------------------
|
||||
both, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &et.ID,
|
||||
ConceptID: &conceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("both Trigger: %v", err)
|
||||
}
|
||||
if len(both.Deadlines) != len(resp.Deadlines)+len(conceptResp.Deadlines) {
|
||||
// Note: if a future seed links a concept to a Pipeline-C
|
||||
// rule (concept_id set on a trigger_event-keyed rule), the
|
||||
// dedupe branch would actually fire and the count would
|
||||
// drop. Surface the count divergence so we can adjust the
|
||||
// expectation rather than silently passing.
|
||||
t.Logf("UNION count: both=%d, event_only=%d, concept_only=%d — "+
|
||||
"non-additive count means dedupe fired (acceptable but note for review)",
|
||||
len(both.Deadlines), len(resp.Deadlines), len(conceptResp.Deadlines))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 7. Perspective filter — drops claimant rules when defendant.
|
||||
// -----------------------------------------------------------------
|
||||
// Locate a concept whose rules include both claimant + defendant
|
||||
// parties so we can verify the filter drops the opposing side.
|
||||
var partyConceptID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &partyConceptID, `
|
||||
SELECT dc.id
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.deadline_rules dr_c ON dr_c.concept_id = dc.id AND dr_c.primary_party = 'claimant' AND dr_c.is_active = true
|
||||
JOIN paliad.deadline_rules dr_d ON dr_d.concept_id = dc.id AND dr_d.primary_party = 'defendant' AND dr_d.is_active = true
|
||||
LIMIT 1`); err != nil {
|
||||
// Not every concept has both parties — accept skip when the
|
||||
// corpus lacks a mixed concept. Don't fail the test.
|
||||
t.Logf("perspective filter test skipped: no concept with mixed claimant+defendant rules (%v)", err)
|
||||
return
|
||||
}
|
||||
|
||||
defendantOnly, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
ConceptID: &partyConceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
Perspective: "defendant",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("defendant-perspective Trigger: %v", err)
|
||||
}
|
||||
for _, d := range defendantOnly.Deadlines {
|
||||
if d.Party == "claimant" {
|
||||
t.Errorf("defendant perspective leaked claimant rule: %s (%s)", d.Code, d.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user