ZDDC/zddc/internal/handler/reviewinghandler_test.go
ZDDC d052e9fed3 Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2
Four user-reported items:

1. landing: remove the standalone-tool strip from the site picker.
   Per user, it was awkward — links pointing at zddc.varasys.io
   releases from inside a deployment is a layering confusion. The
   nav.tool-strip block in landing/template.html and its CSS are
   gone.

2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
   app for the virtual-MDL case where the on-disk folder doesn't
   exist yet. Previously fell through to 404 because the dispatcher
   only routed virtual mdl/ via the IsDir branch — the IsNotExist
   branch was missing the equivalent check. Now both shapes (with
   and without trailing slash) hit RecognizeTableRequest's default-
   MDL fallback and ServeTable serves the embedded tables.html.

3. browse: re-layout the markdown editor to mirror mdedit's layout.
   Was: sidebar on right with TOC top + front-matter bottom.
   Now: sidebar on LEFT with YAML front matter top + Outline bottom,
        content on RIGHT with an informational header (file title +
        save controls + status + source) above the Toast UI editor.
   New horizontal resizer between the front-matter and outline
   sections inside the sidebar (drag the row boundary; arrow keys
   step by 24 px). Browse test selectors updated.

4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
   user can preview files inside virtual reviewing/<tracking>/
   received/ and staged/ folders. IsReviewingPath now returns a
   sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
   depth-2 branch proxies the underlying real folder's listing,
   emitting folder entries with virtual reviewing/ URLs (so
   navigation stays in the aggregator) and file entries with
   canonical archive/ or staging/ URLs (so byte fetches resolve
   directly). ACL is enforced against the real path; depth-1
   received/ + staged/ URLs are now virtual too (was canonical),
   so the user smoothly descends into the depth-2 listing.

Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:30:34 -05:00

220 lines
8.6 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
wantSide 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", ""},
// Depth 2+: side present.
{"/Project/reviewing/123/received/", true, "Project", "123", "received"},
{"/Project/reviewing/123/staged/", true, "Project", "123", "staged"},
{"/Project/reviewing/123/received/sub/", true, "Project", "123", "received/sub"},
// Unknown side at depth 2 is rejected.
{"/Project/reviewing/123/issued/", false, "", "", ""},
// Non-canonical / wrong shape.
{"/Project/", false, "", "", ""},
{"/", false, "", "", ""},
{"/Project/working/", false, "", "", ""},
}
for _, tc := range cases {
gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path)
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide {
t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)",
tc.path, gotProj, gotTracking, gotSide, gotOK, tc.wantProj, tc.wantTracking, tc.wantSide, 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/")
}
// Virtual URL — stays under reviewing/ so depth-2 navigation
// returns to the aggregator (which lists the real folder's
// contents with canonical file URLs).
if want := "/Project/reviewing/002-AB-SUB-0007/received/"; got[0].URL != want {
t.Errorf("received URL=%q, want %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/reviewing/002-AB-SUB-0007/staged/"; got[1].URL != want {
t.Errorf("staged URL=%q, want %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
}