From 150da9d186caaf232c99d685437ac907953f8aff Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 3 Jun 2026 11:51:29 -0500 Subject: [PATCH] test(apps,fs): update availability + listing tests for flat-peer layout availability_test: tools resolve via the peer cascade (classifier on incoming/working/staging, transmittal on staging, tables on mdl/rsk/ssr). tree_test: drop the abandoned per-user-home + folder-nav virtual tests; add an mdl/ cross-party aggregate-listing test; repoint empty-when-missing to the declared peers. --- zddc/internal/apps/availability_test.go | 99 +++++----- zddc/internal/fs/tree.go | 4 +- zddc/internal/fs/tree_test.go | 230 ++++-------------------- 3 files changed, 87 insertions(+), 246 deletions(-) diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 7da756d..99d657b 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -11,48 +11,50 @@ func TestAppAvailableAt(t *testing.T) { dir, app string want bool }{ - // archive: everywhere + // archive: everywhere (baseline) {root, "archive", true}, {root + "/Project-A", "archive", true}, - {root + "/Project-A/working", "archive", true}, + {root + "/Project-A/working/ACME", "archive", true}, {root + "/Project-A/some-other-folder", "archive", true}, // landing: only at root {root, "landing", true}, {root + "/Project-A", "landing", false}, - // classifier: per-party working/, staging/, incoming/ subtrees + // classifier: the inbound/draft workspace peers (incoming/working/staging) {root, "classifier", false}, {root + "/Project-A", "classifier", false}, - {root + "/Project-A/archive/ACME/working", "classifier", true}, - {root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true}, - {root + "/Project-A/archive/ACME/staging", "classifier", true}, - {root + "/Project-A/archive/ACME/staging/2026-06-15_x (DFT) - y", "classifier", true}, - {root + "/Project-A/archive/ACME/incoming", "classifier", true}, - {root + "/Project-A/archive/ACME/incoming/sub", "classifier", true}, + {root + "/Project-A/working/ACME", "classifier", true}, + {root + "/Project-A/working/ACME/deep/nested/path", "classifier", true}, + {root + "/Project-A/staging/ACME", "classifier", true}, + {root + "/Project-A/staging/ACME/2026-06-15_x (DFT) - y", "classifier", true}, + {root + "/Project-A/incoming/ACME", "classifier", true}, + {root + "/Project-A/incoming/ACME/sub", "classifier", true}, + {root + "/Project-A/reviewing/ACME", "classifier", false}, {root + "/Project-A/archive/ACME/received", "classifier", false}, - {root + "/Project-A/archive/ACME/issued", "classifier", false}, - {root + "/Project-A/archive/ACME/mdl", "classifier", false}, + {root + "/Project-A/mdl/ACME", "classifier", false}, {root + "/Project-A/some-other-folder", "classifier", false}, - // browse: universal — every directory has browse available - // (it's in the embedded-defaults baseline available_tools). - {root + "/Project-A/archive/ACME/working", "browse", true}, - {root + "/Project-A/archive/ACME/working/sub", "browse", true}, - {root + "/Project-A/archive/ACME/staging", "browse", true}, - {root + "/Project-A/archive/ACME/incoming", "browse", true}, + // browse: universal (baseline) + {root + "/Project-A/working/ACME", "browse", true}, + {root + "/Project-A/staging/ACME", "browse", true}, + {root + "/Project-A/incoming/ACME", "browse", true}, - // transmittal: per-party staging/ only - {root + "/Project-A/archive/ACME/staging", "transmittal", true}, - {root + "/Project-A/archive/ACME/staging/sub", "transmittal", true}, - {root + "/Project-A/archive/ACME/working", "transmittal", false}, + // transmittal: the staging peer only + {root + "/Project-A/staging/ACME", "transmittal", true}, + {root + "/Project-A/staging/ACME/sub", "transmittal", true}, + {root + "/Project-A/working/ACME", "transmittal", false}, {root + "/Project-A/archive/ACME/issued", "transmittal", false}, - // case-fold: any case of canonical names matches - {root + "/Project-A/archive/ACME/Staging", "transmittal", true}, - {root + "/Project-A/archive/ACME/STAGING", "transmittal", true}, - {root + "/Project-A/archive/ACME/Incoming", "classifier", true}, - {root + "/Project-A/Archive/ACME/incoming", "classifier", true}, + // tables: the register peers + {root + "/Project-A/mdl/ACME", "tables", true}, + {root + "/Project-A/rsk/ACME", "tables", true}, + {root + "/Project-A/ssr", "tables", true}, + + // case-fold: any case of a peer name matches + {root + "/Project-A/Staging/ACME", "transmittal", true}, + {root + "/Project-A/STAGING/ACME", "transmittal", true}, + {root + "/Project-A/Incoming/ACME", "classifier", true}, // unknown app {root + "/Project-A", "weird", false}, @@ -73,45 +75,36 @@ func TestDefaultAppAt(t *testing.T) { dir string want string }{ - // At the deployment root itself, no default tool — landing handles - // the project picker via a separate path. + // Deployment root + bare project root: no default tool. {root, ""}, - // Bare project root: no default. Trailing-slash URL serves browse; - // no-slash falls through to the redirect. {root + "/Project-A", ""}, - // Project-level virtual aggregators (sibling to archive/). + // Top-level peers. {root + "/Project-A/working", "browse"}, - {root + "/Project-A/staging", "browse"}, + {root + "/Project-A/staging", "transmittal"}, {root + "/Project-A/reviewing", "browse"}, + {root + "/Project-A/incoming", "classifier"}, {root + "/Project-A/ssr", "tables"}, {root + "/Project-A/mdl", "tables"}, {root + "/Project-A/rsk", "tables"}, - // Per-party lifecycle slots (the real physical homes). - {root + "/Project-A/archive/Acme/working", "browse"}, - {root + "/Project-A/archive/Acme/working/alice@example.com", "browse"}, - {root + "/Project-A/archive/Acme/working/2026-06-15_x (DFT) - y", "browse"}, - {root + "/Project-A/archive/Acme/staging", "transmittal"}, - {root + "/Project-A/archive/Acme/staging/2026-06-15_x (DFT) - y", "transmittal"}, - {root + "/Project-A/archive/Acme/reviewing", "browse"}, - // archive: at the archive root, party folders default to archive. - // Per-party subfolders override per their function: - // incoming → classifier (the bulk-rename workflow) - // received / issued → archive (WORM record browser) + // Per-party subdirs inherit the peer default. + {root + "/Project-A/working/Acme", "browse"}, + {root + "/Project-A/working/Acme/2026-06-15_x (DFT) - y", "browse"}, + {root + "/Project-A/staging/Acme", "transmittal"}, + {root + "/Project-A/incoming/Acme", "classifier"}, + {root + "/Project-A/mdl/Acme", "tables"}, + {root + "/Project-A/mdl/Acme/anything-deeper", "tables"}, + {root + "/Project-A/rsk/Acme", "tables"}, + // The committed record: archive subtree → archive tool. {root + "/Project-A/archive", "archive"}, {root + "/Project-A/archive/Acme", "archive"}, - {root + "/Project-A/archive/Acme/incoming", "classifier"}, - {root + "/Project-A/archive/Acme/issued", "archive"}, {root + "/Project-A/archive/Acme/received", "archive"}, - // mdl/rsk win over the broader archive rule. - {root + "/Project-A/archive/Acme/mdl", "tables"}, - {root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"}, - {root + "/Project-A/archive/Acme/rsk", "tables"}, + {root + "/Project-A/archive/Acme/issued", "archive"}, // Random non-canonical folder names → no default. {root + "/Project-A/scratch", ""}, - // Case-fold on canonical names. - {root + "/Project-A/archive/Acme/Working", "browse"}, - {root + "/Project-A/archive/Acme/STAGING", "transmittal"}, - {root + "/Project-A/Archive/Acme/MDL", "tables"}, + // Case-fold on peer names. + {root + "/Project-A/Working/Acme", "browse"}, + {root + "/Project-A/STAGING/Acme", "transmittal"}, + {root + "/Project-A/MDL/Acme", "tables"}, } for _, tc := range cases { t.Run(tc.dir, func(t *testing.T) { diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 49ee170..596cd5d 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -66,8 +66,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // written into them — but the cascade (defaults.zddc.yaml // plus any on-disk overrides) declares them via paths:, so // the stage-strip / file nav can link unconditionally. - // Returning [] gives a usable empty view; the - // virtualUserHomeEntry below still fires for working/. + // Returning [] gives a usable empty view (the tables peers + // still surface their synthetic spec entries below). if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) { entries = nil } else { diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index cd067b6..3900363 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -21,157 +21,13 @@ func setupTreeRoot(t *testing.T) string { return root } -// Per-user homes now live at archive//working// (depth- -// 4). The virtual entry fires when listing that path for a viewer -// whose home doesn't yet exist on disk. - -func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) { +// Listing a cascade-declared peer that doesn't exist on disk yet returns +// an empty slice instead of os.ErrNotExist, so fresh projects don't 404. +// (The tables peers mdl/rsk/ssr instead surface their synthetic spec +// entries; see TestListDirectory_MdlAggregate.) +func TestListDirectory_DeclaredPeerEmptyWhenMissing(t *testing.T) { root := setupTreeRoot(t) - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/working", "alice@example.com", - "/Proj/archive/Acme/working/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - - var virtual *string - for i := range got { - if got[i].Virtual { - n := got[i].Name - virtual = &n - } - } - if virtual == nil { - t.Fatalf("expected synthetic / entry, got entries: %+v", got) - } - if *virtual != "alice@example.com/" { - t.Errorf("synthetic name = %q, want alice@example.com/", *virtual) - } -} - -func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) { - root := setupTreeRoot(t) - // A real folder exists for the viewer (any case). - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/working", "alice@example.com", - "/Proj/archive/Acme/working/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, fi := range got { - if fi.Virtual { - t.Errorf("synthetic entry should be suppressed when a case-fold match exists; got %+v", fi) - } - } -} - -func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) { - root := setupTreeRoot(t) - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/working", "" /* no viewer */, - "/Proj/archive/Acme/working/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, fi := range got { - if fi.Virtual { - t.Errorf("anonymous viewer should not see synthetic entries; got %+v", fi) - } - } -} - -func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) { - root := setupTreeRoot(t) - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/staging", "alice@example.com", - "/Proj/archive/Acme/staging/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, fi := range got { - if fi.Virtual { - t.Errorf("staging/ should not have a synthetic user-home entry; got %+v", fi) - } - } -} - -func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) { - root := setupTreeRoot(t) - // Listing inside working// — no synthetic entry should fire. - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/working/alice@example.com", "alice@example.com", - "/Proj/archive/Acme/working/alice@example.com/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - for _, fi := range got { - if fi.Virtual { - t.Errorf("nested working/ subdir must not synthesise the user home; got %+v", fi) - } - } -} - -func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) { - root := setupTreeRoot(t) - // Pre-existing PascalCase Working/ under archive//. - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil { - t.Fatal(err) - } - zddc.InvalidateCache(root) - - got, err := ListDirectory(context.Background(), nil, root, - "Proj/archive/Acme/Working", "alice@example.com", - "/Proj/archive/Acme/Working/", false, false) - if err != nil { - t.Fatalf("list: %v", err) - } - var found bool - for _, fi := range got { - if fi.Virtual { - found = true - } - } - if !found { - t.Errorf("PascalCase Working/ should still surface the synthetic entry; got entries: %+v", got) - } -} - -// Listing a canonical-folder path that doesn't exist on disk yet -// returns an empty slice instead of os.ErrNotExist. The stage-strip -// nav links into /archive/ etc. unconditionally; this keeps -// fresh projects from 404'ing. -// -// The synthetic per-user home entry fires for the in-party working -// slot; other canonical slots return a plain empty listing. -func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { - root := setupTreeRoot(t) - // Proj exists; the party folder skeleton does not. - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"), @@ -180,25 +36,16 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { } zddc.InvalidateCache(root) - for _, stage := range []string{"working", "staging", "reviewing", "incoming"} { - dirPath := "Proj/archive/Acme/" + stage - baseURL := "/" + dirPath + "/" + for _, peer := range []string{"working", "staging", "reviewing", "incoming"} { + dirPath := "Proj/" + peer got, err := ListDirectory(context.Background(), nil, root, - dirPath, "alice@example.com", baseURL, false, false) + dirPath, "alice@example.com", "/"+dirPath+"/", false, false) if err != nil { - t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err) + t.Errorf("ListDirectory(%s) on missing peer: err = %v, want nil", dirPath, err) continue } - // working/ surfaces a synthetic / entry; the - // others should be a flat empty listing. - if stage == "working" { - if len(got) != 1 || !got[0].Virtual { - t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got) - } - } else { - if len(got) != 0 { - t.Errorf("ListDirectory(%s) on missing dir: got %+v, want empty", dirPath, got) - } + if len(got) != 0 { + t.Errorf("ListDirectory(%s) on missing peer: got %+v, want empty", dirPath, got) } } } @@ -224,37 +71,44 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { } } -// Project-level folder-nav virtual lists only the parties that have -// non-empty content in the slot. Empty/missing party slots are -// filtered out. -func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) { +// The mdl/ peer root renders the cross-party AGGREGATE: one entry per +// physical mdl//*.yaml (real URLs), not a folder-nav of party +// dirs. Spec entries (table.yaml/form.yaml) are advertised too. +func TestListDirectory_MdlAggregate(t *testing.T) { root := setupTreeRoot(t) - // Acme has a populated working/; Beta is scaffolded but empty. - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "Proj", "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Beta", "working"), 0o755); err != nil { - t.Fatal(err) + mk := func(p string) { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil { + t.Fatal(err) + } } + mk(filepath.Join(root, "Proj", "mdl", "Acme", "D-001.yaml")) + mk(filepath.Join(root, "Proj", "mdl", "Beta", "D-009.yaml")) zddc.InvalidateCache(root) got, err := ListDirectory(context.Background(), nil, root, - "Proj/working", "alice@example.com", "/Proj/working/", false, false) + "Proj/mdl", "alice@example.com", "/Proj/mdl/", false, false) if err != nil { t.Fatalf("list: %v", err) } - var partyDirs []string + rowURLs := map[string]bool{} for _, fi := range got { - if fi.IsDir && fi.Virtual { - partyDirs = append(partyDirs, fi.Name) + if !fi.IsDir { + rowURLs[fi.URL] = true } } - want := []string{"Acme/"} - if len(partyDirs) != 1 || partyDirs[0] != want[0] { - t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want) + for _, want := range []string{"/Proj/mdl/Acme/D-001.yaml", "/Proj/mdl/Beta/D-009.yaml"} { + if !rowURLs[want] { + t.Errorf("aggregate listing missing row %q; got %+v", want, got) + } + } + // No party SUBDIR entries in the aggregate (rows, not folders). + for _, fi := range got { + if fi.IsDir { + t.Errorf("aggregate mdl/ should not list party dirs; got dir %q", fi.Name) + } } } @@ -301,9 +155,6 @@ func TestListDirectory_VerbsPerEntry(t *testing.T) { if fi.Verbs != want { t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want) } - // Writable stays in lockstep with verbs for the transition - // window — w bit for files, r/c semantics for dirs (no - // Writable on dirs today; we don't assert it). if !fi.IsDir { wantWritable := want == "rw" if fi.Writable != wantWritable { @@ -330,7 +181,6 @@ func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) { } zddc.InvalidateCache(root) - // Elevated admin sees rwcda everywhere. got, err := ListDirectory(context.Background(), nil, root, "Proj", "admin@example.com", "/Proj/", false, true /* elevated */) if err != nil { @@ -342,8 +192,6 @@ func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) { } } - // Same admin un-elevated sees nothing (no explicit ACL grant, - // admin bypass disabled). got, err = ListDirectory(context.Background(), nil, root, "Proj", "admin@example.com", "/Proj/", false, false) if err != nil {