ZDDC/zddc/internal/fs/tree_test.go
ZDDC 59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00

259 lines
8.2 KiB
Go

package fs
import (
"context"
"os"
"path/filepath"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
func setupTreeRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
// Permissive root .zddc so subdirectory ACL checks pass.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
return root
}
// Per-user homes now live at archive/<party>/working/<email>/ (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) {
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 <viewer-email>/ 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/<email>/ — 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/<party>/.
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 <project>/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 {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
for _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
dirPath := "Proj/archive/Acme/" + stage
baseURL := "/" + dirPath + "/"
got, err := ListDirectory(context.Background(), nil, root,
dirPath, "alice@example.com", baseURL, false, false)
if err != nil {
t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
continue
}
// working/ surfaces a synthetic <viewer-email>/ 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)
}
}
}
}
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
// only applies to cascade-declared paths.
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
root := setupTreeRoot(t)
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
_, err := ListDirectory(context.Background(), nil, root,
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
"/Proj/random-folder-that-doesnt-exist/", false, false)
if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
}
}
// 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) {
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)
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root,
"Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
var partyDirs []string
for _, fi := range got {
if fi.IsDir && fi.Virtual {
partyDirs = append(partyDirs, fi.Name)
}
}
want := []string{"Acme/"}
if len(partyDirs) != 1 || partyDirs[0] != want[0] {
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want)
}
}