mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
1e48a7a1dd
Wraps PLAN-2357's two endpoints behind one command:
pad item copy <ref> --to-workspace <slug> --collection <slug>
[--dry-run] [--archive-source] [--field key=value ...]
--dry-run renders the preflight's three contract buckets (carried /
dropped / needs_value) and DR-15's full warning set. Every bucket header
and every warning line prints unconditionally, zeros and empties
included: omitting a zero would make "no attachments" indistinguishable
from "this CLI does not report attachments", and DR-17's whole point is
that none of it is silent. Schema-supplied strings are escaped and list
members quoted, so a comma or newline in an option value cannot forge an
entry or a row.
--format json emits the endpoint's own response. json.Indent is a lexical
transform, so key order, unmodelled fields and int64 precision all
survive; the bytes are never round-tripped through a Go value.
DR-13, the no-retry obligation. There is no idempotency key, so a blind
re-run duplicates the item. Four mechanisms, each with a test:
1. the mutating copy runs on its own *http.Client AND its own
transport. The transport half is the one that matters: retry in Go
is almost always a RoundTripper wrapper, which a merely-dedicated
http.Client would inherit. A plain *http.Transport is cloned so
proxy/TLS config carries; a wrapper is not used at all;
2. its body is hidden behind an opaque reader, leaving Request.GetBody
nil so net/http's own nothing-written replay cannot fire;
3. redirects are refused rather than followed with the POST body;
4. failures are classified into three exclusive outcomes, because each
licenses a different thing to say. UNKNOWN (transport failure, 500
copy_failed) sends the user to check the destination and never
suggests a retry. COMMITTED-BUT-UNREPORTED (a 2xx whose body could
not be read or decoded) exits ZERO -- a non-zero exit would tell a
script the copy did not happen, which is the DR-13 duplicate
arrived at through the reporting layer. A 4xx is a refusal made
before any write and passes through plainly.
The same asymmetry governs stdout: a write failure on the dry run is an
error (nothing happened), while a write failure after the copy committed
goes to stderr and leaves the exit code at 0.
Refuse to guess. The preflight always runs first (it is read-only), and a
non-empty needs_value refuses before any mutating request, naming each
field and the exact --field flags to add. Mirrors the web dialog's
disabled confirm rather than round-tripping the user into an error they
could have been shown.
--field values are typed against the DESTINATION collection's schema, so
a number lands as a number. A malformed --field is a hard error here
rather than the silent skip `pad item create` does: this command's
contract is "you were told what to supply", and dropping a supplied value
would make the refusal a lie.
The response types in internal/cli mirror internal/server's. That is a
layering choice, not a cycle -- nothing in server imports cli, and the
mirror test imports server freely. It follows the posture already
recorded in internal/cli/bootstrap.go: this package is the HTTP client
and does not depend on the server package. An external cli_test package
walks both response shapes and fails on any JSON contract drift.
MCP is deliberately untouched: no pad_item.action: copy, and
ToolSurfaceVersion stays 0.15.
159 lines
4.6 KiB
Go
159 lines
4.6 KiB
Go
package cli_test
|
|
|
|
// The copy RESPONSE types in client_items_copy.go are hand-written mirrors
|
|
// of internal/server's, kept separate by the layering choice documented
|
|
// there. Nothing in the compiler stops them drifting. This test walks both
|
|
// shapes and fails on any difference in JSON field names, omitempty, field
|
|
// sets, or kinds — so a server-side rename is a red build here rather than a
|
|
// field that silently stops rendering in `pad item copy`.
|
|
//
|
|
// SCOPE, precisely: the two RESPONSE types, ItemCopyPreflight and
|
|
// ItemCopyResult, recursively. The REQUEST type is deliberately not here —
|
|
// the server's (itemCopyPreflightRequest) is unexported, so no test outside
|
|
// package server can name it. What covers the request instead is
|
|
// TestCopyItem_RequestShapeIsTheDocumentedOne in client_items_copy_test.go,
|
|
// which asserts the exact key set that goes out on the wire, and
|
|
// TestCopyPreflightAndCopySendIdenticalBodies, which pins the server's
|
|
// "both endpoints take the same body" contract. A request-side rename on the
|
|
// server is therefore caught by those two, not by this file.
|
|
//
|
|
// This file lives in the EXTERNAL test package (cli_test) because it imports
|
|
// internal/server, and package cli itself deliberately does not.
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/cli"
|
|
"github.com/PerpetualSoftware/pad/internal/server"
|
|
)
|
|
|
|
func TestItemCopyMirrorsMatchServerShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mirror any
|
|
origin any
|
|
}{
|
|
{"ItemCopyPreflight", cli.ItemCopyPreflight{}, server.ItemCopyPreflight{}},
|
|
{"ItemCopyResult", cli.ItemCopyResult{}, server.ItemCopyResult{}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var findings []string
|
|
compareJSONShape(reflect.TypeOf(tc.mirror), reflect.TypeOf(tc.origin), tc.name, &findings, map[string]bool{})
|
|
for _, f := range findings {
|
|
t.Errorf("%s", f)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// compareJSONShape recurses over two struct types, comparing the JSON
|
|
// contract they express. mirror is the internal/cli copy; origin is
|
|
// internal/server's authority.
|
|
func compareJSONShape(mirror, origin reflect.Type, path string, findings *[]string, seen map[string]bool) {
|
|
mirror = deref(mirror)
|
|
origin = deref(origin)
|
|
|
|
if mirror == origin {
|
|
// Same type on both sides (e.g. *models.Item) — nothing to drift.
|
|
return
|
|
}
|
|
|
|
key := path + "|" + mirror.String() + "|" + origin.String()
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
|
|
if mirror.Kind() != origin.Kind() {
|
|
*findings = append(*findings, fmt.Sprintf("%s: kind %s (cli) != %s (server)", path, mirror.Kind(), origin.Kind()))
|
|
return
|
|
}
|
|
|
|
switch mirror.Kind() {
|
|
case reflect.Struct:
|
|
mf := jsonFields(mirror)
|
|
of := jsonFields(origin)
|
|
for _, name := range sortedUnion(mf, of) {
|
|
m, inMirror := mf[name]
|
|
o, inOrigin := of[name]
|
|
switch {
|
|
case !inMirror:
|
|
*findings = append(*findings, fmt.Sprintf("%s.%s: present on the server type, MISSING from the cli mirror", path, name))
|
|
case !inOrigin:
|
|
*findings = append(*findings, fmt.Sprintf("%s.%s: present on the cli mirror, MISSING from the server type", path, name))
|
|
default:
|
|
if m.omitempty != o.omitempty {
|
|
*findings = append(*findings, fmt.Sprintf("%s.%s: omitempty %v (cli) != %v (server)", path, name, m.omitempty, o.omitempty))
|
|
}
|
|
compareJSONShape(m.typ, o.typ, path+"."+name, findings, seen)
|
|
}
|
|
}
|
|
case reflect.Slice, reflect.Array, reflect.Map:
|
|
compareJSONShape(mirror.Elem(), origin.Elem(), path+"[]", findings, seen)
|
|
default:
|
|
if mirror.Kind() != origin.Kind() {
|
|
*findings = append(*findings, fmt.Sprintf("%s: %s (cli) != %s (server)", path, mirror, origin))
|
|
}
|
|
}
|
|
}
|
|
|
|
type jsonField struct {
|
|
typ reflect.Type
|
|
omitempty bool
|
|
}
|
|
|
|
func jsonFields(t reflect.Type) map[string]jsonField {
|
|
out := map[string]jsonField{}
|
|
for i := 0; i < t.NumField(); i++ {
|
|
f := t.Field(i)
|
|
if f.PkgPath != "" {
|
|
continue // unexported: not on the wire
|
|
}
|
|
tag := f.Tag.Get("json")
|
|
if tag == "-" {
|
|
continue
|
|
}
|
|
parts := strings.Split(tag, ",")
|
|
name := parts[0]
|
|
if name == "" {
|
|
name = f.Name
|
|
}
|
|
omit := false
|
|
for _, p := range parts[1:] {
|
|
if p == "omitempty" {
|
|
omit = true
|
|
}
|
|
}
|
|
out[name] = jsonField{typ: f.Type, omitempty: omit}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortedUnion(a, b map[string]jsonField) []string {
|
|
set := map[string]bool{}
|
|
for k := range a {
|
|
set[k] = true
|
|
}
|
|
for k := range b {
|
|
set[k] = true
|
|
}
|
|
names := make([]string, 0, len(set))
|
|
for k := range set {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func deref(t reflect.Type) reflect.Type {
|
|
for t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
return t
|
|
}
|