ZDDC/zddc/internal/apps/availability_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

118 lines
4.5 KiB
Go

package apps
import (
"path/filepath"
"testing"
)
func TestAppAvailableAt(t *testing.T) {
root := "/srv/zddc"
cases := []struct {
dir, app string
want bool
}{
// archive: everywhere
{root, "archive", true},
{root + "/Project-A", "archive", true},
{root + "/Project-A/working", "archive", true},
{root + "/Project-A/some-other-folder", "archive", true},
// landing: only at root
{root, "landing", true},
{root + "/Project-A", "landing", false},
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees
{root, "classifier", false},
{root + "/Project-A", "classifier", false},
{root + "/Project-A/working", "classifier", true},
{root + "/Project-A/working/deep/nested/path", "classifier", true},
{root + "/Project-A/staging", "classifier", true},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true},
{root + "/Project-A/archive/ACME/incoming", "classifier", true},
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
{root + "/Project-A/archive/ACME/received", "classifier", false},
{root + "/Project-A/archive/ACME/issued", "classifier", false},
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
{root + "/Project-A/some-other-folder", "classifier", false},
// mdedit: working/ only (review responses live in working/<rs-name>/)
{root + "/Project-A/working", "mdedit", true},
{root + "/Project-A/working/sub", "mdedit", true},
{root + "/Project-A/staging", "mdedit", false},
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
// transmittal: staging/ only
{root + "/Project-A/staging", "transmittal", true},
{root + "/Project-A/staging/sub", "transmittal", true},
{root + "/Project-A/working", "transmittal", false},
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
// case-fold: any case of canonical names matches
{root + "/Project-A/Working", "mdedit", true},
{root + "/Project-A/WORKING", "mdedit", true},
{root + "/Project-A/Staging", "transmittal", true},
{root + "/Project-A/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
{root + "/Project-A/Archive/ACME/incoming", "classifier", true},
// unknown app
{root + "/Project-A", "weird", false},
}
for _, tc := range cases {
t.Run(tc.app+"@"+tc.dir, func(t *testing.T) {
got := AppAvailableAt(root, filepath.Clean(tc.dir), tc.app)
if got != tc.want {
t.Errorf("AppAvailableAt(%q, %q) = %v, want %v", tc.dir, tc.app, got, tc.want)
}
})
}
}
func TestDefaultAppAt(t *testing.T) {
root := "/srv/zddc"
cases := []struct {
dir string
want string
}{
// At the deployment root itself, no default tool — landing handles
// the project picker via a separate path.
{root, ""},
// Bare project root: no default. Trailing-slash URL serves browse;
// no-slash falls through to the redirect.
{root + "/Project-A", ""},
// Canonical project-root folders.
{root + "/Project-A/working", "mdedit"},
{root + "/Project-A/working/alice@example.com", "mdedit"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
{root + "/Project-A/staging", "transmittal"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
// archive: at the archive root, party folders, and per-party
// subfolders (incoming/received/issued).
{root + "/Project-A/archive", "archive"},
{root + "/Project-A/archive/Acme", "archive"},
{root + "/Project-A/archive/Acme/incoming", "archive"},
{root + "/Project-A/archive/Acme/issued", "archive"},
{root + "/Project-A/archive/Acme/received", "archive"},
// 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 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.
{root + "/Project-A/Working", "mdedit"},
{root + "/Project-A/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"},
}
for _, tc := range cases {
t.Run(tc.dir, func(t *testing.T) {
if got := DefaultAppAt(root, tc.dir); got != tc.want {
t.Errorf("DefaultAppAt(%q) = %q, want %q", tc.dir, got, tc.want)
}
})
}
}