ZDDC/zddc/internal/handler/projecthandler_test.go
ZDDC 6145bb0c87 feat(zddc-server): synthetic project landing page at /<project>
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>
2026-05-10 07:26:21 -05:00

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")
}
})
}