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>
This commit is contained in:
parent
d08dcce211
commit
45005d164e
7 changed files with 548 additions and 21 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -841,6 +841,41 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
}
|
||||
}
|
||||
}
|
||||
// Reviewing aggregator. <project>/reviewing/[<tracking>/] 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/<tracking>) 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. <project>/{archive,
|
||||
// working,staging,reviewing}[/] should land on a usable view
|
||||
// (default tool or empty listing) rather than 404, so the
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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/<rs-name>/)
|
||||
// - 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 {
|
|||
// - <project>/archive/<party>/... → "archive"
|
||||
// - <project>/staging/... → "transmittal"
|
||||
// - <project>/working/... → "mdedit"
|
||||
// - <project>/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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
264
zddc/internal/handler/reviewinghandler.go
Normal file
264
zddc/internal/handler/reviewinghandler.go
Normal file
|
|
@ -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:
|
||||
//
|
||||
// <project>/reviewing/ → depth 0: list pending submittals
|
||||
// <project>/reviewing/<tracking>/ → 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/<party>/received/ that doesn't yet have a matching entry in
|
||||
// archive/<party>/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 // /<project>/archive/<party>/received/<folder>/
|
||||
stagedURL string // /<project>/staging/<folder>/ 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/<folder>/ by tracking number.
|
||||
// 2. For each party under archive/<party>/:
|
||||
// a. Index archive/<party>/issued/ by tracking number.
|
||||
// b. For each archive/<party>/received/<folder>:
|
||||
// - 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/<party>/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 <tracking>/ 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)
|
||||
}
|
||||
212
zddc/internal/handler/reviewinghandler_test.go
Normal file
212
zddc/internal/handler/reviewinghandler_test.go
Normal file
|
|
@ -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 <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
|
||||
}
|
||||
Loading…
Reference in a new issue