ZDDC/zddc/internal/handler/reviewinghandler_test.go
ZDDC 45005d164e feat(zddc-server): reviewing/ virtual aggregator + mdedit at the URL
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>
2026-05-09 21:37:08 -05:00

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
}