Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
278 lines
7.9 KiB
Go
278 lines
7.9 KiB
Go
// 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 = "<from docs/demo-user.md>"
|
|
// 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] + "…"
|
|
}
|