Files
paliad/internal/services/projection_counterclaim_test.go
m 82888dea78 feat(t-paliad-174): SmartTimeline Slice 3 — projection parallel tracks + counterclaim handler
ProjectionService.For now composes multiple tracks instead of a single
"parent" stream. The viewed project always emits Track="parent"; visible
CCR children emit Track="counterclaim:<child_id>"; a project that is
itself a CCR (counterclaim_of != nil) pulls its target's events as
Track="parent_context:<parent_id>" so the lawyer working the CCR sees
the main proceeding without leaving the page (§4.5).

Each track runs the actuals + projection pipeline independently with
its own lookahead cap and dependency annotations against its own
proceeding's rule tree. SubProjectID + SubProjectTitle are populated on
non-parent rows so the frontend can render the sub-project title in the
column sub-header.

ProjectionMeta gains AvailableTracks; the handler surfaces it as the
new X-Projection-Tracks response header (CSV) so the wire shape stays
[]TimelineEvent (frozen since Slice 1).

POST /api/projects/{id}/counterclaim wraps ProjectService.CreateCounterclaim
— accepts proceeding_type_id / flip_our_side / title / case_number,
returns the new project's id + canonical /projects/<id> URL.

Tests: pure-function coverage for derivedCounterclaimOurSide (default
flip + R.49.2.b override + court/both pass-through). Live-DB integration
test covers the four invariants — CreateCounterclaim atomicity (parent
audit + child audit + our_side flip + sibling-under-patent placement),
parent's projection surfaces the counterclaim track, child's projection
surfaces parent_context, two-level CCR chains are rejected by both the
service guard and the schema-level trigger.
2026-05-09 16:07:37 +02:00

303 lines
12 KiB
Go

package services
// Live-DB integration test for the counterclaim sub-project shape
// (t-paliad-174 SmartTimeline Slice 3). Skipped without TEST_DATABASE_URL,
// matching the convention of the other live tests in this package.
//
// The test exercises the end-to-end shape:
// 1. CreateCounterclaim atomically creates child + flips our_side +
// writes audit rows on parent AND child + sets counterclaim_of.
// 2. parent_id of the child equals parent's parent_id (sibling-under-
// patent placement).
// 3. ProjectionService.For on the parent surfaces a parallel-track
// counterclaim event; AvailableTracks lists the new track.
// 4. ProjectionService.For on the child surfaces the parent's events
// with track="parent_context:<parent_id>".
// 5. Two-level CCR chains are rejected at the schema level.
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestCreateCounterclaim_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() // sibling parent: the patent hub
caseID := uuid.New() // the parent case (UPC_INF)
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
// proceeding_types seed because they're NOT NULL on the test row.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
t.Fatalf("resolve UPC_INF: %v", err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
t.Fatalf("resolve UPC_REV: %v", err)
}
cleanup := func() {
// Delete CCR children first (FK to caseID via counterclaim_of is
// ON DELETE SET NULL but the child rows still hold parent_id =
// patentID — clear them via a parent_id sweep).
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, 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, 'ccr-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, 'ccr-test@hlc.com', 'CCR Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Parent patent hub.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
VALUES ($1, 'patent', $1::text, 'EP3456789 — Test Patent', 'EP3456789', '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)
}
// Child case (UPC_INF) under the patent.
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', 'active', $3, $4, 'claimant')`,
caseID, patentID, userID, upcInf); err != nil {
t.Fatalf("seed 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)`,
caseID, userID); err != nil {
t.Fatalf("seed case team: %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)
t.Run("CreateCounterclaim flips our_side, places sibling, audits both", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// 1. counterclaim_of points at the parent.
if child.CounterclaimOf == nil || *child.CounterclaimOf != caseID {
t.Errorf("child.CounterclaimOf = %v, want %v", child.CounterclaimOf, caseID)
}
// 2. parent_id = parent's parent_id = patent hub (sibling-under-patent).
if child.ParentID == nil || *child.ParentID != patentID {
t.Errorf("child.ParentID = %v, want %v (sibling under patent)", child.ParentID, patentID)
}
// 3. our_side flipped: parent claimant → child defendant.
if child.OurSide == nil || *child.OurSide != "defendant" {
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
}
// 4. Default proceeding_type_id resolved to UPC_REV.
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
}
// 5. Auto-suggested title carries the patent reference + suffix.
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {
t.Errorf("child.Title = %q, want it to contain EP3456789 and Widerklage", child.Title)
}
// 6. Audit rows on BOTH parent and child with timeline_kind='milestone'.
var parentAudit, childAudit int
if err := pool.GetContext(ctx, &parentAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, caseID); err != nil {
t.Fatalf("count parent audit: %v", err)
}
if parentAudit != 1 {
t.Errorf("parent counterclaim_created rows = %d, want 1", parentAudit)
}
if err := pool.GetContext(ctx, &childAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, child.ID); err != nil {
t.Fatalf("count child audit: %v", err)
}
if childAudit != 1 {
t.Errorf("child counterclaim_created rows = %d, want 1", childAudit)
}
})
t.Run("ProjectionService.For on parent surfaces counterclaim track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, caseID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For parent: %v", err)
}
// AvailableTracks contains parent + the new counterclaim track.
expectTrack := "counterclaim:" + child.ID.String()
var sawCounterclaimTrack bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawCounterclaimTrack = true
break
}
}
if !sawCounterclaimTrack {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
// At least one row carries the counterclaim track + the
// SubProjectID = child.ID.
var countCCR int
for _, r := range rows {
if r.Track == expectTrack {
countCCR++
if r.SubProjectID == nil || *r.SubProjectID != child.ID {
t.Errorf("ccr-track row missing SubProjectID = child.ID")
}
}
}
if countCCR == 0 {
t.Errorf("expected at least one row on counterclaim track, saw 0 (rows=%d)", len(rows))
}
})
t.Run("ProjectionService.For on child surfaces parent_context track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, child.ID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For child: %v", err)
}
expectTrack := "parent_context:" + caseID.String()
var sawParentContext bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawParentContext = true
break
}
}
if !sawParentContext {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
var countParentCtx int
for _, r := range rows {
if r.Track == expectTrack {
countParentCtx++
if r.SubProjectID == nil || *r.SubProjectID != caseID {
t.Errorf("parent_context row missing SubProjectID = parent.ID")
}
}
}
if countParentCtx == 0 {
t.Errorf("expected at least one parent_context row, saw 0 (rows=%d)", len(rows))
}
})
t.Run("Two-level CCR chains are rejected at the schema level", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// Trying to create a CCR against the CCR child = two-level chain.
// CreateCounterclaim guards with an early ErrInvalidInput before
// hitting the trigger; verify the early guard fires.
_, err = projects.CreateCounterclaim(ctx, userID, child.ID, CounterclaimOpts{})
if err == nil {
t.Fatal("expected error for two-level CCR chain, got nil")
}
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
// Also pin the schema-level trigger guard: a direct INSERT
// pointing at a row that already has counterclaim_of NOT NULL
// must be rejected by paliad.projects_no_two_level_ccr.
grandchild := uuid.New()
_, err = pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by, counterclaim_of)
VALUES ($1, 'case', $2, $1::text, 'Grandchild CCR', 'active', $3, $4)`,
grandchild, patentID, userID, child.ID)
if err == nil {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, grandchild)
t.Fatal("expected schema trigger to reject grandchild CCR insert, got success")
}
if !strings.Contains(err.Error(), "two-level counterclaim") {
t.Errorf("trigger error message: %v (want two-level counterclaim)", err)
}
})
}