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.
212 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|