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>
220 lines
8.6 KiB
Go
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
|
|
}
|