URL convention for directories under a project:
- <dir>/ (with trailing slash) → browse (the directory view; same
behaviour as today)
- <dir> (without trailing slash) → the canonical default tool for
that directory's context, served
inline (no 301 hop)
Tool mapping via the new apps.DefaultAppAt(root, dir):
- working/... → mdedit
- staging/... → transmittal
- archive/ → archive
- archive/<party>/ → archive
- archive/<party>/incoming|received|issued/... → archive
- archive/<party>/mdl/... → tables (the per-party MDL grid editor)
Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.
This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.
Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.3 KiB
Go
115 lines
4.3 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 — no default tool wired here yet.
|
|
{root + "/Project-A/reviewing", ""},
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|