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.
216 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
|