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>
This commit is contained in:
parent
e51d9fe908
commit
6145bb0c87
4 changed files with 415 additions and 1 deletions
|
|
@ -960,6 +960,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Project root (depth-1 dir, no trailing slash) gets a synthetic
|
||||||
|
// landing page with the four lifecycle-stage cards + MDL
|
||||||
|
// instructions. With trailing slash, the project falls through to
|
||||||
|
// the regular browse listing.
|
||||||
|
if !strings.HasSuffix(urlPath, "/") &&
|
||||||
|
(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
|
handler.IsProjectRootURL(urlPath) {
|
||||||
|
project := strings.TrimPrefix(urlPath, "/")
|
||||||
|
handler.ServeProjectLanding(cfg, w, r, project)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
if !strings.HasSuffix(urlPath, "/") {
|
||||||
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -441,7 +441,8 @@ func TestDispatchSlashRouting(t *testing.T) {
|
||||||
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
||||||
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
||||||
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
||||||
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false, ""},
|
// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
|
||||||
|
{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
|
||||||
283
zddc/internal/handler/projecthandler.go
Normal file
283
zddc/internal/handler/projecthandler.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsProjectRootURL reports whether urlPath names a project root —
|
||||||
|
// exactly one path segment, no trailing slash. Used by the dispatcher
|
||||||
|
// to route /<project> (with no trailing slash) to ServeProjectLanding
|
||||||
|
// instead of 301'ing to the slash form.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// "/Project-1" → true
|
||||||
|
// "/Project-1/" → false (trailing slash → directory listing)
|
||||||
|
// "/Project-1/x" → false (deeper)
|
||||||
|
// "/" → false (deployment root, served by landing tool)
|
||||||
|
// "" → false
|
||||||
|
func IsProjectRootURL(urlPath string) bool {
|
||||||
|
if urlPath == "" || urlPath == "/" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(urlPath, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimPrefix(urlPath, "/")
|
||||||
|
return !strings.Contains(trimmed, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectLandingTmpl is the inline template for /<project> (no slash).
|
||||||
|
// It's a simple navigation page — four canonical-stage cards, a link
|
||||||
|
// to the full file browser, and instructions for editing the MDL,
|
||||||
|
// listing any parties already present in archive/.
|
||||||
|
var projectLandingTmpl = template.Must(template.New("projectLanding").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Project}} — ZDDC</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #2a5a8a; --primary-hover: #1d4060;
|
||||||
|
--bg: #ffffff; --bg-secondary: #f8f9fa; --bg-hover: #f0f4f8;
|
||||||
|
--text: #212529; --text-muted: #6c757d;
|
||||||
|
--border: #dee2e6; --radius: 4px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--primary: #5fa8e0; --primary-hover: #74b6e6;
|
||||||
|
--bg: #1e1e1e; --bg-secondary: #252526; --bg-hover: #2d2d30;
|
||||||
|
--text: #d4d4d4; --text-muted: #9d9d9d;
|
||||||
|
--border: #3e3e42;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--text); background: var(--bg-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
header.app-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem; background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header .logo {
|
||||||
|
width: 26px; height: 26px; display: inline-block; vertical-align: middle;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
header .title-line { font-size: 1.05rem; font-weight: 600; }
|
||||||
|
header .crumb { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
main {
|
||||||
|
max-width: 880px; margin: 2rem auto; padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6rem; margin: 0 0 0.25rem; font-weight: 600; }
|
||||||
|
h1 .subtle { color: var(--text-muted); font-weight: normal; font-size: 0.9rem; }
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem; margin: 2.25rem 0 0.5rem;
|
||||||
|
padding-bottom: 0.3rem; border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p { margin: 0.5rem 0 1rem; }
|
||||||
|
.lead { color: var(--text-muted); margin-bottom: 1.5rem; }
|
||||||
|
.stages {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
.stage-card {
|
||||||
|
display: block; padding: 1rem 1.1rem;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); text-decoration: none; color: var(--text);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
.stage-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.stage-card:active { transform: translateY(1px); }
|
||||||
|
.stage-card h3 {
|
||||||
|
margin: 0 0 0.3rem; font-size: 1rem; color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stage-card p { margin: 0; color: var(--text-muted); font-size: 0.875rem; }
|
||||||
|
.browse-link {
|
||||||
|
display: inline-block; margin-top: 0.25rem; color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.browse-link:hover { text-decoration: underline; }
|
||||||
|
ol { padding-left: 1.5rem; }
|
||||||
|
ol li { margin-bottom: 0.4rem; }
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--bg-secondary); padding: 0.1em 0.35em;
|
||||||
|
border-radius: 3px; font-size: 0.86em;
|
||||||
|
}
|
||||||
|
.party-list { padding-left: 1.5rem; margin: 0.4rem 0 1rem; }
|
||||||
|
.party-list li { margin-bottom: 0.25rem; }
|
||||||
|
.party-list a { color: var(--primary); text-decoration: none; }
|
||||||
|
.party-list a:hover { text-decoration: underline; }
|
||||||
|
.none-yet { color: var(--text-muted); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<div>
|
||||||
|
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<rect x="14" y="18" width="36" height="7"/>
|
||||||
|
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||||
|
<rect x="14" y="43" width="36" height="7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="title-line">{{.Project}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="crumb">ZDDC project workspace</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>{{.Project}} <span class="subtle">— project workspace</span></h1>
|
||||||
|
<p class="lead">Pick a lifecycle stage, or browse all files.</p>
|
||||||
|
|
||||||
|
<div class="stages">
|
||||||
|
<a class="stage-card" href="/{{.ProjectURL}}/archive">
|
||||||
|
<h3>Archive</h3>
|
||||||
|
<p>Permanent record of issued and received transmittals, organized by counterparty.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" href="/{{.ProjectURL}}/working">
|
||||||
|
<h3>Working</h3>
|
||||||
|
<p>Per-user drafting workspace. Your folder is private by default; you can grant access by editing its <code>.zddc</code> file.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" href="/{{.ProjectURL}}/staging">
|
||||||
|
<h3>Staging</h3>
|
||||||
|
<p>Outbound transmittals being prepared for issue.</p>
|
||||||
|
</a>
|
||||||
|
<a class="stage-card" href="/{{.ProjectURL}}/reviewing">
|
||||||
|
<h3>Reviewing</h3>
|
||||||
|
<p>Pending review responses — inbound submittals paired with their in-progress drafts.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a class="browse-link" href="/{{.ProjectURL}}/">Browse all files →</a></p>
|
||||||
|
|
||||||
|
<h2>Master Deliverables List (MDL)</h2>
|
||||||
|
<p>Each counterparty in the archive has an MDL — an editable table
|
||||||
|
of expected deliverables. The default columns mirror the ZDDC
|
||||||
|
tracking-number components (<code>originator</code>, <code>phase</code>,
|
||||||
|
<code>project</code>, <code>area</code>, <code>discipline</code>,
|
||||||
|
<code>type</code>, <code>sequence</code>, <code>suffix</code>) plus
|
||||||
|
<code>title</code>, <code>plannedRevision</code>,
|
||||||
|
<code>plannedDate</code>, <code>status</code>, and <code>owner</code>.</p>
|
||||||
|
|
||||||
|
<p><strong>To edit the MDL for any party:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Open the project archive: <a href="/{{.ProjectURL}}/archive/"><code>/{{.ProjectURL}}/archive/</code></a></li>
|
||||||
|
<li>Click into a party's folder (e.g. <code>PartyA</code>)</li>
|
||||||
|
<li>Click <code>mdl</code> inside the party folder</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{if .Parties}}
|
||||||
|
<p><strong>Direct links — parties currently in <code>archive/</code>:</strong></p>
|
||||||
|
<ul class="party-list">
|
||||||
|
{{range .Parties}}
|
||||||
|
<li><a href="/{{$.ProjectURL}}/{{$.ArchiveSeg}}/{{.URLName}}/mdl/">{{.DisplayName}} MDL →</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="none-yet">No party folders yet. The MDL view auto-renders at
|
||||||
|
any <code>archive/<party>/mdl/</code> URL, even when the folder
|
||||||
|
doesn't exist on disk — so you can start editing an MDL before any
|
||||||
|
transmittals have been exchanged.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p>To customize the columns or schema for a specific party, drop a
|
||||||
|
<code>table.yaml</code> and <code>form.yaml</code> into
|
||||||
|
<code>archive/<party>/mdl/</code>. Operator-supplied files
|
||||||
|
override the embedded defaults entirely.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
// projectLandingData is the template input.
|
||||||
|
type projectLandingData struct {
|
||||||
|
Project string
|
||||||
|
ProjectURL string // url-escaped Project, for use in href values
|
||||||
|
ArchiveSeg string // on-disk casing of "archive" (Archive vs archive)
|
||||||
|
Parties []partyEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type partyEntry struct {
|
||||||
|
DisplayName string // on-disk name (e.g. "PartyA")
|
||||||
|
URLName string // url-escaped variant
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeProjectLanding renders the project root navigation page at
|
||||||
|
// /<project> (no trailing slash). Lists the four canonical lifecycle
|
||||||
|
// stages as cards, links into the full browse view, and provides
|
||||||
|
// instructions for editing the per-party MDL with direct links to any
|
||||||
|
// parties already present under archive/.
|
||||||
|
//
|
||||||
|
// ACL: the dispatcher already gates this entry by the project's
|
||||||
|
// .zddc cascade before calling here. No additional check needed.
|
||||||
|
func ServeProjectLanding(cfg config.Config, w http.ResponseWriter, r *http.Request, project string) {
|
||||||
|
projectAbs := filepath.Join(cfg.Root, project)
|
||||||
|
|
||||||
|
// On-disk casing of archive/ — preserve in URL hrefs so links
|
||||||
|
// don't bounce through the URL-canonicalisation layer.
|
||||||
|
archiveSeg, _ := zddc.ResolveCanonical(projectAbs, "archive")
|
||||||
|
if archiveSeg == "" {
|
||||||
|
archiveSeg = "archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate parties under archive/<party>/. Failures here are
|
||||||
|
// non-fatal — the page just renders without the direct-link list.
|
||||||
|
var parties []partyEntry
|
||||||
|
if archiveSeg != "" {
|
||||||
|
archiveAbs := filepath.Join(projectAbs, archiveSeg)
|
||||||
|
if entries, err := os.ReadDir(archiveAbs); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parties = append(parties, partyEntry{
|
||||||
|
DisplayName: name,
|
||||||
|
URLName: url.PathEscape(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(parties, func(i, j int) bool {
|
||||||
|
return parties[i].DisplayName < parties[j].DisplayName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := projectLandingData{
|
||||||
|
Project: project,
|
||||||
|
ProjectURL: url.PathEscape(project),
|
||||||
|
ArchiveSeg: url.PathEscape(archiveSeg),
|
||||||
|
Parties: parties,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store") // recomputes party list per hit
|
||||||
|
if err := projectLandingTmpl.Execute(w, data); err != nil {
|
||||||
|
// Headers already flushed; nothing to do beyond log.
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
zddc/internal/handler/projecthandler_test.go
Normal file
119
zddc/internal/handler/projecthandler_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue