feat(zddc): ResolveVirtualView resolver for project-level table aggregations
Models virtualreceived.go's request-time path-rewrite pattern. Recognizes
/<project>/{ssr,mdl,rsk}/... URLs and maps row reads/writes back to
canonical files inside <project>/archive/<party>/, so ACL evaluates
against the per-party chain and operator overrides live where the data
does. ListSSRParties and ListRollupRows feed listing-time synthesis.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da4754b6ef
commit
351dc63cb4
2 changed files with 649 additions and 0 deletions
361
zddc/internal/zddc/virtualviews.go
Normal file
361
zddc/internal/zddc/virtualviews.go
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Virtual project-level table views — SSR, MDL rollup, RSK rollup.
|
||||||
|
//
|
||||||
|
// All three are declared `virtual: true` in defaults.zddc.yaml under
|
||||||
|
// `<project>/{ssr,mdl,rsk}`. The folder does not exist on disk: the
|
||||||
|
// server synthesizes listings by walking archive/*/ at request time
|
||||||
|
// and rewrites file reads/writes back to canonical paths inside the
|
||||||
|
// per-party folders. ACL on each synthetic row is evaluated against
|
||||||
|
// the canonical `<project>/archive/<party>/` chain, so party owners
|
||||||
|
// can edit their own rows and non-owners see them read-only.
|
||||||
|
//
|
||||||
|
// URL conventions
|
||||||
|
//
|
||||||
|
// /<project>/ssr/ → directory listing
|
||||||
|
// /<project>/ssr/table.yaml | form.yaml → embedded default spec
|
||||||
|
// /<project>/ssr/<party>.yaml → reads <project>/archive/<party>/ssr.yaml
|
||||||
|
// /<project>/ssr/<party>.yaml.html → form edit (form recognizer)
|
||||||
|
// /<project>/ssr/form.html → "+ Add row" — SSR create
|
||||||
|
//
|
||||||
|
// /<project>/mdl/ → rollup listing
|
||||||
|
// /<project>/mdl/table.yaml | form.yaml → embedded default project-rollup spec
|
||||||
|
// /<project>/mdl/<party>__<file>.yaml → reads <project>/archive/<party>/mdl/<file>.yaml
|
||||||
|
// /<project>/mdl/<party>__<file>.yaml.html → form edit
|
||||||
|
//
|
||||||
|
// /<project>/rsk/ → analogous
|
||||||
|
//
|
||||||
|
// Modeled on virtualreceived.go: one resolver produces canonical
|
||||||
|
// paths; every caller (listing builder, file API rewrite, form
|
||||||
|
// recognizer) reads its policy chain from the canonical path.
|
||||||
|
|
||||||
|
// VirtualViewKind classifies a resolved virtual URL.
|
||||||
|
type VirtualViewKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
VirtualViewNone VirtualViewKind = iota
|
||||||
|
VirtualViewSSRRoot
|
||||||
|
VirtualViewSSRSpec
|
||||||
|
VirtualViewSSRRow
|
||||||
|
VirtualViewMDLRoot
|
||||||
|
VirtualViewMDLSpec
|
||||||
|
VirtualViewMDLRow
|
||||||
|
VirtualViewRSKRoot
|
||||||
|
VirtualViewRSKSpec
|
||||||
|
VirtualViewRSKRow
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsRowKind reports whether k targets a per-party row file (true for
|
||||||
|
// SSRRow, MDLRow, RSKRow).
|
||||||
|
func (k VirtualViewKind) IsRowKind() bool {
|
||||||
|
switch k {
|
||||||
|
case VirtualViewSSRRow, VirtualViewMDLRow, VirtualViewRSKRow:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSpecKind reports whether k targets a virtual table.yaml/form.yaml.
|
||||||
|
func (k VirtualViewKind) IsSpecKind() bool {
|
||||||
|
switch k {
|
||||||
|
case VirtualViewSSRSpec, VirtualViewMDLSpec, VirtualViewRSKSpec:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRootKind reports whether k targets the listing-level URL of a
|
||||||
|
// virtual view.
|
||||||
|
func (k VirtualViewKind) IsRootKind() bool {
|
||||||
|
switch k {
|
||||||
|
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VirtualViewResolution captures the result of mapping a URL onto
|
||||||
|
// one of the project-level virtual table views. All fields are
|
||||||
|
// populated only when Resolved is true.
|
||||||
|
type VirtualViewResolution struct {
|
||||||
|
Resolved bool
|
||||||
|
Kind VirtualViewKind
|
||||||
|
|
||||||
|
Project string // "<project>"
|
||||||
|
ProjectURL string // "/<project>/"
|
||||||
|
ProjectAbs string // <fsRoot>/<project>
|
||||||
|
|
||||||
|
Slot string // "ssr", "mdl", or "rsk"
|
||||||
|
SlotURL string // "/<project>/<slot>/"
|
||||||
|
|
||||||
|
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
|
||||||
|
SpecBase string
|
||||||
|
|
||||||
|
// Populated for VirtualView*Row kinds.
|
||||||
|
Party string // party folder name (e.g. "0330C1")
|
||||||
|
PartyArchive string // <fsRoot>/<project>/archive/<party>
|
||||||
|
CanonicalAbs string // underlying file on disk
|
||||||
|
CanonicalURL string // /<project>/archive/<party>/...
|
||||||
|
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded)
|
||||||
|
RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
|
||||||
|
// of the canonical virtual view names. Capture 1 = project, capture
|
||||||
|
// 2 = slot, capture 3 = rest (may be empty).
|
||||||
|
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`)
|
||||||
|
|
||||||
|
// partyNameRE matches the SSR schema's `name` pattern. Same regex
|
||||||
|
// used at row-resolution time so URLs with invalid party tokens fail
|
||||||
|
// resolution cleanly instead of producing impossible canonical paths.
|
||||||
|
var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`)
|
||||||
|
|
||||||
|
// ValidPartyName reports whether s is a valid party folder name —
|
||||||
|
// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. Used by URL
|
||||||
|
// resolution AND by the SSR create handler to validate user input.
|
||||||
|
func ValidPartyName(s string) bool {
|
||||||
|
return partyNameRE.MatchString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveVirtualView inspects urlPath and returns a populated
|
||||||
|
// resolution iff the URL targets one of the project-level virtual
|
||||||
|
// views (ssr/, mdl/, rsk/). On a non-match, Resolved=false.
|
||||||
|
//
|
||||||
|
// The resolver does NOT check that the project / party / row file
|
||||||
|
// actually exist on disk — that's the caller's job (handlers use
|
||||||
|
// the canonical path; listings synthesize from real disk state).
|
||||||
|
//
|
||||||
|
// urlPath must be a server-relative URL with one leading slash.
|
||||||
|
// Trailing slashes are tolerated for root kinds.
|
||||||
|
func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
|
||||||
|
var out VirtualViewResolution
|
||||||
|
if urlPath == "" || urlPath[0] != '/' {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSuffix(urlPath, "/")
|
||||||
|
m := virtualViewRE.FindStringSubmatch(trimmed)
|
||||||
|
if m == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
project := m[1]
|
||||||
|
slot := m[2]
|
||||||
|
rest := m[3]
|
||||||
|
|
||||||
|
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
projectAbs := filepath.Join(fsRoot, filepath.FromSlash(project))
|
||||||
|
if !strings.HasPrefix(projectAbs, fsRoot+string(filepath.Separator)) && projectAbs != fsRoot {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Project = project
|
||||||
|
out.ProjectURL = "/" + project + "/"
|
||||||
|
out.ProjectAbs = projectAbs
|
||||||
|
out.Slot = slot
|
||||||
|
out.SlotURL = "/" + project + "/" + slot + "/"
|
||||||
|
|
||||||
|
if rest == "" {
|
||||||
|
switch slot {
|
||||||
|
case "ssr":
|
||||||
|
out.Kind = VirtualViewSSRRoot
|
||||||
|
case "mdl":
|
||||||
|
out.Kind = VirtualViewMDLRoot
|
||||||
|
case "rsk":
|
||||||
|
out.Kind = VirtualViewRSKRoot
|
||||||
|
}
|
||||||
|
out.Resolved = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest == "table.yaml" || rest == "form.yaml" {
|
||||||
|
switch slot {
|
||||||
|
case "ssr":
|
||||||
|
out.Kind = VirtualViewSSRSpec
|
||||||
|
case "mdl":
|
||||||
|
out.Kind = VirtualViewMDLSpec
|
||||||
|
case "rsk":
|
||||||
|
out.Kind = VirtualViewRSKSpec
|
||||||
|
}
|
||||||
|
out.SpecBase = rest
|
||||||
|
out.Resolved = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row files — must be a single segment ending in .yaml.
|
||||||
|
if strings.Contains(rest, "/") || !strings.HasSuffix(rest, ".yaml") {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(rest, ".yaml")
|
||||||
|
|
||||||
|
if slot == "ssr" {
|
||||||
|
if !ValidPartyName(name) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Party = name
|
||||||
|
out.PartyArchive = filepath.Join(projectAbs, "archive", name)
|
||||||
|
out.CanonicalAbs = filepath.Join(out.PartyArchive, "ssr.yaml")
|
||||||
|
out.CanonicalURL = "/" + project + "/archive/" + name + "/ssr.yaml"
|
||||||
|
out.SchemaAbs = filepath.Join(out.PartyArchive, "ssr.form.yaml")
|
||||||
|
out.Kind = VirtualViewSSRRow
|
||||||
|
out.Resolved = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MDL/RSK rollup row — <party>__<file>.yaml.
|
||||||
|
idx := strings.Index(name, "__")
|
||||||
|
if idx <= 0 || idx >= len(name)-2 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
party := name[:idx]
|
||||||
|
rowBase := name[idx+2:]
|
||||||
|
if !ValidPartyName(party) || rowBase == "" || strings.Contains(rowBase, "__") {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.Party = party
|
||||||
|
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
|
||||||
|
out.RowFilename = rowBase + ".yaml"
|
||||||
|
out.CanonicalAbs = filepath.Join(out.PartyArchive, slot, out.RowFilename)
|
||||||
|
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + out.RowFilename
|
||||||
|
switch slot {
|
||||||
|
case "mdl":
|
||||||
|
out.Kind = VirtualViewMDLRow
|
||||||
|
case "rsk":
|
||||||
|
out.Kind = VirtualViewRSKRow
|
||||||
|
}
|
||||||
|
out.Resolved = true
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html
|
||||||
|
// — the SSR "+ Add row" target. Returns the project name when matched.
|
||||||
|
func IsSSRCreateURL(urlPath string) (string, bool) {
|
||||||
|
if urlPath == "" || urlPath[0] != '/' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(urlPath, "/"), "/")
|
||||||
|
if len(parts) != 3 || parts[1] != "ssr" || parts[2] != "form.html" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
project := parts[0]
|
||||||
|
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return project, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
|
||||||
|
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
|
||||||
|
// returns urlPath unchanged + false. The form recognizer calls this
|
||||||
|
// before passing the data URL into ResolveVirtualView.
|
||||||
|
func StripYAMLHTML(urlPath string) (string, bool) {
|
||||||
|
if strings.HasSuffix(urlPath, ".yaml.html") {
|
||||||
|
return strings.TrimSuffix(urlPath, ".html"), true
|
||||||
|
}
|
||||||
|
return urlPath, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSSRParties returns the party folder names that exist under
|
||||||
|
// <project>/archive/. Names are filtered through ValidPartyName so a
|
||||||
|
// hand-created folder with a weird name (e.g. "0330C1 (draft)") won't
|
||||||
|
// confuse the rest of the resolver. Returns nil + nil when archive/
|
||||||
|
// doesn't exist on disk.
|
||||||
|
func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
|
||||||
|
archive := filepath.Join(projectAbs, "archive")
|
||||||
|
entries, err := os.ReadDir(archive)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !ValidPartyName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VirtualRollupRow describes one synthetic row in a project-level
|
||||||
|
// MDL or RSK rollup.
|
||||||
|
type VirtualRollupRow struct {
|
||||||
|
Party string // source party folder
|
||||||
|
Filename string // e.g. "D-001.yaml"
|
||||||
|
SyntheticName string // e.g. "0330C1__D-001.yaml" — used in URLs
|
||||||
|
CanonicalAbs string // underlying file on disk
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRollupRows walks <project>/archive/*/<slot>/ and returns one
|
||||||
|
// synthetic row per *.yaml file. slot must be "mdl" or "rsk".
|
||||||
|
// Returns rows sorted by (party, filename).
|
||||||
|
//
|
||||||
|
// Skipped:
|
||||||
|
// - filenames containing "__" (would break the party__file split)
|
||||||
|
// - "table.yaml" and "form.yaml" (operator spec/schema, not rows)
|
||||||
|
// - any non-*.yaml file
|
||||||
|
// - parties with invalid folder names (filtered by ListSSRParties)
|
||||||
|
func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) {
|
||||||
|
if slot != "mdl" && slot != "rsk" {
|
||||||
|
return nil, errors.New("ListRollupRows: slot must be mdl or rsk")
|
||||||
|
}
|
||||||
|
parties, err := ListSSRParties(fsRoot, projectAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]VirtualRollupRow, 0, len(parties))
|
||||||
|
for _, party := range parties {
|
||||||
|
slotDir := filepath.Join(projectAbs, "archive", party, slot)
|
||||||
|
entries, err := os.ReadDir(slotDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name == "table.yaml" || name == "form.yaml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "__") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, VirtualRollupRow{
|
||||||
|
Party: party,
|
||||||
|
Filename: name,
|
||||||
|
SyntheticName: party + "__" + name,
|
||||||
|
CanonicalAbs: filepath.Join(slotDir, name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Party != out[j].Party {
|
||||||
|
return out[i].Party < out[j].Party
|
||||||
|
}
|
||||||
|
return out[i].Filename < out[j].Filename
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
288
zddc/internal/zddc/virtualviews_test.go
Normal file
288
zddc/internal/zddc/virtualviews_test.go
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveVirtualView_Roots(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
want VirtualViewKind
|
||||||
|
}{
|
||||||
|
{"/Project/ssr", VirtualViewSSRRoot},
|
||||||
|
{"/Project/ssr/", VirtualViewSSRRoot},
|
||||||
|
{"/Project/mdl", VirtualViewMDLRoot},
|
||||||
|
{"/Project/mdl/", VirtualViewMDLRoot},
|
||||||
|
{"/Project/rsk", VirtualViewRSKRoot},
|
||||||
|
{"/Project/rsk/", VirtualViewRSKRoot},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := ResolveVirtualView(root, tc.url)
|
||||||
|
if !got.Resolved || got.Kind != tc.want {
|
||||||
|
t.Errorf("%s: kind=%d resolved=%v; want kind=%d resolved=true", tc.url, got.Kind, got.Resolved, tc.want)
|
||||||
|
}
|
||||||
|
if got.Project != "Project" {
|
||||||
|
t.Errorf("%s: project=%q want Project", tc.url, got.Project)
|
||||||
|
}
|
||||||
|
if !got.Kind.IsRootKind() {
|
||||||
|
t.Errorf("%s: IsRootKind=false", tc.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVirtualView_Specs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
wantKind VirtualViewKind
|
||||||
|
wantBase string
|
||||||
|
}{
|
||||||
|
{"/Project/ssr/table.yaml", VirtualViewSSRSpec, "table.yaml"},
|
||||||
|
{"/Project/ssr/form.yaml", VirtualViewSSRSpec, "form.yaml"},
|
||||||
|
{"/Project/mdl/table.yaml", VirtualViewMDLSpec, "table.yaml"},
|
||||||
|
{"/Project/mdl/form.yaml", VirtualViewMDLSpec, "form.yaml"},
|
||||||
|
{"/Project/rsk/table.yaml", VirtualViewRSKSpec, "table.yaml"},
|
||||||
|
{"/Project/rsk/form.yaml", VirtualViewRSKSpec, "form.yaml"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := ResolveVirtualView(root, tc.url)
|
||||||
|
if !got.Resolved || got.Kind != tc.wantKind {
|
||||||
|
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
|
||||||
|
}
|
||||||
|
if got.SpecBase != tc.wantBase {
|
||||||
|
t.Errorf("%s: SpecBase=%q want %q", tc.url, got.SpecBase, tc.wantBase)
|
||||||
|
}
|
||||||
|
if !got.Kind.IsSpecKind() {
|
||||||
|
t.Errorf("%s: IsSpecKind=false", tc.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVirtualView_SSRRow(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
got := ResolveVirtualView(root, "/Project/ssr/0330C1.yaml")
|
||||||
|
if !got.Resolved || got.Kind != VirtualViewSSRRow {
|
||||||
|
t.Fatalf("unexpected resolution: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Party != "0330C1" {
|
||||||
|
t.Errorf("Party=%q want 0330C1", got.Party)
|
||||||
|
}
|
||||||
|
wantAbs := filepath.Join(root, "Project", "archive", "0330C1", "ssr.yaml")
|
||||||
|
if got.CanonicalAbs != wantAbs {
|
||||||
|
t.Errorf("CanonicalAbs=%q want %q", got.CanonicalAbs, wantAbs)
|
||||||
|
}
|
||||||
|
wantSchema := filepath.Join(root, "Project", "archive", "0330C1", "ssr.form.yaml")
|
||||||
|
if got.SchemaAbs != wantSchema {
|
||||||
|
t.Errorf("SchemaAbs=%q want %q", got.SchemaAbs, wantSchema)
|
||||||
|
}
|
||||||
|
if !got.Kind.IsRowKind() {
|
||||||
|
t.Errorf("IsRowKind=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVirtualView_RollupRow(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
wantKind VirtualViewKind
|
||||||
|
wantParty string
|
||||||
|
wantFilename string
|
||||||
|
wantSlot string
|
||||||
|
}{
|
||||||
|
{"/Project/mdl/0330C1__D-001.yaml", VirtualViewMDLRow, "0330C1", "D-001.yaml", "mdl"},
|
||||||
|
{"/Project/rsk/Acme__R-005.yaml", VirtualViewRSKRow, "Acme", "R-005.yaml", "rsk"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := ResolveVirtualView(root, tc.url)
|
||||||
|
if !got.Resolved || got.Kind != tc.wantKind {
|
||||||
|
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.Party != tc.wantParty {
|
||||||
|
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
|
||||||
|
}
|
||||||
|
if got.RowFilename != tc.wantFilename {
|
||||||
|
t.Errorf("%s: RowFilename=%q want %q", tc.url, got.RowFilename, tc.wantFilename)
|
||||||
|
}
|
||||||
|
wantAbs := filepath.Join(root, "Project", "archive", tc.wantParty, tc.wantSlot, tc.wantFilename)
|
||||||
|
if got.CanonicalAbs != wantAbs {
|
||||||
|
t.Errorf("%s: CanonicalAbs=%q want %q", tc.url, got.CanonicalAbs, wantAbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveVirtualView_NonMatches(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []string{
|
||||||
|
"/",
|
||||||
|
"/Project",
|
||||||
|
"/Project/",
|
||||||
|
"/Project/working",
|
||||||
|
"/Project/archive/Acme/mdl",
|
||||||
|
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
|
||||||
|
"/Project/mdl/__leading.yaml", // empty party
|
||||||
|
"/Project/mdl/party__.yaml", // empty rowBase
|
||||||
|
"/Project/ssr/.hidden.yaml", // dotfile party name
|
||||||
|
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
|
||||||
|
"/Project/notaslot/table.yaml",
|
||||||
|
}
|
||||||
|
for _, url := range cases {
|
||||||
|
got := ResolveVirtualView(root, url)
|
||||||
|
if got.Resolved {
|
||||||
|
t.Errorf("%s: unexpectedly resolved as kind %d", url, got.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSSRCreateURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"/Project/ssr/form.html", "Project", true},
|
||||||
|
{"/Other-Project/ssr/form.html", "Other-Project", true},
|
||||||
|
{"/Project/ssr/", "", false},
|
||||||
|
{"/Project/ssr/Acme.yaml.html", "", false},
|
||||||
|
{"/Project/mdl/form.html", "", false},
|
||||||
|
{"/.hidden/ssr/form.html", "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := IsSSRCreateURL(tc.url)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Errorf("%s: ok=%v want %v", tc.url, ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s: project=%q want %q", tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripYAMLHTML(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true},
|
||||||
|
{"/Project/mdl/foo__bar.yaml.html", "/Project/mdl/foo__bar.yaml", true},
|
||||||
|
{"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false},
|
||||||
|
{"/Project/ssr/form.html", "/Project/ssr/form.html", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := StripYAMLHTML(tc.in)
|
||||||
|
if got != tc.want || ok != tc.wantOK {
|
||||||
|
t.Errorf("%s: (%q, %v) want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidPartyName(t *testing.T) {
|
||||||
|
ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"}
|
||||||
|
bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"}
|
||||||
|
for _, s := range ok {
|
||||||
|
if !ValidPartyName(s) {
|
||||||
|
t.Errorf("ValidPartyName(%q) = false, want true", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range bad {
|
||||||
|
if ValidPartyName(s) {
|
||||||
|
t.Errorf("ValidPartyName(%q) = true, want false", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSSRParties(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
projectAbs := filepath.Join(root, "Project")
|
||||||
|
for _, party := range []string{"0330C1", "0440P2", "Acme"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", party), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A file (not a dir) and a hidden folder should be filtered out.
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "stray.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", ".hidden"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parties, err := ListSSRParties(root, projectAbs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"0330C1", "0440P2", "Acme"}
|
||||||
|
if strings.Join(parties, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("got %v, want %v", parties, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSSRParties_NoArchive(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
projectAbs := filepath.Join(root, "Project")
|
||||||
|
parties, err := ListSSRParties(root, projectAbs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v want nil", err)
|
||||||
|
}
|
||||||
|
if len(parties) != 0 {
|
||||||
|
t.Errorf("got %v, want empty", parties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRollupRows(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
projectAbs := filepath.Join(root, "Project")
|
||||||
|
|
||||||
|
for _, party := range []string{"0330C1", "0440P2"} {
|
||||||
|
mdlDir := filepath.Join(projectAbs, "archive", party, "mdl")
|
||||||
|
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Real rows.
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-002.yaml"), []byte("id: D-002\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0440P2", "mdl", "D-010.yaml"), []byte("id: D-010\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Skipped: table.yaml, form.yaml, anything containing "__".
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "table.yaml"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "form.yaml"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "weird__name.yaml"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ListRollupRows(root, projectAbs, "mdl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
wantNames := []string{"0330C1__D-001.yaml", "0330C1__D-002.yaml", "0440P2__D-010.yaml"}
|
||||||
|
for i, want := range wantNames {
|
||||||
|
if rows[i].SyntheticName != want {
|
||||||
|
t.Errorf("row[%d].SyntheticName=%q want %q", i, rows[i].SyntheticName, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRollupRows_BadSlot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if _, err := ListRollupRows(root, root, "ssr"); err == nil {
|
||||||
|
t.Error("expected error for slot=ssr (only mdl/rsk valid)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue