@pippit-dev/cli 1.0.17 → 1.0.18

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 (40) hide show
  1. package/README.md +23 -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 +235 -0
  6. package/cmd/canvas/canvas_test.go +126 -0
  7. package/cmd/root.go +29 -4
  8. package/cmd/root_test.go +60 -0
  9. package/cmd/short_drama_test.go +4 -7
  10. package/cmd/update/update.go +34 -5
  11. package/cmd/update/update_test.go +84 -0
  12. package/internal/auth/auth_test.go +813 -0
  13. package/internal/auth/browser_darwin.go +14 -0
  14. package/internal/auth/browser_env.go +35 -0
  15. package/internal/auth/browser_linux.go +14 -0
  16. package/internal/auth/browser_windows.go +14 -0
  17. package/internal/auth/identity.go +89 -0
  18. package/internal/auth/loopback.go +324 -0
  19. package/internal/auth/manager.go +338 -144
  20. package/internal/auth/store.go +303 -0
  21. package/internal/auth/store_file_unix.go +176 -0
  22. package/internal/auth/store_file_windows.go +11 -0
  23. package/internal/auth/types.go +72 -0
  24. package/internal/canvas/allocate.go +70 -0
  25. package/internal/canvas/apply.go +203 -0
  26. package/internal/canvas/canvas_test.go +446 -0
  27. package/internal/canvas/create.go +380 -0
  28. package/internal/canvas/get.go +156 -0
  29. package/internal/canvas/types.go +68 -0
  30. package/internal/canvas/upload.go +250 -0
  31. package/internal/common/access_key.go +42 -6
  32. package/internal/common/access_key_test.go +113 -0
  33. package/internal/common/client.go +119 -21
  34. package/internal/common/client_test.go +213 -0
  35. package/internal/common/runner.go +15 -0
  36. package/internal/config/config.go +0 -20
  37. package/internal/config/config_test.go +0 -18
  38. package/package.json +1 -1
  39. package/skills/short-drama/SKILL.md +4 -4
  40. package/skills/xyq-nest-skill/SKILL.md +11 -1
@@ -0,0 +1,203 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "regexp"
8
+ "strings"
9
+
10
+ "github.com/Pippit-dev/pippit-cli/internal/common"
11
+ )
12
+
13
+ var decimalIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`)
14
+
15
+ type ApplyOptions struct {
16
+ ProjectID string
17
+ Request ApplyRequest
18
+ }
19
+
20
+ type ApplyRequest struct {
21
+ BatchID string `json:"batch_id"`
22
+ ClientID string `json:"client_id"`
23
+ RootPippitAssetID string `json:"root_pippit_asset_id,omitempty"`
24
+ Transactions []PatchTransaction `json:"transactions"`
25
+ Base map[string]any `json:"Base"`
26
+ }
27
+
28
+ type PatchTransaction struct {
29
+ TransactionID string `json:"transaction_id"`
30
+ Intent string `json:"intent,omitempty"`
31
+ MergeKey string `json:"merge_key,omitempty"`
32
+ Attempt *int32 `json:"attempt,omitempty"`
33
+ EnqueuedAt *int64 `json:"enqueued_at,omitempty"`
34
+ Patches []PatchEntry `json:"patches"`
35
+ }
36
+
37
+ type PatchEntry struct {
38
+ AssetID string `json:"asset_id"`
39
+ BaseAssetVersion *int64 `json:"base_asset_version,omitempty"`
40
+ AssetSourceType *int32 `json:"asset_source_type,omitempty"`
41
+ Op string `json:"op"`
42
+ Path string `json:"path"`
43
+ Value json.RawMessage `json:"value,omitempty"`
44
+ }
45
+
46
+ type ApplyResult struct {
47
+ BatchID string `json:"batch_id"`
48
+ Results []PatchTransactionResult `json:"results"`
49
+ LogID string `json:"log_id,omitempty"`
50
+ }
51
+
52
+ type PatchTransactionResult struct {
53
+ TransactionID string `json:"transaction_id"`
54
+ Status string `json:"status"`
55
+ AssetVersions map[string]int64 `json:"asset_versions,omitempty"`
56
+ BlockedByTransaction string `json:"blocked_by,omitempty"`
57
+ Error string `json:"error,omitempty"`
58
+ }
59
+
60
+ type applyData struct {
61
+ Results []PatchTransactionResult `json:"results"`
62
+ }
63
+
64
+ func Apply(ctx context.Context, opts ApplyOptions, runner *common.Runner) (*ApplyResult, error) {
65
+ client, err := runnerClient(runner, "canvas apply")
66
+ if err != nil {
67
+ return nil, err
68
+ }
69
+ projectID := strings.TrimSpace(opts.ProjectID)
70
+ if projectID != "" && !decimalIDPattern.MatchString(projectID) {
71
+ return nil, fmt.Errorf("canvas apply project_id must be a positive decimal JSON string")
72
+ }
73
+ request := opts.Request
74
+ request.Base = base()
75
+ if err := validateApplyRequest(&request); err != nil {
76
+ return nil, err
77
+ }
78
+
79
+ var envelope responseEnvelope
80
+ if err := client.SendRequest(ctx, pathWithProjectID(ApplyPath, projectID), request, &envelope); err != nil {
81
+ return nil, fmt.Errorf("canvas apply request failed; outcome may be ambiguous, do not replay blindly: %w", err)
82
+ }
83
+ if err := envelope.validate("canvas apply"); err != nil {
84
+ return nil, err
85
+ }
86
+ var data applyData
87
+ if err := json.Unmarshal(envelope.Data, &data); err != nil {
88
+ return nil, common.NewLogIDError(
89
+ fmt.Sprintf("canvas apply returned invalid data: %v; query affected assets before retrying because outcome cannot be confirmed", err),
90
+ envelope.LogID,
91
+ )
92
+ }
93
+ ordered, err := validateApplyResults(request.Transactions, data.Results)
94
+ if err != nil {
95
+ return nil, common.NewLogIDError(
96
+ fmt.Sprintf("%s; query affected assets before retrying because outcome cannot be confirmed", err),
97
+ envelope.LogID,
98
+ )
99
+ }
100
+ return &ApplyResult{
101
+ BatchID: request.BatchID,
102
+ Results: ordered,
103
+ LogID: strings.TrimSpace(envelope.LogID),
104
+ }, nil
105
+ }
106
+
107
+ func validateApplyRequest(request *ApplyRequest) error {
108
+ if request == nil {
109
+ return fmt.Errorf("canvas apply request is required")
110
+ }
111
+ request.BatchID = strings.TrimSpace(request.BatchID)
112
+ request.ClientID = strings.TrimSpace(request.ClientID)
113
+ request.RootPippitAssetID = strings.TrimSpace(request.RootPippitAssetID)
114
+ if request.BatchID == "" {
115
+ return fmt.Errorf("canvas apply batch_id is required")
116
+ }
117
+ if request.ClientID == "" {
118
+ return fmt.Errorf("canvas apply client_id is required")
119
+ }
120
+ if len(request.Transactions) != 1 {
121
+ return fmt.Errorf("canvas apply requires exactly one transaction in this beta; put related patches in that transaction")
122
+ }
123
+ transactionIDs := make(map[string]struct{}, len(request.Transactions))
124
+ for txIndex := range request.Transactions {
125
+ tx := &request.Transactions[txIndex]
126
+ tx.TransactionID = strings.TrimSpace(tx.TransactionID)
127
+ if tx.TransactionID == "" {
128
+ return fmt.Errorf("canvas apply transactions[%d].transaction_id is required", txIndex)
129
+ }
130
+ if _, duplicate := transactionIDs[tx.TransactionID]; duplicate {
131
+ return fmt.Errorf("canvas apply transaction_id %q is duplicated", tx.TransactionID)
132
+ }
133
+ transactionIDs[tx.TransactionID] = struct{}{}
134
+ if len(tx.Patches) == 0 {
135
+ return fmt.Errorf("canvas apply transaction %q has no patches", tx.TransactionID)
136
+ }
137
+ for patchIndex := range tx.Patches {
138
+ patch := &tx.Patches[patchIndex]
139
+ patch.AssetID = strings.TrimSpace(patch.AssetID)
140
+ patch.Op = strings.ToLower(strings.TrimSpace(patch.Op))
141
+ if patch.AssetID == "" {
142
+ return fmt.Errorf("canvas apply transaction %q patch[%d].asset_id is required", tx.TransactionID, patchIndex)
143
+ }
144
+ switch patch.Op {
145
+ case "add", "replace":
146
+ if len(patch.Value) == 0 || !json.Valid(patch.Value) {
147
+ return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex)
148
+ }
149
+ case "remove":
150
+ if len(patch.Value) != 0 && !json.Valid(patch.Value) {
151
+ return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex)
152
+ }
153
+ default:
154
+ return fmt.Errorf("canvas apply transaction %q patch[%d].op must be add, replace, or remove", tx.TransactionID, patchIndex)
155
+ }
156
+ if patch.BaseAssetVersion != nil && *patch.BaseAssetVersion < 0 {
157
+ return fmt.Errorf("canvas apply transaction %q patch[%d].base_asset_version must not be negative", tx.TransactionID, patchIndex)
158
+ }
159
+ }
160
+ }
161
+ return nil
162
+ }
163
+
164
+ func validateApplyResults(expected []PatchTransaction, actual []PatchTransactionResult) ([]PatchTransactionResult, error) {
165
+ byID := make(map[string]PatchTransactionResult, len(actual))
166
+ for _, result := range actual {
167
+ result.TransactionID = strings.TrimSpace(result.TransactionID)
168
+ if result.TransactionID == "" {
169
+ return nil, fmt.Errorf("canvas apply returned a result without transaction_id")
170
+ }
171
+ if _, duplicate := byID[result.TransactionID]; duplicate {
172
+ return nil, fmt.Errorf("canvas apply returned duplicate result for transaction %q", result.TransactionID)
173
+ }
174
+ byID[result.TransactionID] = result
175
+ }
176
+ if len(byID) != len(expected) {
177
+ return nil, fmt.Errorf("canvas apply returned %d transaction results, want %d", len(byID), len(expected))
178
+ }
179
+ ordered := make([]PatchTransactionResult, 0, len(expected))
180
+ for _, transaction := range expected {
181
+ result, ok := byID[transaction.TransactionID]
182
+ if !ok {
183
+ return nil, fmt.Errorf("canvas apply omitted transaction result %q", transaction.TransactionID)
184
+ }
185
+ if strings.ToLower(strings.TrimSpace(result.Status)) != "ack" {
186
+ return nil, fmt.Errorf(
187
+ "canvas apply transaction %q was not acknowledged: status=%q blocked_by=%q error=%q",
188
+ transaction.TransactionID, result.Status, result.BlockedByTransaction, result.Error,
189
+ )
190
+ }
191
+ for _, patch := range transaction.Patches {
192
+ version, ok := result.AssetVersions[patch.AssetID]
193
+ if !ok {
194
+ return nil, fmt.Errorf("canvas apply transaction %q omitted version for asset %q", transaction.TransactionID, patch.AssetID)
195
+ }
196
+ if version < 0 {
197
+ return nil, fmt.Errorf("canvas apply transaction %q returned negative version for asset %q", transaction.TransactionID, patch.AssetID)
198
+ }
199
+ }
200
+ ordered = append(ordered, result)
201
+ }
202
+ return ordered, nil
203
+ }
@@ -0,0 +1,446 @@
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 TestApplyBetaRejectsMultipleTransactionsBeforeRequest(t *testing.T) {
340
+ requestCalls := 0
341
+ client := &fakeClient{send: func(context.Context, string, any, any) error {
342
+ requestCalls++
343
+ return nil
344
+ }}
345
+ request := ApplyRequest{
346
+ BatchID: "batch-1", ClientID: "client-1",
347
+ Transactions: []PatchTransaction{
348
+ {TransactionID: "tx-1", Patches: []PatchEntry{{AssetID: "asset-1", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
349
+ {TransactionID: "tx-2", Patches: []PatchEntry{{AssetID: "asset-2", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
350
+ },
351
+ }
352
+ _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
353
+ if err == nil || !strings.Contains(err.Error(), "exactly one transaction") {
354
+ t.Fatalf("Apply() error = %v, want single-transaction beta guard", err)
355
+ }
356
+ if requestCalls != 0 {
357
+ t.Fatalf("request calls = %d, want validation before side effects", requestCalls)
358
+ }
359
+ }
360
+
361
+ func TestApplyTransportFailureWarnsAgainstBlindReplay(t *testing.T) {
362
+ request := ApplyRequest{
363
+ BatchID: "batch-1", ClientID: "client-1",
364
+ Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
365
+ AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`),
366
+ }}}},
367
+ }
368
+ client := &fakeClient{send: func(context.Context, string, any, any) error { return errors.New("timeout") }}
369
+ _, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
370
+ if err == nil || !strings.Contains(err.Error(), "do not replay blindly") {
371
+ t.Fatalf("Apply() error = %v, want replay warning", err)
372
+ }
373
+ }
374
+
375
+ func TestUploadWaitsForQueryableAssetWithoutRequiringCover(t *testing.T) {
376
+ path := filepath.Join(t.TempDir(), "clip.mp4")
377
+ if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
378
+ t.Fatalf("WriteFile() error = %v", err)
379
+ }
380
+ client := &fakeClient{
381
+ multipart: func(_ context.Context, gotPath string, fields map[string]string, file common.MultipartFile, out any) error {
382
+ if gotPath != UploadPath || len(fields) != 0 {
383
+ t.Fatalf("multipart = (%q, %#v), want upload without auth fields", gotPath, fields)
384
+ }
385
+ if file.FieldName != "file" || file.ContentType != "video/mp4" {
386
+ t.Fatalf("file = %#v, want video multipart", file)
387
+ }
388
+ return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
389
+ },
390
+ send: func(_ context.Context, path string, _ any, out any) error {
391
+ if path != QueryPath {
392
+ t.Fatalf("path = %q, want query", path)
393
+ }
394
+ 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"}}]}}`)
395
+ },
396
+ }
397
+ result, err := Upload(context.Background(), UploadOptions{
398
+ Path: path, PollInterval: time.Millisecond, WaitTimeout: time.Second,
399
+ }, runnerWithClient(client))
400
+ if err != nil {
401
+ t.Fatalf("Upload() error = %v", err)
402
+ }
403
+ if result.State != StateReady || result.AssetID != "workspace-1" || result.PippitAssetID != "asset-1" || result.PollAttempts != 1 {
404
+ t.Fatalf("Upload() = %#v, want ready identifiers", result)
405
+ }
406
+ if result.Locator.DownloadURL == "" || result.Locator.CoverURL != "" {
407
+ t.Fatalf("Locator = %#v, want playable locator without cover requirement", result.Locator)
408
+ }
409
+ }
410
+
411
+ func TestUploadWaitTimeoutPreservesDurableAssetID(t *testing.T) {
412
+ path := filepath.Join(t.TempDir(), "clip.mp4")
413
+ if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
414
+ t.Fatalf("WriteFile() error = %v", err)
415
+ }
416
+ client := &fakeClient{
417
+ multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error {
418
+ return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
419
+ },
420
+ send: func(_ context.Context, _ string, _ any, out any) error {
421
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`)
422
+ },
423
+ }
424
+ result, err := Upload(context.Background(), UploadOptions{
425
+ Path: path, PollInterval: 50 * time.Millisecond, WaitTimeout: time.Millisecond,
426
+ }, runnerWithClient(client))
427
+ if err != nil {
428
+ t.Fatalf("Upload() error = %v, want accepted processing result", err)
429
+ }
430
+ if result.State != StateProcessing || result.PippitAssetID != "asset-1" || result.Locator.PippitAssetID != "asset-1" {
431
+ t.Fatalf("Upload() = %#v, want durable ID after wait timeout", result)
432
+ }
433
+ if !strings.Contains(result.Warning, "was not queryable") {
434
+ t.Fatalf("warning = %q, want visibility warning", result.Warning)
435
+ }
436
+ }
437
+
438
+ func TestEnvelopeRetIsStrict(t *testing.T) {
439
+ client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
440
+ return decodeInto(out, `{"errmsg":"missing ret","log_id":"strict-log","data":{"Assets":[]}}`)
441
+ }}
442
+ _, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(client))
443
+ if err == nil || !strings.Contains(err.Error(), `ret=""`) || !strings.Contains(err.Error(), "strict-log") {
444
+ t.Fatalf("Get() error = %v, want strict ret validation", err)
445
+ }
446
+ }