Files
pad/internal/cli/client_items_copy_mirror_test.go
xarmian 1e48a7a1dd feat(cli): add pad item copy for cross-workspace copy and move (TASK-2366)
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.
2026-07-31 02:23:24 +00:00

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
}