feat(handler): mdl/ → table-app default with embedded fallback spec
Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:
1. **Dispatcher redirect.** GET (and HEAD) on
<project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
and deeper mdl/ paths fall through unchanged.
2. **Default-spec fallback in RecognizeTableRequest.** When a request
matches archive/<party>/mdl.table.html and no operator-supplied
tables: { mdl: ... } declaration covers it, the handler returns a
recognised request anyway. Operator declarations still win — and a
typo'd declaration pointing at a missing file yields 404 (not a
silent fallback).
3. **Static-file fallback for the spec yaml.** GET archive/<party>/
mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
default bytes (default-mdl.{table,form}.yaml in the handler package)
when no operator file exists at that path. Operator files always
win because the dispatcher's os.Stat finds them before reaching the
IsDefaultMdlSpec branch.
The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.
Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
override wins, scope check, embedded yaml served, operator yaml wins,
scope check on yaml fallback)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce108e1eb3
commit
821ed3ee19
6 changed files with 461 additions and 17 deletions
|
|
@ -529,6 +529,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
|
||||
// The table app is the canonical view for the per-party Master
|
||||
// Deliverables List. Direct navigation to the data folder lands on
|
||||
// the grid editor; clients that want the raw row listing can still
|
||||
// hit archive/<party>/mdl/ via the file API or with an explicit
|
||||
// trailing-segment beyond mdl/.
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
|
||||
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
|
||||
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||
// table handler renders inline, reading rows from a directory of
|
||||
// *.yaml files declared in the directory's .zddc tables: map.
|
||||
|
|
@ -536,6 +553,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// so RecognizeTableRequest returns nil whenever there's no matching
|
||||
// declaration and the URL falls through to the static-file path
|
||||
// (or to the form intercept below for *.form.html / *.yaml.html).
|
||||
//
|
||||
// One exception: archive/<party>/mdl.table.html falls back to the
|
||||
// embedded default MDL spec when no operator declaration exists.
|
||||
// RecognizeTableRequest implements that fallback internally.
|
||||
if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
|
||||
handler.ServeTable(cfg, tableReq, w, r)
|
||||
return
|
||||
|
|
@ -596,11 +617,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Default MDL spec fallback: archive/<party>/mdl.table.yaml
|
||||
// and archive/<party>/mdl.form.yaml are served from embedded
|
||||
// bytes when no operator file exists on disk. The table app
|
||||
// fetches these client-side; the fallback lets a fresh
|
||||
// project work out of the box.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
|
||||
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
||||
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-ZDDC-Source", "default-mdl-spec")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(bytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
// File doesn't exist at this path. If the URL matches one of
|
||||
// the five canonical app HTML names AND the request directory
|
||||
// is one where that app is available (Incoming/Working/Staging
|
||||
// for classifier/mdedit/transmittal, anywhere for archive,
|
||||
// root only for landing), resolve via the apps subsystem.
|
||||
// the canonical app HTML names AND the request directory is
|
||||
// one where that app is available (working/staging/incoming
|
||||
// for classifier, working for mdedit, staging for
|
||||
// transmittal, anywhere for archive, root only for landing),
|
||||
// resolve via the apps subsystem.
|
||||
if appsSrv != nil {
|
||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
|
|
|
|||
|
|
@ -373,6 +373,79 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDispatchMdlRedirect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*@example.com\": rwcda\n")
|
||||
mustMkdir(t, filepath.Join(root, "ProjectA", "archive", "Acme"))
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
Root: root,
|
||||
IndexPath: ".archive",
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
"mdl trailing slash → table",
|
||||
"/ProjectA/archive/Acme/mdl/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"case-fold MDL trailing slash",
|
||||
"/ProjectA/archive/Acme/MDL/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"case-fold ARCHIVE",
|
||||
"/ProjectA/Archive/Acme/mdl/",
|
||||
http.StatusFound,
|
||||
"/ProjectA/Archive/Acme/mdl.table.html",
|
||||
},
|
||||
{
|
||||
"deeper than party-level mdl is NOT redirected",
|
||||
"/ProjectA/archive/Acme/incoming/mdl/",
|
||||
// Falls through to static-file pipeline; no folder exists there → 404.
|
||||
http.StatusNotFound,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"working/mdl is NOT redirected (not under archive)",
|
||||
"/ProjectA/working/mdl/",
|
||||
http.StatusNotFound,
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, nil, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
||||
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
||||
// API's write path, so a write to an archive URL never silently mutates
|
||||
|
|
|
|||
48
zddc/internal/handler/default-mdl.form.yaml
Normal file
48
zddc/internal/handler/default-mdl.form.yaml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Default row schema for a Master Deliverables List entry. Served by
|
||||
# zddc-server when no operator-supplied mdl.form.yaml exists at
|
||||
# archive/<party>/. Operators can override per-party.
|
||||
|
||||
title: Deliverable
|
||||
description: One planned or in-flight deliverable for this party.
|
||||
|
||||
schema:
|
||||
type: object
|
||||
required: [tracking, title]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
tracking:
|
||||
type: string
|
||||
title: Tracking number
|
||||
description: ZDDC tracking identifier (e.g. proj-EM-SPC-0001).
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
title: Deliverable title
|
||||
minLength: 1
|
||||
discipline:
|
||||
type: string
|
||||
title: Discipline
|
||||
description: Engineering discipline code (EM, EL, MC, ST, ...).
|
||||
type:
|
||||
type: string
|
||||
title: Document type
|
||||
description: Code for document class (SPC, DWG, RPT, ...).
|
||||
plannedRevision:
|
||||
type: string
|
||||
title: Planned revision
|
||||
description: Issue revision label, e.g. A, B, IFR, IFC.
|
||||
plannedDate:
|
||||
type: string
|
||||
title: Planned date
|
||||
format: date
|
||||
status:
|
||||
type: string
|
||||
title: Current status
|
||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
||||
owner:
|
||||
type: string
|
||||
title: Owner
|
||||
description: Email or party name responsible for producing this row.
|
||||
notes:
|
||||
type: string
|
||||
title: Notes
|
||||
41
zddc/internal/handler/default-mdl.table.yaml
Normal file
41
zddc/internal/handler/default-mdl.table.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Default Master Deliverables List spec, served by zddc-server when no
|
||||
# operator-supplied mdl.table.yaml exists at archive/<party>/. Operators
|
||||
# can override per-party by writing their own file at
|
||||
# archive/<party>/mdl.table.yaml plus a tables: { mdl: ./mdl.table.yaml }
|
||||
# entry in the party's .zddc.
|
||||
|
||||
title: Master Deliverables List
|
||||
description: Planned and actual deliverables for this party.
|
||||
|
||||
rowSchema: ./mdl.form.yaml
|
||||
rows: ./mdl
|
||||
|
||||
columns:
|
||||
- field: tracking
|
||||
title: Tracking
|
||||
width: 11em
|
||||
sort: asc
|
||||
- field: title
|
||||
title: Deliverable
|
||||
- field: discipline
|
||||
title: Disc.
|
||||
width: 5em
|
||||
- field: type
|
||||
title: Type
|
||||
width: 6em
|
||||
- field: plannedRevision
|
||||
title: Rev.
|
||||
width: 5em
|
||||
- field: plannedDate
|
||||
title: Planned
|
||||
format: date
|
||||
- field: status
|
||||
title: Status
|
||||
width: 6em
|
||||
enum: [DFT, IFR, IFA, IFC, AFC, AB]
|
||||
- field: owner
|
||||
title: Owner
|
||||
|
||||
defaults:
|
||||
sort:
|
||||
- { field: plannedDate, dir: asc }
|
||||
|
|
@ -41,6 +41,66 @@ import (
|
|||
//go:embed tables.html
|
||||
var embeddedTablesHTML []byte
|
||||
|
||||
//go:embed default-mdl.table.yaml
|
||||
var embeddedDefaultMdlTable []byte
|
||||
|
||||
//go:embed default-mdl.form.yaml
|
||||
var embeddedDefaultMdlForm []byte
|
||||
|
||||
// DefaultMdlTableYAML returns the embedded default mdl.table.yaml bytes.
|
||||
// Used by the static-file handler to serve the default spec at
|
||||
// archive/<party>/mdl.table.yaml when no operator file exists on disk.
|
||||
func DefaultMdlTableYAML() []byte { return embeddedDefaultMdlTable }
|
||||
|
||||
// DefaultMdlFormYAML returns the embedded default mdl.form.yaml bytes.
|
||||
func DefaultMdlFormYAML() []byte { return embeddedDefaultMdlForm }
|
||||
|
||||
// IsDefaultMdlSpec reports whether (urlPath, dirAbs) describes a request
|
||||
// for the default mdl.table.yaml or mdl.form.yaml under archive/<party>/
|
||||
// where no operator file exists. Caller is the static-file handler.
|
||||
//
|
||||
// Returns the embedded bytes + true when the fallback should fire.
|
||||
// Returns nil + false when an operator-supplied file exists or the path
|
||||
// is not eligible for the fallback.
|
||||
func IsDefaultMdlSpec(fsRoot, urlPath string) ([]byte, bool) {
|
||||
base := strings.ToLower(filepath.Base(urlPath))
|
||||
var bytes []byte
|
||||
switch base {
|
||||
case "mdl.table.yaml":
|
||||
bytes = embeddedDefaultMdlTable
|
||||
case "mdl.form.yaml":
|
||||
bytes = embeddedDefaultMdlForm
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
if !isAtArchivePartyLevel(fsRoot, urlPath) {
|
||||
return nil, false
|
||||
}
|
||||
// Operator file wins if it exists on disk.
|
||||
rel := strings.TrimPrefix(filepath.ToSlash(urlPath), "/")
|
||||
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
||||
if !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) && abs != fsRoot {
|
||||
return nil, false
|
||||
}
|
||||
if fileExists(abs) {
|
||||
return nil, false
|
||||
}
|
||||
return bytes, true
|
||||
}
|
||||
|
||||
// isAtArchivePartyLevel reports whether urlPath refers to a file
|
||||
// directly under <project>/archive/<party>/ (depth-3 directory). The
|
||||
// canonical-folder names are case-folded.
|
||||
func isAtArchivePartyLevel(fsRoot, urlPath string) bool {
|
||||
rel := strings.Trim(filepath.ToSlash(urlPath), "/")
|
||||
parts := strings.Split(rel, "/")
|
||||
// <project>/archive/<party>/<file> = 4 segments
|
||||
if len(parts) != 4 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parts[1], "archive")
|
||||
}
|
||||
|
||||
// TableRequest describes a recognized table-system request.
|
||||
type TableRequest struct {
|
||||
// Name is the table's URL stem (the key declared in .zddc tables).
|
||||
|
|
@ -86,16 +146,17 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
|||
}
|
||||
zddcPath := filepath.Join(dirAbs, ".zddc")
|
||||
zf, err := zddc.ParseFile(zddcPath)
|
||||
if err != nil {
|
||||
if err != nil && !isNotExistError(err) {
|
||||
// Malformed .zddc — log and pass through; static handler will 500
|
||||
// if it cares. Recognition just says "not a declared table here."
|
||||
slog.Warn("table: .zddc parse error", "path", zddcPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
specRel, ok := zf.Tables[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if specRel, ok := zf.Tables[name]; ok {
|
||||
// Operator explicitly declared this table — honour it strictly.
|
||||
// If the declared spec file is missing, return nil so the URL
|
||||
// 404s rather than silently falling back to the default. This
|
||||
// keeps a typo in the operator's .zddc visible.
|
||||
specAbs := filepath.Join(dirAbs, filepath.FromSlash(specRel))
|
||||
if !strings.HasPrefix(specAbs, fsRoot+string(filepath.Separator)) && specAbs != fsRoot {
|
||||
return nil
|
||||
|
|
@ -109,6 +170,42 @@ func RecognizeTableRequest(fsRoot, method, urlPath string) *TableRequest {
|
|||
Dir: dirAbs,
|
||||
}
|
||||
}
|
||||
// No operator declaration — apply the default MDL spec fallback at
|
||||
// archive/<party>/. The spec bytes are served by IsDefaultMdlSpec via
|
||||
// the static-file dispatcher.
|
||||
if strings.EqualFold(name, "mdl") && isArchivePartyDir(fsRoot, dirAbs) {
|
||||
return &TableRequest{
|
||||
Name: "mdl",
|
||||
SpecPath: filepath.Join(dirAbs, "mdl.table.yaml"), // virtual; static handler may serve embedded bytes
|
||||
Dir: dirAbs,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNotExistError reports whether err indicates a missing file. Local
|
||||
// helper to avoid pulling errors.Is into the handler package.
|
||||
func isNotExistError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no such file or directory")
|
||||
}
|
||||
|
||||
// isArchivePartyDir reports whether dirAbs is a <project>/archive/<party>/
|
||||
// directory under fsRoot, with archive case-folded.
|
||||
func isArchivePartyDir(fsRoot, dirAbs string) bool {
|
||||
rel, err := filepath.Rel(fsRoot, dirAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(rel, "../") || rel == ".." || rel == "." {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parts[1], "archive")
|
||||
}
|
||||
|
||||
// ServeTable serves the static tables.html bytes for a recognized
|
||||
// request. ACL gate is the read action at the request directory; on
|
||||
|
|
|
|||
|
|
@ -227,3 +227,144 @@ tables:
|
|||
t.Errorf("status = %d want 403; body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- default MDL spec fallback ---------------------------------------------
|
||||
|
||||
// archivePartyTestSetup builds a minimal Project/archive/<party>/ tree
|
||||
// with no operator-supplied tables: declaration. RecognizeTableRequest
|
||||
// should still fire for "mdl" thanks to the default-spec fallback.
|
||||
func archivePartyTestSetup(t *testing.T, partyZddcExtras string) (string, func(method, target, email string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
||||
[]byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partyDir := filepath.Join(root, "Project", "archive", "Acme")
|
||||
if err := os.MkdirAll(partyDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partyZddcExtras != "" {
|
||||
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), []byte(partyZddcExtras), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
zddc.InvalidateCache(root)
|
||||
|
||||
cfg := config.Config{
|
||||
Root: root,
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
}
|
||||
do := func(method, target, email string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(nil))
|
||||
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
tr := RecognizeTableRequest(cfg.Root, method, target)
|
||||
rec := httptest.NewRecorder()
|
||||
if tr == nil {
|
||||
rec.WriteHeader(http.StatusNotFound)
|
||||
return rec
|
||||
}
|
||||
ServeTable(cfg, tr, rec, req)
|
||||
return rec
|
||||
}
|
||||
return root, do
|
||||
}
|
||||
|
||||
func TestRecognizeTableRequest_DefaultMdlAtArchiveParty(t *testing.T) {
|
||||
_, do := archivePartyTestSetup(t, "")
|
||||
|
||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("default mdl recognition: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "<html") {
|
||||
t.Errorf("expected tables.html bytes, got %q…", body[:min(80, len(body))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeTableRequest_OperatorOverrideWins(t *testing.T) {
|
||||
// Operator declared a custom mdl spec that points at a non-existent
|
||||
// file. The fallback should NOT fire, because the operator
|
||||
// explicitly took control. RecognizeTableRequest returns nil.
|
||||
root, do := archivePartyTestSetup(t, `tables:
|
||||
mdl: ./custom-mdl.table.yaml
|
||||
`)
|
||||
_ = root
|
||||
rec := do(http.MethodGet, "/Project/archive/Acme/mdl.table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("operator declaration with missing spec should fall through to 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeTableRequest_DefaultOnlyAtPartyLevel(t *testing.T) {
|
||||
// Default fallback is scoped to <project>/archive/<party>/. A
|
||||
// request at a deeper path (e.g. archive/Acme/mdl/sub/) or a
|
||||
// non-archive path should return nil (no recognition).
|
||||
_, do := archivePartyTestSetup(t, "")
|
||||
|
||||
rec := do(http.MethodGet, "/Project/archive/Acme/incoming/mdl.table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("mdl deeper than party level should not recognise; got %d", rec.Code)
|
||||
}
|
||||
rec = do(http.MethodGet, "/Project/working/mdl.table.html", "alice@example.com")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("mdl outside archive/ should not recognise; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefaultMdlSpec_ServesEmbeddedYAML(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// archive/Acme/ exists but no mdl.table.yaml on disk.
|
||||
if err := os.MkdirAll(filepath.Join(root, "Project", "archive", "Acme"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bts, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected fallback to fire")
|
||||
}
|
||||
if !strings.Contains(string(bts), "Master Deliverables List") {
|
||||
t.Errorf("default table spec missing expected header; got %q…", string(bts)[:min(80, len(bts))])
|
||||
}
|
||||
|
||||
bts, ok = IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.form.yaml")
|
||||
if !ok {
|
||||
t.Fatalf("expected form fallback to fire")
|
||||
}
|
||||
if !strings.Contains(string(bts), "Deliverable") {
|
||||
t.Errorf("default form spec missing expected title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefaultMdlSpec_OperatorFileWins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "Project", "archive", "Acme")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "mdl.table.yaml"), []byte("custom: yes\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := IsDefaultMdlSpec(root, "/Project/archive/Acme/mdl.table.yaml"); ok {
|
||||
t.Errorf("operator file should win over embedded fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefaultMdlSpec_OnlyAtArchivePartyLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cases := []string{
|
||||
"/Project/working/mdl.table.yaml",
|
||||
"/Project/archive/mdl.table.yaml", // depth 3 — no party segment
|
||||
"/Project/archive/Acme/sub/mdl.table.yaml",
|
||||
}
|
||||
for _, p := range cases {
|
||||
if _, ok := IsDefaultMdlSpec(root, p); ok {
|
||||
t.Errorf("path %q should NOT trigger default fallback", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue