Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

331 lines
9.2 KiB
Go

// Command mock-woo serves minimal WooCommerce REST API v3 fixtures for local
// live sync proofs (Test Connection, product batch push, orders/reviews pull).
//
// Usage:
//
// go run ./cmd/mock-woo -addr 127.0.0.1:19090
// go run ./cmd/mock-woo -addr 127.0.0.1:19090 -key ck_mock -secret cs_mock
package main
import (
"encoding/base64"
"encoding/json"
"flag"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const apiPrefix = "/wp-json/wc/v3"
type server struct {
key string
secret string
mu sync.Mutex
nextID atomic.Int64
bySKU map[string]product
logReq atomic.Int64
}
type product struct {
ID int `json:"id"`
SKU string `json:"sku"`
Name string `json:"name"`
}
type orderBilling struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
type orderLine struct {
ID int `json:"id"`
Name string `json:"name"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Total string `json:"total"`
SKU string `json:"sku"`
MetaData []any `json:"meta_data"`
}
type order struct {
ID int `json:"id"`
Status string `json:"status"`
Currency string `json:"currency"`
Total string `json:"total"`
CustomerID int `json:"customer_id"`
DateCreated string `json:"date_created"`
DateCreatedGMT string `json:"date_created_gmt"`
Billing orderBilling `json:"billing"`
LineItems []orderLine `json:"line_items"`
}
type review struct {
ID int `json:"id"`
ProductID int `json:"product_id"`
Status string `json:"status"`
Reviewer string `json:"reviewer"`
ReviewerEmail string `json:"reviewer_email"`
Review string `json:"review"`
Rating int `json:"rating"`
DateCreated string `json:"date_created"`
DateCreatedGMT string `json:"date_created_gmt"`
ProductName string `json:"product_name"`
}
func main() {
addr := flag.String("addr", "127.0.0.1:19090", "listen address")
key := flag.String("key", envOr("MOCK_WOO_KEY", "ck_mock_local"), "consumer key")
secret := flag.String("secret", envOr("MOCK_WOO_SECRET", "cs_mock_local"), "consumer secret")
flag.Parse()
s := &server{
key: strings.TrimSpace(*key),
secret: strings.TrimSpace(*secret),
bySKU: map[string]product{},
}
s.nextID.Store(1000)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"mock-woo"}`))
})
log.Printf("mock-woo listening on http://%s key=%s", *addr, s.key)
log.Printf("WC base: http://%s%s", *addr, apiPrefix)
if err := http.ListenAndServe(*addr, mux); err != nil {
log.Fatal(err)
}
}
func envOr(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return def
}
func (s *server) handle(w http.ResponseWriter, r *http.Request) {
s.logReq.Add(1)
if !strings.HasPrefix(r.URL.Path, apiPrefix) {
http.NotFound(w, r)
return
}
if !s.authOK(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="WooCommerce"`)
http.Error(w, `{"code":"woocommerce_rest_cannot_view","message":"unauthorized"}`, http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, apiPrefix)
path = strings.TrimSuffix(path, "/")
switch {
case r.Method == http.MethodGet && path == "/products":
s.handleListProducts(w, r)
case r.Method == http.MethodPost && path == "/products/batch":
s.handleBatchProducts(w, r)
case r.Method == http.MethodGet && path == "/products/categories":
s.writeJSON(w, []map[string]any{
{"id": 10, "name": "Demo Electronics", "slug": "demo-electronics"},
{"id": 11, "name": "Accessories", "slug": "accessories"},
})
case r.Method == http.MethodGet && path == "/products/attributes":
s.writeJSON(w, []map[string]any{
{"id": 20, "name": "Color", "slug": "pa_color"},
{"id": 21, "name": "Size", "slug": "pa_size"},
})
case r.Method == http.MethodGet && path == "/orders":
s.handleOrders(w, r)
case r.Method == http.MethodGet && path == "/products/reviews":
s.handleReviews(w, r)
default:
http.Error(w, `{"code":"rest_no_route","message":"no route"}`, http.StatusNotFound)
}
}
func (s *server) authOK(r *http.Request) bool {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Basic ") {
return false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h, "Basic "))
if err != nil {
return false
}
parts := strings.SplitN(string(raw), ":", 2)
if len(parts) != 2 {
return false
}
return parts[0] == s.key && parts[1] == s.secret
}
func (s *server) handleListProducts(w http.ResponseWriter, r *http.Request) {
sku := strings.TrimSpace(r.URL.Query().Get("sku"))
s.mu.Lock()
defer s.mu.Unlock()
out := make([]product, 0)
if sku != "" {
if p, ok := s.bySKU[sku]; ok {
out = append(out, p)
}
} else {
for _, p := range s.bySKU {
out = append(out, p)
if len(out) >= 1 {
break
}
}
}
s.writeJSON(w, out)
}
func (s *server) handleBatchProducts(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
if err != nil {
http.Error(w, `{"message":"read body"}`, http.StatusBadRequest)
return
}
var req struct {
Create []map[string]any `json:"create"`
Update []map[string]any `json:"update"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"message":"invalid json"}`, http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
created := make([]product, 0, len(req.Create))
updated := make([]product, 0, len(req.Update))
for _, item := range req.Create {
p := s.upsertFromPayload(item, 0)
created = append(created, p)
}
for _, item := range req.Update {
id := intFromAny(item["id"])
p := s.upsertFromPayload(item, id)
updated = append(updated, p)
}
s.writeJSON(w, map[string]any{"create": created, "update": updated})
}
func (s *server) upsertFromPayload(item map[string]any, preferID int) product {
sku, _ := item["sku"].(string)
name, _ := item["name"].(string)
if name == "" {
name = "Product"
}
id := preferID
if id <= 0 {
if existing, ok := s.bySKU[sku]; ok && sku != "" {
id = existing.ID
} else {
id = int(s.nextID.Add(1))
}
}
p := product{ID: id, SKU: sku, Name: name}
if sku != "" {
s.bySKU[sku] = p
}
return p
}
func (s *server) handleOrders(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page <= 0 {
page = 1
}
if page > 1 {
s.writeJSON(w, []order{})
return
}
now := time.Now().UTC().Format("2006-01-02T15:04:05")
orders := []order{
{
ID: 5001, Status: "completed", Currency: "EUR", Total: "499.00", CustomerID: 1,
DateCreated: now, DateCreatedGMT: now,
Billing: orderBilling{Email: "anna.buyer@example.com", FirstName: "Anna", LastName: "Buyer"},
LineItems: []orderLine{{
ID: 1, Name: "Mock 4K TV", ProductID: 90001, Quantity: 1, Total: "499.00", SKU: "MOCK-WOO-TV",
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Demo Electronics"}}},
}},
},
{
ID: 5002, Status: "completed", Currency: "EUR", Total: "149.00", CustomerID: 2,
DateCreated: now, DateCreatedGMT: now,
Billing: orderBilling{Email: "ben.buyer@example.com", FirstName: "Ben", LastName: "Buyer"},
LineItems: []orderLine{{
ID: 2, Name: "Mock Soundbar", ProductID: 90002, Quantity: 1, Total: "149.00", SKU: "MOCK-WOO-SOUND",
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Demo Electronics"}}},
}},
},
{
ID: 5003, Status: "processing", Currency: "EUR", Total: "29.00", CustomerID: 3,
DateCreated: now, DateCreatedGMT: now,
Billing: orderBilling{Email: "cara.buyer@example.com", FirstName: "Cara", LastName: "Buyer"},
LineItems: []orderLine{{
ID: 3, Name: "Mock Cable", ProductID: 90010, Quantity: 1, Total: "29.00", SKU: "MOCK-WOO-CABLE",
MetaData: []any{map[string]any{"key": "categories", "value": []any{"Accessories"}}},
}},
},
}
s.writeJSON(w, orders)
}
func (s *server) handleReviews(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page <= 0 {
page = 1
}
if page > 1 {
s.writeJSON(w, []review{})
return
}
now := time.Now().UTC().Format("2006-01-02T15:04:05")
s.writeJSON(w, []review{
{
ID: 7001, ProductID: 90001, Status: "approved", Reviewer: "Anna Buyer",
ReviewerEmail: "anna.buyer@example.com", Review: "Great mock TV.", Rating: 5,
DateCreated: now, DateCreatedGMT: now, ProductName: "Mock 4K TV",
},
{
ID: 7002, ProductID: 90002, Status: "approved", Reviewer: "Ben Buyer",
ReviewerEmail: "ben.buyer@example.com", Review: "Solid soundbar for demos.", Rating: 4,
DateCreated: now, DateCreatedGMT: now, ProductName: "Mock Soundbar",
},
})
}
func (s *server) writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
if err := enc.Encode(v); err != nil {
log.Printf("encode: %v", err)
}
}
func intFromAny(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case json.Number:
i, _ := t.Int64()
return int(i)
case string:
i, _ := strconv.Atoi(t)
return i
default:
return 0
}
}