// Command v1-process-smoke: low-concurrency legacy v1 create/process + poll. // // Contract (see docs/live-public-api-e2e.md): // // POST /api/v1/products/process { items:[{ean,…}], processing_type } // GET /api/v1/products/process/{process_id} until COMPLETED|FAILED|CANCELLED // // Auth: set API_KEY (or -key). Local seed key is documented in docs/demo-user.md — // do not commit other secrets. Default base: http://127.0.0.1:28471 (HTTP_ADDR). // // Usage (API + worker listening): // // $env:API_KEY = "" // cd scripts/v1-process-smoke && go run . package main import ( "bytes" "encoding/json" "flag" "fmt" "io" "net/http" "os" "strings" "time" ) const ( defaultBase = "http://127.0.0.1:28471" defaultPoll = 2 * time.Second defaultTimeout = 10 * time.Second defaultWait = 3 * time.Minute defaultProcType = "full" defaultSmokeEAN = "8700999000001" ) type envelope struct { Data json.RawMessage `json:"data"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } type startData struct { ProcessID string `json:"process_id"` Message string `json:"message"` TotalItems int `json:"total_items"` ProcessedItems int `json:"processed_items"` Errors []string `json:"errors"` } type statusData struct { Status string `json:"status"` ProcessID string `json:"process_id"` ProcessingType json.RawMessage `json:"processing_type"` Items []any `json:"items"` TotalItems int `json:"total_items"` Error string `json:"error"` Message string `json:"message"` } func main() { base := flag.String("base", envOr("API_BASE", defaultBase), "API base URL (no trailing slash)") key := flag.String("key", strings.TrimSpace(os.Getenv("API_KEY")), "API key (Bearer); prefer env API_KEY") eans := flag.String("eans", envOr("SMOKE_EANS", defaultSmokeEAN), "comma-separated EANs to upsert+process") title := flag.String("title", envOr("SMOKE_TITLE", "v1-process-smoke sample"), "title set on each created item") ptype := flag.String("processing-type", envOr("SMOKE_PROCESSING_TYPE", defaultProcType), "processing_type (full|category|title|…)") pollEvery := flag.Duration("poll", defaultPoll, "status poll interval") waitFor := flag.Duration("wait", defaultWait, "max time to wait for terminal status") timeout := flag.Duration("timeout", defaultTimeout, "per-request HTTP timeout") flag.Parse() apiKey := strings.TrimSpace(*key) if apiKey == "" { fmt.Fprintln(os.Stderr, "API_KEY (or -key) is required.") fmt.Fprintln(os.Stderr, "Local seed key: docs/demo-user.md (Platform Demo; prefix dk_demo_lo…).") fmt.Fprintln(os.Stderr, "Example: $env:API_KEY = \"…\" ; go run .") os.Exit(2) } eanList := splitCSV(*eans) if len(eanList) == 0 { fmt.Fprintln(os.Stderr, "-eans must list at least one EAN") os.Exit(2) } if *pollEvery <= 0 || *waitFor <= 0 { fmt.Fprintln(os.Stderr, "-poll and -wait must be > 0") os.Exit(2) } baseURL := strings.TrimRight(strings.TrimSpace(*base), "/") client := &http.Client{Timeout: *timeout} items := make([]map[string]string, 0, len(eanList)) for _, ean := range eanList { items = append(items, map[string]string{ "ean": ean, "title": *title, }) } body, err := json.Marshal(map[string]any{ "processing_type": *ptype, "items": items, }) if err != nil { fmt.Fprintf(os.Stderr, "marshal start body: %v\n", err) os.Exit(1) } fmt.Printf("v1-process-smoke base=%s items=%d processing_type=%s poll=%s wait=%s\n", baseURL, len(items), *ptype, pollEvery.String(), waitFor.String()) fmt.Println("ASSUMPTION: worker must be running or jobs stay PENDING/PROCESSING until timeout.") startURL := baseURL + "/api/v1/products/process" raw, code, err := doJSON(client, http.MethodPost, startURL, apiKey, body) if err != nil { fmt.Fprintf(os.Stderr, "POST %s: %v\n", startURL, err) os.Exit(1) } start, err := decodeStart(raw, code) if err != nil { fmt.Fprintf(os.Stderr, "POST start failed (HTTP %d): %v\nbody=%s\n", code, err, truncate(string(raw), 400)) os.Exit(1) } fmt.Printf("started process_id=%s total_items=%d processed_items=%d %s\n", start.ProcessID, start.TotalItems, start.ProcessedItems, start.Message) if len(start.Errors) > 0 { fmt.Printf("item warnings: %s\n", strings.Join(start.Errors, "; ")) } deadline := time.Now().Add(*waitFor) statusURL := baseURL + "/api/v1/products/process/" + start.ProcessID for { raw, code, err := doJSON(client, http.MethodGet, statusURL, apiKey, nil) if err != nil { fmt.Fprintf(os.Stderr, "GET %s: %v\n", statusURL, err) os.Exit(1) } st, err := decodeStatus(raw, code) if err != nil { fmt.Fprintf(os.Stderr, "GET status failed (HTTP %d): %v\nbody=%s\n", code, err, truncate(string(raw), 400)) os.Exit(1) } fmt.Printf("poll status=%s items=%d\n", st.Status, len(st.Items)) switch strings.ToUpper(st.Status) { case "COMPLETED": if st.Message != "" { fmt.Println(st.Message) } fmt.Printf("ok process_id=%s total_items=%d\n", st.ProcessID, st.TotalItems) os.Exit(0) case "FAILED": fmt.Fprintf(os.Stderr, "process failed: %s\n", firstNonEmpty(st.Error, st.Message, "unknown")) os.Exit(1) case "CANCELLED", "CANCELED": fmt.Fprintln(os.Stderr, "process cancelled") os.Exit(1) } if time.Now().After(deadline) { fmt.Fprintf(os.Stderr, "timeout after %s (last status=%s). Is the worker running?\n", waitFor.String(), st.Status) os.Exit(1) } time.Sleep(*pollEvery) } } func doJSON(client *http.Client, method, url, apiKey string, body []byte) ([]byte, int, error) { var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, url, rdr) if err != nil { return nil, 0, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } res, err := client.Do(req) if err != nil { return nil, 0, err } defer res.Body.Close() raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return nil, res.StatusCode, err } return raw, res.StatusCode, nil } func decodeStart(raw []byte, code int) (startData, error) { var out startData data, err := unwrapData(raw, code) if err != nil { return out, err } if err := json.Unmarshal(data, &out); err != nil { return out, err } if strings.TrimSpace(out.ProcessID) == "" { return out, fmt.Errorf("missing data.process_id") } return out, nil } func decodeStatus(raw []byte, code int) (statusData, error) { var out statusData data, err := unwrapData(raw, code) if err != nil { return out, err } if err := json.Unmarshal(data, &out); err != nil { return out, err } if strings.TrimSpace(out.Status) == "" { return out, fmt.Errorf("missing data.status") } return out, nil } func unwrapData(raw []byte, code int) (json.RawMessage, error) { var env envelope if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("invalid JSON: %w", err) } if env.Error != nil { return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) } if code < 200 || code >= 300 { return nil, fmt.Errorf("unexpected HTTP %d", code) } if len(env.Data) == 0 || string(env.Data) == "null" { return nil, fmt.Errorf("missing data") } return env.Data, nil } func splitCSV(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func envOr(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }