GET /<project> (no trailing slash) used to 301 to /<project>/ which
served the browse listing. Now it serves a small server-rendered
landing page with:
- Four lifecycle-stage cards (archive/working/staging/reviewing)
linking to the no-slash form of each canonical folder, so each
card opens its default tool (archive view, mdedit sandboxed to
working/, transmittal at staging/, mdedit at reviewing/).
- A "Browse all files" link to the slash form for the generic
file tree.
- A "Master Deliverables List" section with step-by-step
instructions for editing any party's MDL plus direct links to
the MDL of each party already present under archive/ (sorted,
case-preserved). Falls back to a friendly "no parties yet"
message when the archive is fresh.
Trailing-slash form (/<project>/) is unchanged — still 200 +
embedded browse.html. The slash-vs-no-slash convention now extends
all the way up the URL tree:
/ → landing tool (project picker)
/<project> → project landing (this commit)
/<project>/ → browse
/<project>/working → mdedit
/<project>/working/ → browse
... etc.
Implementation:
- new internal/handler/projecthandler.go — IsProjectRootURL
predicate + ServeProjectLanding rendering an inlined html/template.
Page styles are inline; tokens mirror shared/base.css and
auto-flip on prefers-color-scheme: dark.
- dispatcher in cmd/zddc-server/main.go: at the IsDir branch's
no-slash fork, intercept depth-1 single-segment URLs before
the historical 301. Other depths still 301 unchanged.
Tests:
- internal/handler/projecthandler_test.go (4 cases): predicate
coverage; landing page renders project name + four stage cards;
on-disk parties surface as MDL links with case preserved; fresh
project falls back to the no-parties-yet copy.
- cmd/zddc-server/main_test.go TestDispatchSlashRouting: the
"project root no-slash → 301" case becomes "→ landing (200)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.4 KiB
Go
119 lines
3.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
)
|
|
|
|
func TestIsProjectRootURL(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"/Project-1": true,
|
|
"/Project_2": true,
|
|
"/Project-1/": false, // trailing slash
|
|
"/Project-1/x": false, // deeper
|
|
"/": false, // deployment root
|
|
"": false,
|
|
}
|
|
for path, want := range cases {
|
|
if got := IsProjectRootURL(path); got != want {
|
|
t.Errorf("IsProjectRootURL(%q) = %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeProjectLanding(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Need .zddc on disk for the resolver to be happy at the root.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Project with two parties under archive/ (PascalCase to exercise
|
|
// the case-insensitive archive resolver) and one orphaned dot-prefixed
|
|
// dir that should be filtered out of the party list.
|
|
for _, sub := range []string{
|
|
"Project-1/Archive/PartyA",
|
|
"Project-1/Archive/PartyB",
|
|
"Project-1/Archive/.hidden",
|
|
} {
|
|
if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
cfg := config.Config{Root: root}
|
|
|
|
t.Run("renders project name + stage cards", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeProjectLanding(cfg, rec, req, "Project-1")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
|
|
// Page identifies the project and includes the four stages.
|
|
for _, want := range []string{
|
|
"Project-1",
|
|
"<h3>Archive</h3>",
|
|
"<h3>Working</h3>",
|
|
"<h3>Staging</h3>",
|
|
"<h3>Reviewing</h3>",
|
|
"Master Deliverables List",
|
|
`href="/Project-1/working"`,
|
|
`href="/Project-1/archive/"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q", want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("lists existing parties as direct MDL links", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/Project-1", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeProjectLanding(cfg, rec, req, "Project-1")
|
|
body := rec.Body.String()
|
|
|
|
// Both parties surfaced; on-disk casing preserved in the URL.
|
|
if !strings.Contains(body, `href="/Project-1/Archive/PartyA/mdl/"`) {
|
|
t.Errorf("body missing PartyA MDL link")
|
|
}
|
|
if !strings.Contains(body, `href="/Project-1/Archive/PartyB/mdl/"`) {
|
|
t.Errorf("body missing PartyB MDL link")
|
|
}
|
|
// Dot-prefixed entries filtered out.
|
|
if strings.Contains(body, ".hidden") {
|
|
t.Errorf("body should not list .hidden directory")
|
|
}
|
|
})
|
|
|
|
t.Run("no parties yet → falls back to generic instruction", func(t *testing.T) {
|
|
bare := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(bare, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*\": rwcda\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(bare, "Fresh"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bareCfg := config.Config{Root: bare}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Fresh", nil)
|
|
rec := httptest.NewRecorder()
|
|
ServeProjectLanding(bareCfg, rec, req, "Fresh")
|
|
body := rec.Body.String()
|
|
|
|
// Falls through to the "no party folders yet" copy.
|
|
if !strings.Contains(body, "No party folders yet") {
|
|
t.Errorf("body missing fresh-project fallback copy")
|
|
}
|
|
})
|
|
}
|