From 45005d164e8c9382fccaac315d92a01d91af8edf Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 21:37:08 -0500 Subject: [PATCH] feat(zddc-server): reviewing/ virtual aggregator + mdedit at the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the reviewing/ aggregator described in the saved project memory (~/.claude/projects/-home-user-src-zddc/memory/ project_reviewing_folder_design.md). reviewing/ stays in VirtualOnlyCanonicalNames — never materialised on disk — and is served as a join over archive//received/, archive// issued/, and staging/, recomputed on every read. Two depths, both trailing-slash: GET /reviewing/?json=1 → array of virtual / entries, one per submittal in archive//received/ that doesn't yet have a matching archive//issued/ entry. Sorted by tracking. URLs stay under reviewing/ so the user can drill into the per-submittal view. ACL: per-party, filtered like fs.ListDirectory. GET /reviewing//?json=1 → array of two virtual entries, received/ + staged/, with canonical URLs pointing back to archive//received/... and staging/... respectively. staged/ is omitted when no response draft exists yet. When the response moves staging/ → archive//issued/, the entry vanishes from depth-0 on the next listing. No mutation of the reviewing/ subtree itself; pure join, recomputed on read. Front-end at /reviewing[//] is mdedit (per user request). DefaultAppAt + AppAvailableAt extended to recognise "reviewing" as a canonical mdedit-bearing folder. The polyfill in shared/zddc-source.js is updated to follow listing entries' explicit url field when present (absolute or root-relative) — that's how mdedit's tree follows the depth-1 received/ + staged/ links into the canonical archive/staging subtrees. Dispatcher routing in zddc-server/main.go: - GET /reviewing/[/] with Accept: json → ServeReviewing - GET /reviewing/[/] with Accept: html → mdedit (rooted at the virtual path; polyfill fetches the JSON listing on its own) - GET /reviewing (no slash) → mdedit (via DefaultAppAt) - GET /reviewing/ (no slash) → 301 to slash form Tests: - handler/reviewinghandler_test.go (6 cases): IsReviewingPath classification + ServeReviewing depth-0/depth-1 with and without staged drafts + 404 on unknown tracking + empty when archive/ is absent. - apps/availability_test.go updated: reviewing/ now expects mdedit rather than "" (no default). - cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders extended to assert reviewing → mdedit at the no-slash form; older "no-slash/reviewing → 301" test removed. Future work (not in this commit): write translation. Editing a file under reviewing//staged/.md works today because the polyfill rewrites to //staging//.md before fetching — the user's URL bar moves to the canonical path on click. A virtual-filesystem mode where the URL bar stays under reviewing/ throughout would require server-side write rewriting (translate PUT/DELETE on reviewing/.../staged/... into the canonical staging/ path). Not needed for the MVP — links in mdedit's tree work. Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/zddc-source.js | 19 +- zddc/cmd/zddc-server/main.go | 35 +++ zddc/cmd/zddc-server/main_test.go | 18 +- zddc/internal/apps/availability.go | 14 +- zddc/internal/apps/availability_test.go | 7 +- zddc/internal/handler/reviewinghandler.go | 264 ++++++++++++++++++ .../internal/handler/reviewinghandler_test.go | 212 ++++++++++++++ 7 files changed, 548 insertions(+), 21 deletions(-) create mode 100644 zddc/internal/handler/reviewinghandler.go create mode 100644 zddc/internal/handler/reviewinghandler_test.go diff --git a/shared/zddc-source.js b/shared/zddc-source.js index 4cfecc9..03ac16e 100644 --- a/shared/zddc-source.js +++ b/shared/zddc-source.js @@ -171,7 +171,24 @@ for (var i = 0; i < entries.length; i++) { var e = entries[i]; var rawName = stripSlash(e.name); - var childUrl = joinUrl(url, rawName, e.is_dir); + // Listing entries can carry an explicit URL for virtual + // links (e.g. the reviewing-aggregator's received/+staged/ + // entries point to canonical archive/+staging paths). + // Use it when present so navigation follows the listing's + // own routing rather than computing a synthetic child URL + // off the parent. Caddy-shape listings don't set url + // (or set it to a relative form) — joinUrl handles those. + var childUrl; + if (e.url && /^https?:\/\/|^\//.test(e.url)) { + // Absolute or root-relative: use as-is, normalised against origin. + var u = e.url; + if (u[0] === '/') { + u = location.origin + u; + } + childUrl = u; + } else { + childUrl = joinUrl(url, rawName, e.is_dir); + } if (e.is_dir) { yield new HttpDirectoryHandle(childUrl, rawName); } else { diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 0bc50cc..a19c142 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -841,6 +841,41 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } } + // Reviewing aggregator. /reviewing/[/] is + // a virtual view. With trailing slash: + // - JSON request → aggregator listing (handler.ServeReviewing) + // - HTML request → mdedit, rooted at the reviewing/ path. + // mdedit's polyfill then fetches the JSON + // listing on its own. + // Without trailing slash, depth-3 (reviewing/) 301s + // to the slash form; depth-2 (reviewing) falls through to the + // canonical-folder block below where DefaultAppAt routes to + // mdedit and the no-slash branch serves it directly. + if r.Method == http.MethodGet || r.Method == http.MethodHead { + if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok { + if !strings.HasSuffix(urlPath, "/") { + if tracking != "" { + http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently) + return + } + // Depth-2 no-slash falls through to canonical-folder block. + } else { + chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Join(cfg.Root, proj)) + if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if strings.Contains(r.Header.Get("Accept"), "application/json") { + handler.ServeReviewing(cfg, w, r, proj, tracking) + return + } + if appsSrv != nil { + appsSrv.Serve(w, r, "mdedit", chain, absPath) + return + } + } + } + } // Canonical project-root folder fallback. /{archive, // working,staging,reviewing}[/] should land on a usable view // (default tool or empty listing) rather than 404, so the diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 290d12e..532deb8 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -529,6 +529,10 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) { {"working", "ZDDC Markdown"}, {"staging", "ZDDC Transmittal"}, {"archive", "ZDDC Archive"}, + // reviewing/ also routes to mdedit; the polyfill follows the + // virtual aggregator's listing into canonical archive/+staging + // paths from there. + {"reviewing", "ZDDC Markdown"}, } for _, tc := range noSlashDefaultApp { t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) { @@ -544,20 +548,6 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) { }) } - // reviewing/ has no default tool — no-slash form should 301 to - // the slash form (which then renders the empty listing). - t.Run("no-slash/reviewing → 301 to slash", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/Project/reviewing", nil) - rec := httptest.NewRecorder() - dispatch(cfg, idx, ring, appsSrv, nil, rec, req) - if rec.Code != http.StatusMovedPermanently { - t.Errorf("status=%d, want 301", rec.Code) - } - if loc := rec.Header().Get("Location"); loc != "/Project/reviewing/" { - t.Errorf("Location=%q, want %q", loc, "/Project/reviewing/") - } - }) - // Non-canonical missing folder still 404s (the fallback is // scoped to the four canonical names, not a blanket "missing → // empty" rule). diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index 1580bed..0feba33 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -14,9 +14,11 @@ import ( // - classifier: requestDir is, or descends from, a folder named // "working", "staging", or "incoming" (the directories where // in-flight files get classified) -// - mdedit: requestDir is, or descends from, a "working" folder -// (where markdown drafts are written and edited, including review -// responses drafted in working//) +// - mdedit: requestDir is, or descends from, a "working" or +// "reviewing" folder. working/ is the drafting workspace; +// reviewing/ is the virtual aggregation view of pending review +// responses (server-rendered listings; mdedit follows the +// listing's canonical URLs via the polyfill). // - transmittal: requestDir is, or descends from, a "staging" folder // (where outgoing transmittals are prepared) // - landing: only at the deployment root (the project picker) @@ -41,7 +43,7 @@ func AppAvailableAt(root, requestDir, app string) bool { case "classifier": return inAncestorWithName(root, requestDir, "working", "staging", "incoming") case "mdedit": - return inAncestorWithName(root, requestDir, "working") + return inAncestorWithName(root, requestDir, "working", "reviewing") case "transmittal": return inAncestorWithName(root, requestDir, "staging") } @@ -81,6 +83,8 @@ func inAncestorWithName(root, requestDir string, names ...string) bool { // - /archive//... → "archive" // - /staging/... → "transmittal" // - /working/... → "mdedit" +// - /reviewing/... → "mdedit" (operates on the +// virtual aggregator listing) // - any other directory → "" (no default) // // The mdl rule wins over the broader archive rule because the table @@ -120,6 +124,8 @@ func DefaultAppAt(root, requestDir string) string { return "transmittal" case "working": return "mdedit" + case "reviewing": + return "mdedit" } return "" } diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 65a8d81..9b71294 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -96,8 +96,11 @@ func TestDefaultAppAt(t *testing.T) { // mdl wins over the broader archive rule. {root + "/Project-A/archive/Acme/mdl", "tables"}, {root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"}, - // reviewing/ is virtual — no default tool wired here yet. - {root + "/Project-A/reviewing", ""}, + // reviewing/ is virtual but mdedit is wired as the default + // tool; the polyfill follows the listing's canonical URLs + // into archive/ and staging/ for the actual files. + {root + "/Project-A/reviewing", "mdedit"}, + {root + "/Project-A/reviewing/123-EM-SUB-0001", "mdedit"}, // Random non-canonical folder names → no default. {root + "/Project-A/scratch", ""}, // Case-fold on canonical names. diff --git a/zddc/internal/handler/reviewinghandler.go b/zddc/internal/handler/reviewinghandler.go new file mode 100644 index 0000000..e59c824 --- /dev/null +++ b/zddc/internal/handler/reviewinghandler.go @@ -0,0 +1,264 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// IsReviewingPath classifies a URL as a reviewing-aggregator path and +// extracts (project, tracking). The aggregator is a virtual view at: +// +// /reviewing/ → depth 0: list pending submittals +// /reviewing// → depth 1: list received/ + staged/ +// +// Anything deeper than depth 1 returns ok=false; the depth-1 listing +// emits canonical URLs (under archive/ and staging/) so navigation past +// that point goes through the regular file-tree handlers, not back into +// the virtual reviewing/ subtree. +// +// Trailing slash on either depth is required and tolerated. Match on +// "reviewing" is case-insensitive. +func IsReviewingPath(urlPath string) (project, tracking string, ok bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") { + return "", "", false + } + switch len(parts) { + case 2: + return parts[0], "", true + case 3: + return parts[0], parts[2], true + default: + return "", "", false + } +} + +// pendingSubmittal is one row of the aggregator's view: a submittal in +// archive//received/ that doesn't yet have a matching entry in +// archive//issued/, optionally paired with an in-progress +// response folder under staging/. +type pendingSubmittal struct { + tracking string // canonical tracking number, e.g. "123456-ST-SUB-0026" + party string // party folder name, e.g. "Acme" + receivedURL string // //archive//received// + stagedURL string // //staging// or "" if no draft yet + lastModified time.Time // newer of the two folders' mtimes +} + +// computePending walks the project's archive/ and staging/ subtrees to +// build the virtual reviewing-aggregator view. +// +// Algorithm: +// +// 1. Index staging// by tracking number. +// 2. For each party under archive//: +// a. Index archive//issued/ by tracking number. +// b. For each archive//received/: +// - skip folders that don't parse as transmittal folders. +// - skip if tracking already in issued (response complete). +// - emit a pendingSubmittal pointing at the canonical received +// URL and (if found) the matching staging URL. +// +// ACL: per-party. The caller's email + decider are consulted on the +// archive//received/ subtree before reading its contents — a +// party the caller can't see at upstream is omitted entirely (no info +// leak via tracking-number listing). +// +// Missing intermediate folders (archive/, party/issued/, staging/) are +// not errors; they just produce empty intermediate sets. This matches +// the lazy-instantiation pattern of the canonical project folders. +func computePending(ctx context.Context, decider policy.Decider, + fsRoot, project, email string) ([]pendingSubmittal, error) { + + projectAbs := filepath.Join(fsRoot, project) + archiveAbs := filepath.Join(projectAbs, "archive") + stagingAbs := filepath.Join(projectAbs, "staging") + + // Index staging by tracking → folder name. + stagedByTracking := map[string]string{} + if entries, err := os.ReadDir(stagingAbs); err == nil { + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { + stagedByTracking[tracking] = e.Name() + } + } + } + + parties, err := os.ReadDir(archiveAbs) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var result []pendingSubmittal + for _, p := range parties { + if !p.IsDir() || strings.HasPrefix(p.Name(), ".") { + continue + } + party := p.Name() + partyAbs := filepath.Join(archiveAbs, party) + receivedAbs := filepath.Join(partyAbs, "received") + + // ACL: skip parties whose received/ subtree the caller can't read. + // Filtering at the party level is cheaper than per-entry and matches + // fs.ListDirectory's omit-denied-subdirs convention. + chain, err := zddc.EffectivePolicy(fsRoot, receivedAbs) + if err != nil { + continue + } + receivedURLPrefix := "/" + project + "/archive/" + party + "/received/" + if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed { + continue + } + + // Index this party's issued/ trackings (no ACL filter — issued/ + // is WORM-readable to anyone with party access by design, and + // we just need the set membership for matching). + issuedTrackings := map[string]bool{} + if entries, err := os.ReadDir(filepath.Join(partyAbs, "issued")); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok { + issuedTrackings[tracking] = true + } + } + } + + receivedEntries, err := os.ReadDir(receivedAbs) + if err != nil { + continue + } + for _, e := range receivedEntries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()) + if !ok { + continue + } + if issuedTrackings[tracking] { + continue // response complete; not pending + } + + info, err := e.Info() + if err != nil { + continue + } + modTime := info.ModTime() + + sub := pendingSubmittal{ + tracking: tracking, + party: party, + receivedURL: receivedURLPrefix + url.PathEscape(e.Name()) + "/", + lastModified: modTime, + } + if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft { + sub.stagedURL = "/" + project + "/staging/" + url.PathEscape(stagedFolder) + "/" + if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil { + if stagedInfo.ModTime().After(modTime) { + sub.lastModified = stagedInfo.ModTime() + } + } + } + result = append(result, sub) + } + } + + sort.Slice(result, func(i, j int) bool { + return result[i].tracking < result[j].tracking + }) + return result, nil +} + +// ServeReviewing emits the aggregator JSON listing for either depth 0 +// (project's full pending list) or depth 1 (one submittal's +// received/ + staged/ pair). The HTML branch is handled separately by +// the apps subsystem (mdedit served at the URL); only requests that +// accept JSON reach here. +func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request, + project, tracking string) { + + pending, err := computePending(r.Context(), DeciderFromContext(r), + cfg.Root, project, EmailFromContext(r)) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + var entries []listing.FileInfo + switch tracking { + case "": + // Depth 0: list pending submittals as virtual / folders. + // The URLs stay under reviewing/ so the user can drill into a + // per-submittal view. + urlPrefix := "/" + project + "/reviewing/" + for _, s := range pending { + entries = append(entries, listing.FileInfo{ + Name: s.tracking + "/", + URL: urlPrefix + url.PathEscape(s.tracking) + "/", + ModTime: s.lastModified, + IsDir: true, + Virtual: true, + }) + } + default: + // Depth 1: find the matching pending entry; emit received/ + + // staged/ pointing at canonical archive/staging URLs. Clients + // using the polyfill follow these URLs out of the virtual + // subtree into the real file paths underneath. + var match *pendingSubmittal + for i := range pending { + if pending[i].tracking == tracking { + match = &pending[i] + break + } + } + if match == nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + entries = append(entries, listing.FileInfo{ + Name: "received/", + URL: match.receivedURL, + ModTime: match.lastModified, + IsDir: true, + Virtual: true, + }) + if match.stagedURL != "" { + entries = append(entries, listing.FileInfo{ + Name: "staged/", + URL: match.stagedURL, + ModTime: match.lastModified, + IsDir: true, + Virtual: true, + }) + } + } + + if entries == nil { + entries = []listing.FileInfo{} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") // virtual; recompute every time + w.Header().Set("X-ZDDC-Source", "reviewing-aggregator") + _ = json.NewEncoder(w).Encode(entries) +} diff --git a/zddc/internal/handler/reviewinghandler_test.go b/zddc/internal/handler/reviewinghandler_test.go new file mode 100644 index 0000000..621f95c --- /dev/null +++ b/zddc/internal/handler/reviewinghandler_test.go @@ -0,0 +1,212 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +func TestIsReviewingPath(t *testing.T) { + cases := []struct { + path string + wantOK bool + wantProj string + wantTracking string + }{ + {"/Project/reviewing/", true, "Project", ""}, + {"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"}, + // Case-insensitive on the literal "reviewing" segment. + {"/Project/Reviewing/", true, "Project", ""}, + {"/Project/REVIEWING/x/", true, "Project", "x"}, + // No trailing slash: still classified (caller decides redirect). + {"/Project/reviewing", true, "Project", ""}, + {"/Project/reviewing/123/", true, "Project", "123"}, + // Non-canonical / wrong shape. + {"/Project/", false, "", ""}, + {"/", false, "", ""}, + {"/Project/working/", false, "", ""}, + {"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported + } + for _, tc := range cases { + gotProj, gotTracking, gotOK := IsReviewingPath(tc.path) + if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking { + t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)", + tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK) + } + } +} + +// Test setup: build a synthetic project tree with two parties, one +// pending submittal each. Verify the aggregator returns: +// - depth 0: 2 virtual / entries, sorted, both with +// URLs under //reviewing/ +// - depth 1: received/ + staged/ entries with canonical URLs +func TestServeReviewing(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"*\": rwcda\n") + + // Two parties under archive/, each with a pending submittal. + // Acme: submitted but no response staged or issued yet. + // Beta: submitted, response staged but not yet issued. + pAcmeReceived := filepath.Join(root, "Project", "archive", "Acme", "received", + "2025-10-31_001-AB-SUB-0001 (IFR) - Pending acme review") + pBetaReceived := filepath.Join(root, "Project", "archive", "Beta", "received", + "2025-11-01_002-AB-SUB-0007 (IFR) - Pending beta review") + pBetaStaged := filepath.Join(root, "Project", "staging", + "2025-11-15_002-AB-SUB-0007 (RSC) - Beta response draft") + for _, p := range []string{pAcmeReceived, pBetaReceived, pBetaStaged} { + mustMkdir(t, p) + } + // And a third party (Gamma) where the submittal has BEEN issued — + // should NOT appear in the pending list. + pGammaReceived := filepath.Join(root, "Project", "archive", "Gamma", "received", + "2025-09-01_003-CD-SUB-0099 (IFR) - Already responded") + pGammaIssued := filepath.Join(root, "Project", "archive", "Gamma", "issued", + "2025-09-15_003-CD-SUB-0099 (RSC) - The response we sent") + mustMkdir(t, pGammaReceived) + mustMkdir(t, pGammaIssued) + + zddc.InvalidateCache(root) + + cfg := config.Config{ + Root: root, + EmailHeader: "X-Auth-Request-Email", + } + + t.Run("depth-0 lists pending submittals only", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) + rec := httptest.NewRecorder() + ServeReviewing(cfg, rec, req, "Project", "") + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) + } + var got []listing.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2 (Acme + Beta pending; Gamma issued); body=%s", + len(got), rec.Body.String()) + } + // Sorted by tracking number → 001-* before 002-*. + if got[0].Name != "001-AB-SUB-0001/" { + t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "001-AB-SUB-0001/") + } + if got[1].Name != "002-AB-SUB-0007/" { + t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "002-AB-SUB-0007/") + } + for i, e := range got { + if !e.IsDir || !e.Virtual { + t.Errorf("entries[%d] IsDir=%v Virtual=%v, want both true", i, e.IsDir, e.Virtual) + } + // Per-submittal URL stays under reviewing/ (the user can + // drill into the per-submittal received/+staged/ view). + if e.URL != "/Project/reviewing/"+got[i].Name[:len(got[i].Name)-1]+"/" { + t.Errorf("entries[%d].URL=%q, want under /Project/reviewing/", i, e.URL) + } + } + }) + + t.Run("depth-1 with staged draft → received/ + staged/", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/002-AB-SUB-0007/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) + rec := httptest.NewRecorder() + ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007") + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) + } + var got []listing.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2 (received/ + staged/); body=%s", + len(got), rec.Body.String()) + } + if got[0].Name != "received/" { + t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/") + } + // Canonical URL — outside reviewing/ subtree. + if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) { + t.Errorf("received URL=%q, want prefix %q", got[0].URL, want) + } + if got[1].Name != "staged/" { + t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/") + } + if want := "/Project/staging/"; !startsWith(got[1].URL, want) { + t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want) + } + }) + + t.Run("depth-1 with no staged draft → received/ only", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/001-AB-SUB-0001/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) + rec := httptest.NewRecorder() + ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001") + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) + } + var got []listing.FileInfo + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].Name != "received/" { + t.Fatalf("got %+v, want [received/] only (no draft)", got) + } + }) + + t.Run("depth-1 unknown tracking → 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/reviewing/999-ZZ-SUB-9999/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) + rec := httptest.NewRecorder() + ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999") + if rec.Code != http.StatusNotFound { + t.Errorf("status=%d, want 404", rec.Code) + } + }) + + t.Run("missing archive/ entirely → empty depth-0 listing", func(t *testing.T) { + // Fresh project state: no archive/ subtree at all. + bareRoot := t.TempDir() + mustWrite(t, filepath.Join(bareRoot, ".zddc"), + "acl:\n permissions:\n \"*\": rwcda\n") + mustMkdir(t, filepath.Join(bareRoot, "Fresh")) + zddc.InvalidateCache(bareRoot) + bareCfg := config.Config{Root: bareRoot, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Fresh/reviewing/", nil) + req.Header.Set("Accept", "application/json") + req = req.WithContext(WithEmail(req.Context(), "alice@example.com")) + rec := httptest.NewRecorder() + ServeReviewing(bareCfg, rec, req, "Fresh", "") + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + // Empty array, not "null". + if body == "null" || body == "null\n" { + t.Errorf("body=%q, want []; nil-slice encoded as null", body) + } + }) +} + +// startsWith — local helper. mustMkdir / mustWrite live in +// formhandler_test.go and are reused here. +func startsWith(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +}