Files
paliad/internal/services/export_project_test.go
mAi 8f1f88b517 feat(export): t-paliad-214 Slice 2 backend — project-subtree sync export
Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.

§4 permission gate enforced server-side:
  - global_admin can export anything, OR
  - direct project_teams membership with responsibility ∈ {lead, member}
  - Observers + Externals + derived-only partner-unit users → 403
    bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
    vorbehalten / Data export is restricted to project team members").

Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.

Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.

Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.

__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.

ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.

8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
2026-05-20 13:03:57 +02:00

216 lines
6.8 KiB
Go

package services
// Tests for the Slice 2 (project-subtree) sheet registry. Pure-function
// shape tests — live-DB integration coverage of the SQL itself stays in
// the existing query patterns the personal-scope tests already cover.
import (
"strings"
"testing"
"time"
"github.com/google/uuid"
)
// TestProjectSheetQueries_RegistryShape pins the sheet inventory + the
// design's §2 contract: every entity sheet binds rootID as $1, and the
// approval_policies sheet ships with all three sources (project +
// ancestor + partner_unit_default).
func TestProjectSheetQueries_RegistryShape(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
qs := projectSheetQueries(rootID, false)
wantSheets := []string{
"projects",
"project_teams",
"project_partner_units",
"deadlines",
"appointments",
"parties",
"notes",
"documents",
"project_events",
"approval_requests",
"approval_policies",
"checklist_instances",
"partner_units",
"partner_unit_members",
"users_referenced",
"system_audit_log_subset",
"ref__proceeding_types",
"ref__event_types",
"ref__event_categories",
"ref__deadline_rules",
"ref__deadline_concepts",
"ref__courts",
"ref__countries",
"ref__holidays",
}
gotSheets := []string{}
for _, q := range qs {
gotSheets = append(gotSheets, q.SheetName)
}
if len(gotSheets) != len(wantSheets) {
t.Fatalf("sheet count = %d, want %d (got %v)", len(gotSheets), len(wantSheets), gotSheets)
}
for i, want := range wantSheets {
if gotSheets[i] != want {
t.Errorf("sheet[%d] = %q, want %q", i, gotSheets[i], want)
}
}
// Every NON-reference sheet binds rootID as $1.
for _, q := range qs {
if strings.HasPrefix(q.SheetName, "ref__") {
if len(q.Args) != 0 {
t.Errorf("ref sheet %q has %d args, want 0", q.SheetName, len(q.Args))
}
continue
}
if len(q.Args) != 1 {
t.Errorf("entity sheet %q has %d args, want 1", q.SheetName, len(q.Args))
continue
}
if got, ok := q.Args[0].(uuid.UUID); !ok || got != rootID {
t.Errorf("entity sheet %q first arg = %v, want rootID %v", q.SheetName, q.Args[0], rootID)
}
}
}
// TestProjectSheetQueries_ApprovalPoliciesTripleSource verifies that the
// approval_policies sheet's SQL carries all three source tags so an
// importer can reconstruct the effective gate (Q4 lock-in).
func TestProjectSheetQueries_ApprovalPoliciesTripleSource(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
var found *sheetQuery
for i := range qs {
if qs[i].SheetName == "approval_policies" {
found = &qs[i]
break
}
}
if found == nil {
t.Fatal("approval_policies sheet missing from registry")
}
for _, src := range []string{
`'project'::text AS source`,
`'ancestor'::text AS source`,
`'partner_unit_default'::text AS source`,
} {
if !strings.Contains(found.SQL, src) {
t.Errorf("approval_policies SQL missing %q tag — Q4 triple-source attribution broken.\nSQL:\n%s",
src, found.SQL)
}
}
}
// TestProjectSheetQueries_DirectOnlyNarrowsSubtree pins that direct_only=true
// produces a subtree subquery resolving to exactly the root (no LIKE-walk).
func TestProjectSheetQueries_DirectOnlyNarrowsSubtree(t *testing.T) {
subtreeAll := projectSubtreeProjectIDsSQL(false)
subtreeRoot := projectSubtreeProjectIDsSQL(true)
if !strings.Contains(subtreeAll, `LIKE r.path`) {
t.Errorf("default subtree SQL missing path-LIKE descendant walk:\n%s", subtreeAll)
}
if strings.Contains(subtreeRoot, `LIKE`) {
t.Errorf("direct_only subtree SQL still has LIKE walk — should be root-only:\n%s", subtreeRoot)
}
if !strings.Contains(subtreeRoot, `$1::uuid`) {
t.Errorf("direct_only subtree SQL missing $1::uuid root reference:\n%s", subtreeRoot)
}
}
// TestProjectSheetQueries_NoPersonalSidecars guards against an accidental
// inclusion of personal sidecars (caldav config, views, pins, paliadin
// turns) in the project-scope export. These are per-user, not per-project,
// and don't belong in a matter handover.
func TestProjectSheetQueries_NoPersonalSidecars(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
switch q.SheetName {
case "my_caldav_config", "my_views", "my_pinned_projects", "my_card_layouts", "my_paliadin_turns", "me":
t.Errorf("project-scope export must not include personal sidecar sheet %q", q.SheetName)
}
// Also defence-in-depth on the SQL: no SELECT from
// user_caldav_config or paliadin_turns from project scope.
if strings.Contains(q.SQL, "user_caldav_config") {
t.Errorf("sheet %q SQL touches user_caldav_config — never in project scope", q.SheetName)
}
if strings.Contains(q.SQL, "paliadin_turns") {
t.Errorf("sheet %q SQL touches paliadin_turns — never in project scope", q.SheetName)
}
}
}
// TestProjectSheetQueries_AttachedPartnerUnitsOnly pins that the
// partner_units sheet is filtered to attached units only (not the full
// org chart).
func TestProjectSheetQueries_AttachedPartnerUnitsOnly(t *testing.T) {
qs := projectSheetQueries(uuid.New(), false)
for _, q := range qs {
if q.SheetName != "partner_units" {
continue
}
if !strings.Contains(q.SQL, "project_partner_units") {
t.Errorf("partner_units sheet SQL must filter via project_partner_units (got attached-only requirement):\n%s",
q.SQL)
}
return
}
t.Fatal("partner_units sheet missing from registry")
}
// TestShortUUIDSuffix_ReturnsLast8Hex pins the §3 filename disambiguator
// shape — Q5 lock-in.
func TestShortUUIDSuffix_ReturnsLast8Hex(t *testing.T) {
cases := []struct {
in uuid.UUID
want string
}{
{uuid.Nil, ""},
{uuid.MustParse("11111111-1111-1111-1111-aaaaaaaaaaaa"), "aaaaaaaaaaaa"},
{uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb"), "a89469e2cacb"},
}
for _, c := range cases {
got := shortUUIDSuffix(c.in)
if got != c.want {
t.Errorf("shortUUIDSuffix(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestMetaToKeyValueRows_ProjectScopeRows verifies that project-scope
// meta picks up scope_root_label + scope_root_path + direct_only rows
// (so the __meta sheet carries Q6 lock-in details).
func TestMetaToKeyValueRows_ProjectScopeRows(t *testing.T) {
rootID := uuid.MustParse("61e3fb9e-29fb-44aa-867e-a89469e2cacb")
m := ExportMeta{
SchemaVersion: 1,
FirmName: "HLC",
Scope: ExportScopeProject,
ScopeRootID: &rootID,
ScopeRootLabel: "Siemens AG",
ScopeRootPath: "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
DirectOnly: false,
GeneratedAt: time.Date(2026, 5, 20, 14, 23, 0, 0, time.UTC),
RowCounts: map[string]int{},
}
rows := metaToKeyValueRows(m)
want := map[string]string{
"scope_root_label": "Siemens AG",
"scope_root_path": "61e3fb9e_29fb_44aa_867e_a89469e2cacb",
"direct_only": "FALSE",
}
seen := map[string]string{}
for _, r := range rows {
seen[r[0]] = r[1]
}
for k, v := range want {
if seen[k] != v {
t.Errorf("meta key %q = %q, want %q (full rows: %v)", k, seen[k], v, rows)
}
}
}