Files
paliad/internal/services/projection_anchor_cross_test.go
mAi 3ff1b23238 fix(timeline): t-paliad-237 — anchor lookup must traverse linked proceedings
On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.

The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.

Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).

Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
2026-05-22 23:43:15 +02:00

212 lines
8.3 KiB
Go

package services
// Live-DB integration test for the CCR-anchors-inf-rule path
// (t-paliad-237). The SmartTimeline on a CCR project surfaces parent
// inf rules in the parent_context lane; clicking "Datum setzen" on
// those rows used to bubble up as a generic 500 because the anchor
// lookup was scoped to the CCR's own proceeding_type_id. The service
// now detects the cross-proceeding case and rejects with a structured
// error pointing at the parent project — verified end-to-end here.
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestRecordAnchor_CrossProceeding_RejectsWithParentPointer_Live(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()
userID := uuid.New()
patentID := uuid.New()
parentCaseID := uuid.New()
// Resolve upc.inf.cfi + upc.rev.cfi ids.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCInfringement); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCInfringement, err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCRevocation); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCRevocation, err)
}
// Seed a unique inf-only rule for this test so the assertion does
// not couple to the live seed corpus.
infRuleID := uuid.New()
infRuleCode := "test.t237.infonly." + uuid.NewString()[:8]
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE id = $1`, infRuleID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, parentCaseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, parentCaseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, parentCaseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, parentCaseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, parentCaseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, parentCaseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'cross-anchor-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'cross-anchor-test@hlc.com', 'Cross-Anchor Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
VALUES ($1, 'patent', $1::text, 'EP-T237 — Test Patent', 'EP-T237', 'active', $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
proceeding_type_id, our_side)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text,
'UPC-CFI München — Klage (T237)', 'active', $3, $4, 'claimant')`,
parentCaseID, patentID, userID, upcInf); err != nil {
t.Fatalf("seed parent case: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
parentCaseID, userID); err != nil {
t.Fatalf("seed parent team: %v", err)
}
// Inf-only rule. Lives under upc.inf.cfi (upcInf), NOT upc.rev.cfi.
if _, err := pool.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', $1, true)`,
"t-paliad-237 cross-anchor test seed"); err != nil {
t.Fatalf("set audit_reason: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadline_rules
(id, proceeding_type_id, submission_code, name, name_en,
duration_value, duration_unit, timing, is_court_set, is_spawn,
sequence_order, is_active, priority,
lifecycle_state, created_at, updated_at)
VALUES ($1, $2, $3, 'Klageschrift (T237)', 'Statement of Claim (T237)',
0, 'days', 'after', false, false,
0, true, 'mandatory', 'published', now(), now())`,
infRuleID, upcInf, infRuleCode); err != nil {
t.Fatalf("seed inf rule: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fristen := NewFristenrechnerService(rules, holidays, courts)
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
// Create the CCR child. Default proceeding_type → upc.rev.cfi.
child, err := projects.CreateCounterclaim(ctx, userID, parentCaseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Fatalf("CCR child proceeding_type_id = %v, want upcRev (%d)", child.ProceedingTypeID, upcRev)
}
// Anchor attempt #1: rule lives in the parent's proceeding type, not
// the CCR's. Must reject with CrossProceedingAnchorError.
_, err = projection.RecordAnchor(ctx, userID, child.ID, AnchorInput{
RuleCode: infRuleCode,
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
})
if err == nil {
t.Fatal("expected cross-proceeding rejection, got nil")
}
cpe, ok := IsCrossProceedingAnchor(err)
if !ok {
t.Fatalf("expected CrossProceedingAnchorError, got %T: %v", err, err)
}
if cpe.RequestedRuleCode != infRuleCode {
t.Errorf("RequestedRuleCode = %q, want %q", cpe.RequestedRuleCode, infRuleCode)
}
if cpe.ParentProjectID != parentCaseID {
t.Errorf("ParentProjectID = %v, want %v", cpe.ParentProjectID, parentCaseID)
}
if cpe.RequestedRuleNameDE != "Klageschrift (T237)" {
t.Errorf("RequestedRuleNameDE = %q", cpe.RequestedRuleNameDE)
}
if cpe.RequestedRuleNameEN != "Statement of Claim (T237)" {
t.Errorf("RequestedRuleNameEN = %q", cpe.RequestedRuleNameEN)
}
// Anchor attempt #2: same rule code, anchored on the PARENT inf
// project. Must succeed — the legitimate happy path stays intact.
res, err := projection.RecordAnchor(ctx, userID, parentCaseID, AnchorInput{
RuleCode: infRuleCode,
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("anchor on parent inf project failed: %v", err)
}
if res == nil || res.DeadlineID == nil {
t.Fatalf("expected DeadlineID set on anchor result, got %+v", res)
}
// Anchor attempt #3: rule code that exists in NEITHER project — must
// still return the legacy "unknown submission_code" error, not the
// cross-proceeding error.
_, err = projection.RecordAnchor(ctx, userID, child.ID, AnchorInput{
RuleCode: "test.t237.nonexistent." + uuid.NewString()[:8],
ActualDate: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
})
if err == nil {
t.Fatal("expected error for unknown rule, got nil")
}
if _, ok := IsCrossProceedingAnchor(err); ok {
t.Errorf("unknown rule should NOT surface as cross-proceeding error: %v", err)
}
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("unknown rule should wrap ErrInvalidInput, got %v", err)
}
}