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/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.
Two depths, both trailing-slash:
GET <project>/reviewing/?json=1
→ array of virtual <tracking>/ entries, one per submittal in
archive/<party>/received/ that doesn't yet have a matching
archive/<party>/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 <project>/reviewing/<tracking>/?json=1
→ array of two virtual entries, received/ + staged/, with
canonical URLs pointing back to archive/<party>/received/...
and staging/... respectively. staged/ is omitted when no
response draft exists yet.
When the response moves staging/ → archive/<party>/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 <project>/reviewing[/<tracking>/] 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 <project>/reviewing/[<tracking>/] with Accept: json
→ ServeReviewing
- GET <project>/reviewing/[<tracking>/] with Accept: html
→ mdedit (rooted at the virtual path; polyfill fetches the
JSON listing on its own)
- GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
- GET <project>/reviewing/<tracking> (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/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.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) <noreply@anthropic.com>
212 lines
8 KiB
Go
212 lines
8 KiB
Go
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 <tracking>/ entries, sorted, both with
|
|
// URLs under /<project>/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
|
|
}
|