@pippit-dev/cli 1.0.17 → 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +41 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +241 -0
  6. package/cmd/canvas/canvas_test.go +182 -0
  7. package/cmd/get_credit_balance.go +37 -0
  8. package/cmd/get_credit_balance_test.go +73 -0
  9. package/cmd/root.go +31 -5
  10. package/cmd/root_test.go +69 -0
  11. package/cmd/short_drama_test.go +5 -7
  12. package/cmd/update/update.go +34 -5
  13. package/cmd/update/update_test.go +84 -0
  14. package/dist/checksums.txt +6 -0
  15. package/dist/xyq-canvas-command-runtime.cjs +16 -0
  16. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
  17. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
  18. package/internal/auth/auth_test.go +813 -0
  19. package/internal/auth/browser_darwin.go +14 -0
  20. package/internal/auth/browser_env.go +35 -0
  21. package/internal/auth/browser_linux.go +14 -0
  22. package/internal/auth/browser_windows.go +14 -0
  23. package/internal/auth/identity.go +89 -0
  24. package/internal/auth/loopback.go +324 -0
  25. package/internal/auth/manager.go +338 -144
  26. package/internal/auth/store.go +303 -0
  27. package/internal/auth/store_file_unix.go +176 -0
  28. package/internal/auth/store_file_windows.go +11 -0
  29. package/internal/auth/types.go +72 -0
  30. package/internal/canvas/allocate.go +70 -0
  31. package/internal/canvas/apply.go +279 -0
  32. package/internal/canvas/canvas_test.go +559 -0
  33. package/internal/canvas/create.go +380 -0
  34. package/internal/canvas/get.go +156 -0
  35. package/internal/canvas/types.go +68 -0
  36. package/internal/canvas/upload.go +250 -0
  37. package/internal/common/access_key.go +42 -6
  38. package/internal/common/access_key_test.go +113 -0
  39. package/internal/common/client.go +119 -21
  40. package/internal/common/client_test.go +213 -0
  41. package/internal/common/get_credit_balance.go +61 -0
  42. package/internal/common/get_credit_balance_test.go +75 -0
  43. package/internal/common/runner.go +15 -0
  44. package/internal/config/config.go +11 -28
  45. package/internal/config/config_test.go +3 -18
  46. package/package.json +9 -2
  47. package/scripts/canvas-command.js +881 -0
  48. package/scripts/run.js +21 -4
  49. package/skills/short-drama/SKILL.md +4 -4
  50. package/skills/xyq-nest-skill/SKILL.md +11 -1
@@ -0,0 +1,559 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/Pippit-dev/pippit-cli/internal/common"
15
+ )
16
+
17
+ type fakeClient struct {
18
+ send func(context.Context, string, any, any) error
19
+ multipart func(context.Context, string, map[string]string, common.MultipartFile, any) error
20
+ }
21
+
22
+ func (f *fakeClient) SendRequest(ctx context.Context, path string, body any, out any) error {
23
+ if f.send == nil {
24
+ return fmt.Errorf("unexpected request to %s", path)
25
+ }
26
+ return f.send(ctx, path, body, out)
27
+ }
28
+
29
+ func (f *fakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
30
+ return f.SendRequest(ctx, path, body, out)
31
+ }
32
+
33
+ func (f *fakeClient) SendMultipartRequest(
34
+ ctx context.Context,
35
+ path string,
36
+ fields map[string]string,
37
+ file common.MultipartFile,
38
+ out any,
39
+ ) error {
40
+ if f.multipart == nil {
41
+ return fmt.Errorf("unexpected multipart request to %s", path)
42
+ }
43
+ return f.multipart(ctx, path, fields, file, out)
44
+ }
45
+
46
+ func runnerWithClient(client common.Client) *common.Runner {
47
+ return &common.Runner{Client: client}
48
+ }
49
+
50
+ func decodeInto(out any, payload string) error {
51
+ return json.Unmarshal([]byte(payload), out)
52
+ }
53
+
54
+ func requestJSON(t *testing.T, body any) map[string]any {
55
+ t.Helper()
56
+ payload, err := json.Marshal(body)
57
+ if err != nil {
58
+ t.Fatalf("json.Marshal() error = %v", err)
59
+ }
60
+ var result map[string]any
61
+ if err := json.Unmarshal(payload, &result); err != nil {
62
+ t.Fatalf("json.Unmarshal() error = %v", err)
63
+ }
64
+ return result
65
+ }
66
+
67
+ func TestCreateReturnsRecoverableOperationIDs(t *testing.T) {
68
+ client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
69
+ if path != CreatePath {
70
+ t.Fatalf("path = %q, want %q", path, CreatePath)
71
+ }
72
+ request := requestJSON(t, body)
73
+ if request["surface"] != SurfaceNovel || request["request_id"] != "request-1" {
74
+ t.Fatalf("request = %#v, want novel request", request)
75
+ }
76
+ if baseValue, ok := request["Base"].(map[string]any); !ok || len(baseValue) != 0 {
77
+ t.Fatalf("Base = %#v, want empty object", request["Base"])
78
+ }
79
+ if _, exists := request["team_id"]; exists {
80
+ t.Fatalf("request = %#v, must not contain team context", request)
81
+ }
82
+ return decodeInto(out, `{"ret":"0","log_id":"log-create","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
83
+ }}
84
+
85
+ result, err := Create(context.Background(), CreateOptions{Title: "A", RequestID: "request-1"}, runnerWithClient(client))
86
+ if err != nil {
87
+ t.Fatalf("Create() error = %v", err)
88
+ }
89
+ if result.RequestID != "request-1" || result.State != StateCreating || result.ProjectID != "100" || result.CanvasAssetID != "200" {
90
+ t.Fatalf("Create() = %#v, want recoverable creating IDs", result)
91
+ }
92
+ }
93
+
94
+ func TestCreateWaitWithInlineOverviewStillRequiresQueryableRoot(t *testing.T) {
95
+ queryCalls := 0
96
+ client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
97
+ switch path {
98
+ case CreatePath:
99
+ return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","overview_pippit_asset_id":"300","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
100
+ case QueryPath:
101
+ queryCalls++
102
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`)
103
+ default:
104
+ return fmt.Errorf("unexpected path %s", path)
105
+ }
106
+ }}
107
+ result, err := Create(context.Background(), CreateOptions{RequestID: "request-1", Wait: true}, runnerWithClient(client))
108
+ if err != nil {
109
+ t.Fatalf("Create() error = %v", err)
110
+ }
111
+ if queryCalls != 1 || result.State != StateCreating || !strings.Contains(result.Warning, "root asset is not queryable yet") {
112
+ t.Fatalf("Create() = %#v, query calls=%d, want visibility-gated ready", result, queryCalls)
113
+ }
114
+ }
115
+
116
+ func TestCreateTransportFailureExplainsAmbiguousOutcome(t *testing.T) {
117
+ client := &fakeClient{send: func(context.Context, string, any, any) error {
118
+ return errors.New("timeout")
119
+ }}
120
+ _, err := Create(context.Background(), CreateOptions{RequestID: "request-1"}, runnerWithClient(client))
121
+ if err == nil || !strings.Contains(err.Error(), "outcome may be ambiguous, do not retry blindly") {
122
+ t.Fatalf("Create() error = %v, want ambiguous outcome guidance", err)
123
+ }
124
+ }
125
+
126
+ func TestCreateWaitsForMachineArtifactWithoutV2(t *testing.T) {
127
+ getThreadCalls := 0
128
+ client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
129
+ switch path {
130
+ case CreatePath:
131
+ return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
132
+ case "/api/biz/v1/skill/get_thread":
133
+ getThreadCalls++
134
+ request := requestJSON(t, body)
135
+ if _, exists := request["version"]; exists {
136
+ t.Fatalf("get_thread request = %#v, must not request v2 readable text", request)
137
+ }
138
+ return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}] }]}}}`)
139
+ case QueryPath:
140
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":"200"}]}}`)
141
+ default:
142
+ return fmt.Errorf("unexpected path %s", path)
143
+ }
144
+ }}
145
+
146
+ result, err := Create(context.Background(), CreateOptions{
147
+ RequestID: "request-1",
148
+ Wait: true,
149
+ PollInterval: time.Millisecond,
150
+ WaitTimeout: time.Second,
151
+ }, runnerWithClient(client))
152
+ if err != nil {
153
+ t.Fatalf("Create() error = %v", err)
154
+ }
155
+ if getThreadCalls != 1 || result.State != StateReady || result.OverviewPippitAssetID != "300" {
156
+ t.Fatalf("Create() = %#v, calls=%d, want ready artifact", result, getThreadCalls)
157
+ }
158
+ if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") {
159
+ t.Fatalf("WebURL = %q, want canonical overview URL", result.WebURL)
160
+ }
161
+ }
162
+
163
+ func TestCreateKeepsCreatingWhenRootAssetIsNotQueryable(t *testing.T) {
164
+ client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
165
+ switch path {
166
+ case CreatePath:
167
+ return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
168
+ case "/api/biz/v1/skill/get_thread":
169
+ return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}]}]}}}`)
170
+ case QueryPath:
171
+ return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[]}}`)
172
+ default:
173
+ return fmt.Errorf("unexpected path %s", path)
174
+ }
175
+ }}
176
+ result, err := Create(context.Background(), CreateOptions{
177
+ RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second,
178
+ }, runnerWithClient(client))
179
+ if err != nil {
180
+ t.Fatalf("Create() error = %v, want accepted processing result", err)
181
+ }
182
+ if result.State != StateCreating || result.ProjectID != "100" || result.OverviewPippitAssetID != "300" || !strings.Contains(result.Warning, "root asset is not queryable yet") {
183
+ t.Fatalf("Create() = %#v, want creating state with query warning", result)
184
+ }
185
+ if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") {
186
+ t.Fatalf("WebURL = %q, want preserved canonical overview URL", result.WebURL)
187
+ }
188
+ }
189
+
190
+ func TestCreateWaitTimeoutPreservesAcceptedIDs(t *testing.T) {
191
+ client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
192
+ switch path {
193
+ case CreatePath:
194
+ return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
195
+ case "/api/biz/v1/skill/get_thread":
196
+ return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`)
197
+ default:
198
+ return fmt.Errorf("unexpected path %s", path)
199
+ }
200
+ }}
201
+
202
+ result, err := Create(context.Background(), CreateOptions{
203
+ RequestID: "request-1",
204
+ Wait: true,
205
+ PollInterval: 50 * time.Millisecond,
206
+ WaitTimeout: time.Millisecond,
207
+ }, runnerWithClient(client))
208
+ if err != nil {
209
+ t.Fatalf("Create() error = %v, want accepted result with warning", err)
210
+ }
211
+ if result == nil || result.ProjectID != "100" || result.ThreadID != "thread-1" || result.RunID != "run-1" || result.CanvasAssetID != "200" {
212
+ t.Fatalf("Create() = %#v, want all accepted IDs", result)
213
+ }
214
+ if result.State != StateCreating || !strings.Contains(result.Warning, "was not ready") {
215
+ t.Fatalf("Create() = %#v, want creating state with timeout warning", result)
216
+ }
217
+ }
218
+
219
+ func TestCreateTerminalFailureReturnsAcceptedIDsAndError(t *testing.T) {
220
+ client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
221
+ if path == CreatePath {
222
+ return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
223
+ }
224
+ return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":4}]}}}`)
225
+ }}
226
+ result, err := Create(context.Background(), CreateOptions{
227
+ RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second,
228
+ }, runnerWithClient(client))
229
+ if result == nil || result.ProjectID != "100" || result.State != "failed" {
230
+ t.Fatalf("Create() result = %#v, want failed result with accepted IDs", result)
231
+ }
232
+ if err == nil || !strings.Contains(err.Error(), "accepted canvas IDs") || !strings.Contains(err.Error(), "do not create again blindly") {
233
+ t.Fatalf("Create() error = %v, want accepted ID guidance", err)
234
+ }
235
+ }
236
+
237
+ func TestGetRequiresEveryRequestedStringID(t *testing.T) {
238
+ client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
239
+ if path != QueryPath {
240
+ t.Fatalf("path = %q, want query", path)
241
+ }
242
+ return decodeInto(out, `{"ret":"0","log_id":"log-query","data":{"Assets":[{"PippitAssetID":"2"},{"PippitAssetID":"1"}]}}`)
243
+ }}
244
+ result, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1", "2"}}, runnerWithClient(client))
245
+ if err != nil {
246
+ t.Fatalf("Get() error = %v", err)
247
+ }
248
+ firstID, _ := assetIDFromRaw(result.Assets[0])
249
+ if firstID != "1" || result.LogID != "log-query" {
250
+ t.Fatalf("Get() = %#v, want request order and log ID", result)
251
+ }
252
+
253
+ missingClient := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
254
+ return decodeInto(out, `{"ret":"0","log_id":"missing-log","data":{"Assets":[]}}`)
255
+ }}
256
+ _, err = Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(missingClient))
257
+ if err == nil || !strings.Contains(err.Error(), "did not return requested assets: 1") || !strings.Contains(err.Error(), "missing-log") {
258
+ t.Fatalf("Get() error = %v, want strict missing asset error", err)
259
+ }
260
+ }
261
+
262
+ func TestGetRejectsNumericAssetIDInResponse(t *testing.T) {
263
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
264
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":123}]}}`)
265
+ }}
266
+ _, err := Get(context.Background(), GetOptions{AssetIDs: []string{"123"}}, runnerWithClient(client))
267
+ if err == nil || !strings.Contains(err.Error(), "must be a JSON string") {
268
+ t.Fatalf("Get() error = %v, want string ID validation", err)
269
+ }
270
+ }
271
+
272
+ func TestAllocateRequiresExactUniqueStringIDs(t *testing.T) {
273
+ client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
274
+ if path != AllocatePath {
275
+ t.Fatalf("path = %q, want allocate", path)
276
+ }
277
+ request := requestJSON(t, body)
278
+ if request["count"] != float64(2) {
279
+ t.Fatalf("count = %#v, want 2", request["count"])
280
+ }
281
+ return decodeInto(out, `{"ret":"0","data":{"ids":["10","11"]}}`)
282
+ }}
283
+ result, err := Allocate(context.Background(), 2, runnerWithClient(client))
284
+ if err != nil || strings.Join(result.AssetIDs, ",") != "10,11" {
285
+ t.Fatalf("Allocate() = (%#v, %v), want two IDs", result, err)
286
+ }
287
+
288
+ duplicate := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
289
+ return decodeInto(out, `{"ret":"0","data":{"ids":["10","10"]}}`)
290
+ }}
291
+ _, err = Allocate(context.Background(), 2, runnerWithClient(duplicate))
292
+ if err == nil || !strings.Contains(err.Error(), "duplicate id") {
293
+ t.Fatalf("Allocate() error = %v, want duplicate rejection", err)
294
+ }
295
+ }
296
+
297
+ func TestApplyRequiresAcknowledgementAndEveryAssetVersion(t *testing.T) {
298
+ version := int64(0)
299
+ request := ApplyRequest{
300
+ BatchID: "batch-1",
301
+ ClientID: "client-1",
302
+ Transactions: []PatchTransaction{{
303
+ TransactionID: "tx-1",
304
+ Patches: []PatchEntry{{AssetID: "asset-1", BaseAssetVersion: &version, Op: "add", Path: "", Value: json.RawMessage(`{"nodes":[]}`)}},
305
+ }},
306
+ }
307
+ client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
308
+ if path != ApplyPath+"?project_id=123" {
309
+ t.Fatalf("path = %q, want project query", path)
310
+ }
311
+ payload := requestJSON(t, body)
312
+ if baseValue, ok := payload["Base"].(map[string]any); !ok || len(baseValue) != 0 {
313
+ t.Fatalf("Base = %#v, want empty object", payload["Base"])
314
+ }
315
+ return decodeInto(out, `{"ret":"0","log_id":"log-apply","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{"asset-1":1}}]}}`)
316
+ }}
317
+ result, err := Apply(context.Background(), ApplyOptions{ProjectID: "123", Request: request}, runnerWithClient(client))
318
+ if err != nil || result.Results[0].AssetVersions["asset-1"] != 1 {
319
+ t.Fatalf("Apply() = (%#v, %v), want ack", result, err)
320
+ }
321
+
322
+ rejected := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
323
+ return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"blocked","blocked_by":"tx-0"}]}}`)
324
+ }}
325
+ _, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(rejected))
326
+ if err == nil || !strings.Contains(err.Error(), "was not acknowledged") {
327
+ t.Fatalf("Apply() error = %v, want status rejection", err)
328
+ }
329
+
330
+ missingVersion := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
331
+ return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{}}]}}`)
332
+ }}
333
+ _, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(missingVersion))
334
+ if err == nil || !strings.Contains(err.Error(), "omitted version") {
335
+ t.Fatalf("Apply() error = %v, want missing version rejection", err)
336
+ }
337
+ }
338
+
339
+ func TestApplyAllowsExplicitTransportOutcomesWithoutHidingServerResultFields(t *testing.T) {
340
+ request := ApplyRequest{
341
+ BatchID: "batch-1", ClientID: "client-1",
342
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
343
+ AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`),
344
+ }}}},
345
+ }
346
+ for _, status := range []string{"reject", "blocked", "skipped"} {
347
+ t.Run(status, func(t *testing.T) {
348
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
349
+ response := fmt.Sprintf(
350
+ `{"ret":"0","log_id":"transport-log","data":{"results":[{"transaction_id":"tx-1","status":%q,"blocked_by":"tx-0","message":"server detail","current_asset_versions":{"asset-1":7},"server_detail":{"reason":"conflict"}}]}}`,
351
+ status,
352
+ )
353
+ return decodeInto(out, response)
354
+ }}
355
+ result, err := Apply(context.Background(), ApplyOptions{
356
+ Request: request,
357
+ AllowNonAcknowledgedResults: true,
358
+ }, runnerWithClient(client))
359
+ if err != nil || result.Results[0].Status != status {
360
+ t.Fatalf("Apply() = (%#v, %v), want structured %s result", result, err, status)
361
+ }
362
+ payload, err := json.Marshal(result)
363
+ if err != nil {
364
+ t.Fatalf("Marshal() error = %v", err)
365
+ }
366
+ var output map[string]any
367
+ if err := json.Unmarshal(payload, &output); err != nil {
368
+ t.Fatalf("Unmarshal() error = %v", err)
369
+ }
370
+ entry := output["results"].([]any)[0].(map[string]any)
371
+ if entry["message"] != "server detail" || entry["server_detail"] == nil || entry["current_asset_versions"] == nil {
372
+ t.Fatalf("result = %#v, want complete server transaction result", entry)
373
+ }
374
+ })
375
+ }
376
+ }
377
+
378
+ func TestApplyTransportResultAllowsRootAssetCreateAckWithoutVersion(t *testing.T) {
379
+ request := ApplyRequest{
380
+ BatchID: "batch-1", ClientID: "client-1",
381
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
382
+ AssetID: "asset-new", Op: "add", Path: "", Value: json.RawMessage(`{"nodes":[]}`),
383
+ }}}},
384
+ }
385
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
386
+ return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","server_detail":{"created":true}}]}}`)
387
+ }}
388
+ result, err := Apply(context.Background(), ApplyOptions{
389
+ Request: request,
390
+ AllowNonAcknowledgedResults: true,
391
+ }, runnerWithClient(client))
392
+ if err != nil || result.Results[0].Status != "ack" {
393
+ t.Fatalf("Apply() = (%#v, %v), want transport ACK", result, err)
394
+ }
395
+ payload, err := json.Marshal(result)
396
+ if err != nil {
397
+ t.Fatalf("Marshal() error = %v", err)
398
+ }
399
+ var output map[string]any
400
+ if err := json.Unmarshal(payload, &output); err != nil {
401
+ t.Fatalf("Unmarshal() error = %v", err)
402
+ }
403
+ entry := output["results"].([]any)[0].(map[string]any)
404
+ if entry["asset_versions"] != nil || entry["server_detail"] == nil {
405
+ t.Fatalf("result = %#v, want unchanged ACK for SDK decoder", entry)
406
+ }
407
+ }
408
+
409
+ func TestApplyTransportResultStillRequiresVersionsForNonCreatePatches(t *testing.T) {
410
+ for _, patch := range []PatchEntry{
411
+ {AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`)},
412
+ {AssetID: "asset-1", Op: "add", Path: "/nodes/0", Value: json.RawMessage(`{}`)},
413
+ } {
414
+ t.Run(patch.Op+patch.Path, func(t *testing.T) {
415
+ request := ApplyRequest{
416
+ BatchID: "batch-1", ClientID: "client-1",
417
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{patch}}},
418
+ }
419
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
420
+ return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack"}]}}`)
421
+ }}
422
+ _, err := Apply(context.Background(), ApplyOptions{
423
+ Request: request,
424
+ AllowNonAcknowledgedResults: true,
425
+ }, runnerWithClient(client))
426
+ if err == nil || !strings.Contains(err.Error(), "omitted version") {
427
+ t.Fatalf("Apply() error = %v, want missing version rejection", err)
428
+ }
429
+ })
430
+ }
431
+ }
432
+
433
+ func TestApplyTransportResultModeStillRejectsInvalidStatus(t *testing.T) {
434
+ request := ApplyRequest{
435
+ BatchID: "batch-1", ClientID: "client-1",
436
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
437
+ AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`),
438
+ }}}},
439
+ }
440
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
441
+ return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"pending"}]}}`)
442
+ }}
443
+ _, err := Apply(context.Background(), ApplyOptions{
444
+ Request: request,
445
+ AllowNonAcknowledgedResults: true,
446
+ }, runnerWithClient(client))
447
+ if err == nil || !strings.Contains(err.Error(), "invalid status") {
448
+ t.Fatalf("Apply() error = %v, want invalid response rejection", err)
449
+ }
450
+ }
451
+
452
+ func TestApplyBetaRejectsMultipleTransactionsBeforeRequest(t *testing.T) {
453
+ requestCalls := 0
454
+ client := &fakeClient{send: func(context.Context, string, any, any) error {
455
+ requestCalls++
456
+ return nil
457
+ }}
458
+ request := ApplyRequest{
459
+ BatchID: "batch-1", ClientID: "client-1",
460
+ Transactions: []PatchTransaction{
461
+ {TransactionID: "tx-1", Patches: []PatchEntry{{AssetID: "asset-1", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
462
+ {TransactionID: "tx-2", Patches: []PatchEntry{{AssetID: "asset-2", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
463
+ },
464
+ }
465
+ _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
466
+ if err == nil || !strings.Contains(err.Error(), "exactly one transaction") {
467
+ t.Fatalf("Apply() error = %v, want single-transaction beta guard", err)
468
+ }
469
+ if requestCalls != 0 {
470
+ t.Fatalf("request calls = %d, want validation before side effects", requestCalls)
471
+ }
472
+ }
473
+
474
+ func TestApplyTransportFailureWarnsAgainstBlindReplay(t *testing.T) {
475
+ request := ApplyRequest{
476
+ BatchID: "batch-1", ClientID: "client-1",
477
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
478
+ AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`),
479
+ }}}},
480
+ }
481
+ client := &fakeClient{send: func(context.Context, string, any, any) error { return errors.New("timeout") }}
482
+ _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
483
+ if err == nil || !strings.Contains(err.Error(), "do not replay blindly") {
484
+ t.Fatalf("Apply() error = %v, want replay warning", err)
485
+ }
486
+ }
487
+
488
+ func TestUploadWaitsForQueryableAssetWithoutRequiringCover(t *testing.T) {
489
+ path := filepath.Join(t.TempDir(), "clip.mp4")
490
+ if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
491
+ t.Fatalf("WriteFile() error = %v", err)
492
+ }
493
+ client := &fakeClient{
494
+ multipart: func(_ context.Context, gotPath string, fields map[string]string, file common.MultipartFile, out any) error {
495
+ if gotPath != UploadPath || len(fields) != 0 {
496
+ t.Fatalf("multipart = (%q, %#v), want upload without auth fields", gotPath, fields)
497
+ }
498
+ if file.FieldName != "file" || file.ContentType != "video/mp4" {
499
+ t.Fatalf("file = %#v, want video multipart", file)
500
+ }
501
+ return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
502
+ },
503
+ send: func(_ context.Context, path string, _ any, out any) error {
504
+ if path != QueryPath {
505
+ t.Fatalf("path = %q, want query", path)
506
+ }
507
+ return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[{"PippitAssetID":"asset-1","SourceID":"workspace-1","Video":{"DownloadUrl":"https://example.test/clip.mp4","VID":"vid-1"}}]}}`)
508
+ },
509
+ }
510
+ result, err := Upload(context.Background(), UploadOptions{
511
+ Path: path, PollInterval: time.Millisecond, WaitTimeout: time.Second,
512
+ }, runnerWithClient(client))
513
+ if err != nil {
514
+ t.Fatalf("Upload() error = %v", err)
515
+ }
516
+ if result.State != StateReady || result.AssetID != "workspace-1" || result.PippitAssetID != "asset-1" || result.PollAttempts != 1 {
517
+ t.Fatalf("Upload() = %#v, want ready identifiers", result)
518
+ }
519
+ if result.Locator.DownloadURL == "" || result.Locator.CoverURL != "" {
520
+ t.Fatalf("Locator = %#v, want playable locator without cover requirement", result.Locator)
521
+ }
522
+ }
523
+
524
+ func TestUploadWaitTimeoutPreservesDurableAssetID(t *testing.T) {
525
+ path := filepath.Join(t.TempDir(), "clip.mp4")
526
+ if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
527
+ t.Fatalf("WriteFile() error = %v", err)
528
+ }
529
+ client := &fakeClient{
530
+ multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error {
531
+ return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
532
+ },
533
+ send: func(_ context.Context, _ string, _ any, out any) error {
534
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`)
535
+ },
536
+ }
537
+ result, err := Upload(context.Background(), UploadOptions{
538
+ Path: path, PollInterval: 50 * time.Millisecond, WaitTimeout: time.Millisecond,
539
+ }, runnerWithClient(client))
540
+ if err != nil {
541
+ t.Fatalf("Upload() error = %v, want accepted processing result", err)
542
+ }
543
+ if result.State != StateProcessing || result.PippitAssetID != "asset-1" || result.Locator.PippitAssetID != "asset-1" {
544
+ t.Fatalf("Upload() = %#v, want durable ID after wait timeout", result)
545
+ }
546
+ if !strings.Contains(result.Warning, "was not queryable") {
547
+ t.Fatalf("warning = %q, want visibility warning", result.Warning)
548
+ }
549
+ }
550
+
551
+ func TestEnvelopeRetIsStrict(t *testing.T) {
552
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
553
+ return decodeInto(out, `{"errmsg":"missing ret","log_id":"strict-log","data":{"Assets":[]}}`)
554
+ }}
555
+ _, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(client))
556
+ if err == nil || !strings.Contains(err.Error(), `ret=""`) || !strings.Contains(err.Error(), "strict-log") {
557
+ t.Fatalf("Get() error = %v, want strict ret validation", err)
558
+ }
559
+ }