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.
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAPIVersion = "2024-10"
|
||||
defaultTimeout = 30 * time.Second
|
||||
maxBodyBytes = 8 << 20
|
||||
dryRunToken = "dry-run"
|
||||
maxRateLimitRetries = 5
|
||||
maxSKULookupChunk = 50
|
||||
)
|
||||
|
||||
var apiVersionRe = regexp.MustCompile(`^\d{4}-\d{2}$`)
|
||||
|
||||
// normalizeAPIVersion accepts Shopify Admin API versions like "2024-10".
|
||||
// Anything else falls back to the default to prevent path injection.
|
||||
func normalizeAPIVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if apiVersionRe.MatchString(v) {
|
||||
return v
|
||||
}
|
||||
return defaultAPIVersion
|
||||
}
|
||||
|
||||
// shopifySKUSearchQuery builds a GraphQL productVariants search string.
|
||||
// Quoting prevents SKU characters from acting as Shopify search operators.
|
||||
func shopifySKUSearchQuery(sku string) string {
|
||||
escaped := strings.ReplaceAll(sku, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return `sku:"` + escaped + `"`
|
||||
}
|
||||
|
||||
// shopifySKUSearchQueryOR joins multiple sku:"…" clauses for one GraphQL lookup.
|
||||
func shopifySKUSearchQueryOR(skus []string) string {
|
||||
parts := make([]string, 0, len(skus))
|
||||
for _, sku := range skus {
|
||||
sku = strings.TrimSpace(sku)
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, shopifySKUSearchQuery(sku))
|
||||
}
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func uniqueTrimmedSKUs(skus []string) []string {
|
||||
seen := make(map[string]struct{}, len(skus))
|
||||
out := make([]string, 0, len(skus))
|
||||
for _, sku := range skus {
|
||||
sku = strings.TrimSpace(sku)
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[sku]; ok {
|
||||
continue
|
||||
}
|
||||
seen[sku] = struct{}{}
|
||||
out = append(out, sku)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retryAfterWait(h http.Header, attempt int) time.Duration {
|
||||
if ra := strings.TrimSpace(h.Get("Retry-After")); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
// 1s, 2s, 4s, 8s, 16s (capped)
|
||||
shift := attempt
|
||||
if shift > 4 {
|
||||
shift = 4
|
||||
}
|
||||
return time.Duration(1<<shift) * time.Second
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ShopDomain string
|
||||
AccessToken string
|
||||
APIVersion string
|
||||
HTTP *http.Client
|
||||
DryRun bool
|
||||
|
||||
dryProductSeq atomic.Int64
|
||||
dryOrderSeq atomic.Int64
|
||||
}
|
||||
|
||||
type ShopInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type ProductImage struct {
|
||||
Src string `json:"src,omitempty"`
|
||||
}
|
||||
|
||||
type ProductVariant struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
SKU string `json:"sku,omitempty"`
|
||||
Price string `json:"price,omitempty"`
|
||||
Barcode string `json:"barcode,omitempty"`
|
||||
}
|
||||
|
||||
type Metafield struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ProductPayload struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
ProductType string `json:"product_type,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Variants []ProductVariant `json:"variants,omitempty"`
|
||||
Images []ProductImage `json:"images,omitempty"`
|
||||
Metafields []Metafield `json:"metafields,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Variants []ProductVariant `json:"variants"`
|
||||
}
|
||||
|
||||
type OrderCustomer struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
type OrderLineItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ProductID int64 `json:"product_id"`
|
||||
VariantID int64 `json:"variant_id"`
|
||||
SKU string `json:"sku"`
|
||||
Title string `json:"title"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FinancialStatus string `json:"financial_status"`
|
||||
FulfillmentStatus string `json:"fulfillment_status"`
|
||||
Currency string `json:"currency"`
|
||||
TotalPrice string `json:"total_price"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Customer *OrderCustomer `json:"customer"`
|
||||
Email string `json:"email"`
|
||||
LineItems []OrderLineItem `json:"line_items"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func NewClient(shopDomain, token, apiVersion string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = security.SafeHTTPClient(defaultTimeout, false)
|
||||
}
|
||||
apiVersion = normalizeAPIVersion(apiVersion)
|
||||
token = strings.TrimSpace(token)
|
||||
dry := strings.EqualFold(token, dryRunToken)
|
||||
return &Client{
|
||||
ShopDomain: strings.TrimSpace(strings.ToLower(shopDomain)),
|
||||
AccessToken: token,
|
||||
APIVersion: apiVersion,
|
||||
HTTP: httpClient,
|
||||
DryRun: dry,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) TestConnection(ctx context.Context) (*ShopInfo, error) {
|
||||
if c.DryRun {
|
||||
return &ShopInfo{ID: 1, Name: "Dry Run Shop", Domain: c.ShopDomain, Currency: "USD"}, nil
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodGet, "/shop.json", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Shop ShopInfo `json:"shop"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Shop, nil
|
||||
}
|
||||
|
||||
func (c *Client) FindProductBySKU(ctx context.Context, sku string) (*Product, error) {
|
||||
found, err := c.FindProductsBySKUs(ctx, []string{sku})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sku = strings.TrimSpace(sku)
|
||||
if p, ok := found[sku]; ok && p.ID > 0 {
|
||||
cp := p
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FindProductsBySKUs resolves many SKUs in chunked GraphQL lookups (not one HTTP call per SKU).
|
||||
// Keys in the returned map are the exact requested SKUs that matched.
|
||||
func (c *Client) FindProductsBySKUs(ctx context.Context, skus []string) (map[string]Product, error) {
|
||||
skus = uniqueTrimmedSKUs(skus)
|
||||
out := make(map[string]Product, len(skus))
|
||||
if len(skus) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if c.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type gqlResp struct {
|
||||
Data struct {
|
||||
ProductVariants struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
SKU string `json:"sku"`
|
||||
Product struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"product"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"productVariants"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
chunkSize := maxSKULookupChunk
|
||||
for i := 0; i < len(skus); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(skus) {
|
||||
end = len(skus)
|
||||
}
|
||||
chunk := skus[i:end]
|
||||
want := make(map[string]struct{}, len(chunk))
|
||||
for _, sku := range chunk {
|
||||
want[sku] = struct{}{}
|
||||
}
|
||||
body := map[string]any{
|
||||
"query": `query($q: String!, $n: Int!) {
|
||||
productVariants(first: $n, query: $q) {
|
||||
edges { node { sku product { id title } } }
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]any{
|
||||
"q": shopifySKUSearchQueryOR(chunk),
|
||||
"n": len(chunk),
|
||||
},
|
||||
}
|
||||
raw, err := c.doGraphQL(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp gqlResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Errors) > 0 {
|
||||
return nil, fmt.Errorf("shopify graphql: %s", resp.Errors[0].Message)
|
||||
}
|
||||
for _, edge := range resp.Data.ProductVariants.Edges {
|
||||
node := edge.Node
|
||||
sku := strings.TrimSpace(node.SKU)
|
||||
if _, ok := want[sku]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := out[sku]; exists {
|
||||
continue
|
||||
}
|
||||
id, err := parseShopifyGID(node.Product.ID)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
out[sku] = Product{
|
||||
ID: id,
|
||||
Title: node.Product.Title,
|
||||
Variants: []ProductVariant{{SKU: sku}},
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseShopifyGID(gid string) (int64, error) {
|
||||
// gid://shopify/Product/1234567890
|
||||
parts := strings.Split(strings.TrimSpace(gid), "/")
|
||||
if len(parts) == 0 {
|
||||
return 0, fmt.Errorf("empty gid")
|
||||
}
|
||||
return strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
}
|
||||
|
||||
func (c *Client) doGraphQL(ctx context.Context, body any) ([]byte, error) {
|
||||
base := AdminBaseURL(c.ShopDomain)
|
||||
apiPath := fmt.Sprintf("/admin/api/%s/graphql.json", c.APIVersion)
|
||||
u, err := url.Parse(base + apiPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRateLimitRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||||
req.Header.Set("X-Shopify-Access-Token", c.AccessToken)
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("shopify response too large")
|
||||
}
|
||||
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable {
|
||||
lastErr = fmt.Errorf("shopify graphql %d: rate limited", res.StatusCode)
|
||||
if attempt == maxRateLimitRetries {
|
||||
break
|
||||
}
|
||||
wait := retryAfterWait(res.Header, attempt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 400 {
|
||||
msg = msg[:400] + "…"
|
||||
}
|
||||
return nil, fmt.Errorf("shopify graphql %d: %s", res.StatusCode, msg)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) CreateProduct(ctx context.Context, product ProductPayload) (*Product, error) {
|
||||
if c.DryRun {
|
||||
id := c.dryProductSeq.Add(1) + 1000
|
||||
return &Product{ID: id, Title: product.Title, Variants: product.Variants}, nil
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/products.json", nil, map[string]any{"product": product})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Product Product `json:"product"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Product, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateProduct(ctx context.Context, productID int64, product ProductPayload) (*Product, error) {
|
||||
product.ID = productID
|
||||
if c.DryRun {
|
||||
return &Product{ID: productID, Title: product.Title, Variants: product.Variants}, nil
|
||||
}
|
||||
path := fmt.Sprintf("/products/%d.json", productID)
|
||||
raw, err := c.do(ctx, http.MethodPut, path, nil, map[string]any{"product": product})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrap struct {
|
||||
Product Product `json:"product"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wrap.Product, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListOrdersPage(ctx context.Context, pageInfo string, limit int) ([]Order, string, error) {
|
||||
if limit <= 0 || limit > 250 {
|
||||
limit = 50
|
||||
}
|
||||
if c.DryRun {
|
||||
if pageInfo != "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
id := c.dryOrderSeq.Add(1) + 5000
|
||||
return []Order{{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("#D%d", id),
|
||||
FinancialStatus: "paid",
|
||||
Currency: "USD",
|
||||
TotalPrice: "19.99",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Email: "dry-run@example.com",
|
||||
Customer: &OrderCustomer{ID: 1, Email: "dry-run@example.com", FirstName: "Dry", LastName: "Run"},
|
||||
LineItems: []OrderLineItem{{
|
||||
ID: 1, ProductID: 1001, VariantID: 2001, SKU: "DRY-SKU", Title: "Dry product", Name: "Dry product", Quantity: 1, Price: "19.99",
|
||||
}},
|
||||
}}, "", nil
|
||||
}
|
||||
q := map[string]string{
|
||||
"limit": strconv.Itoa(limit),
|
||||
"status": "any",
|
||||
"order": "created_at desc",
|
||||
}
|
||||
if pageInfo != "" {
|
||||
q["page_info"] = pageInfo
|
||||
}
|
||||
raw, linkHeader, err := c.doWithHeaders(ctx, http.MethodGet, "/orders.json", q, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var wrap struct {
|
||||
Orders []Order `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var rawItems []json.RawMessage
|
||||
_ = json.Unmarshal(extractArray(raw, "orders"), &rawItems)
|
||||
for i := range wrap.Orders {
|
||||
if i < len(rawItems) {
|
||||
wrap.Orders[i].Raw = rawItems[i]
|
||||
}
|
||||
}
|
||||
return wrap.Orders, nextPageInfo(linkHeader), nil
|
||||
}
|
||||
|
||||
func extractArray(raw []byte, key string) []byte {
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
func nextPageInfo(linkHeader string) string {
|
||||
// Rel="next" URL page_info=...
|
||||
parts := strings.Split(linkHeader, ",")
|
||||
for _, part := range parts {
|
||||
if !strings.Contains(part, `rel="next"`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(part, "<")
|
||||
end := strings.Index(part, ">")
|
||||
if start < 0 || end <= start {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(part[start+1 : end])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return u.Query().Get("page_info")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, query map[string]string, body any) ([]byte, error) {
|
||||
raw, _, err := c.doWithHeaders(ctx, method, path, query, body)
|
||||
return raw, err
|
||||
}
|
||||
|
||||
func (c *Client) doWithHeaders(ctx context.Context, method, path string, query map[string]string, body any) ([]byte, string, error) {
|
||||
base := AdminBaseURL(c.ShopDomain)
|
||||
apiPath := fmt.Sprintf("/admin/api/%s%s", c.APIVersion, path)
|
||||
u, err := url.Parse(base + apiPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
q := u.Query()
|
||||
for k, v := range query {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
if err := GuardAdminURL(u.String(), c.ShopDomain); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var bodyBytes []byte
|
||||
if body != nil {
|
||||
bodyBytes, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRateLimitRetries; attempt++ {
|
||||
var rdr io.Reader
|
||||
if bodyBytes != nil {
|
||||
rdr = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||||
req.Header.Set("X-Shopify-Access-Token", c.AccessToken)
|
||||
if bodyBytes != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
link := res.Header.Get("Link")
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(raw) > maxBodyBytes {
|
||||
return nil, "", fmt.Errorf("shopify response too large")
|
||||
}
|
||||
if res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable {
|
||||
lastErr = fmt.Errorf("shopify api %d: rate limited", res.StatusCode)
|
||||
if attempt == maxRateLimitRetries {
|
||||
break
|
||||
}
|
||||
wait := retryAfterWait(res.Header, attempt)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, "", ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 400 {
|
||||
msg = msg[:400] + "…"
|
||||
}
|
||||
return nil, "", fmt.Errorf("shopify api %d: %s", res.StatusCode, msg)
|
||||
}
|
||||
return raw, link, nil
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
// DeriveKey builds a 32-byte AES key. Prefer explicitKey; fall back to hashed material.
|
||||
// In production, explicitKey is required; empty returns nil (fail closed).
|
||||
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
if b, err := decodeKeyMaterial(explicitKey); err == nil {
|
||||
return b
|
||||
}
|
||||
sum := sha256.Sum256([]byte(explicitKey))
|
||||
return sum[:]
|
||||
}
|
||||
if config.IsProductionEnv() {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte("descrybe-shopify-v1|" + fallbackMaterial))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func decodeKeyMaterial(s string) ([]byte, error) {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("invalid key material")
|
||||
}
|
||||
|
||||
func EncryptSecret(key []byte, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func DecryptSecret(key []byte, stored string) (string, error) {
|
||||
if stored == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(stored, encPrefix) {
|
||||
if config.IsProductionEnv() {
|
||||
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidShopDomain = errors.New("invalid shop domain")
|
||||
ErrBlockedShopDomain = errors.New("shop domain host is not allowed")
|
||||
)
|
||||
|
||||
var shopNameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
|
||||
|
||||
// NormalizeShopDomain validates a Shopify shop and returns host "name.myshopify.com".
|
||||
// Accepts "name", "name.myshopify.com", or https URLs. Admin API always uses myshopify.com
|
||||
// (custom storefront domains are rejected). Blocks IPs, localhost, and private ranges (SSRF).
|
||||
func NormalizeShopDomain(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" {
|
||||
return "", ErrInvalidShopDomain
|
||||
}
|
||||
raw = strings.TrimPrefix(raw, "https://")
|
||||
raw = strings.TrimPrefix(raw, "http://")
|
||||
raw = strings.SplitN(raw, "/", 2)[0]
|
||||
raw = strings.SplitN(raw, "?", 2)[0]
|
||||
raw = strings.TrimSuffix(raw, ".")
|
||||
|
||||
if strings.Contains(raw, ":") {
|
||||
host, _, err := net.SplitHostPort(raw)
|
||||
if err == nil {
|
||||
raw = host
|
||||
}
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
return "", ErrBlockedShopDomain
|
||||
}
|
||||
if raw == "localhost" || raw == "metadata.google.internal" || raw == "metadata" {
|
||||
return "", ErrBlockedShopDomain
|
||||
}
|
||||
|
||||
shop := raw
|
||||
if strings.HasSuffix(raw, ".myshopify.com") {
|
||||
shop = strings.TrimSuffix(raw, ".myshopify.com")
|
||||
} else if strings.Contains(raw, ".") {
|
||||
// Custom domains / arbitrary hosts are not valid Admin API bases.
|
||||
return "", fmt.Errorf("%w: use your *.myshopify.com shop name (not a custom domain)", ErrInvalidShopDomain)
|
||||
}
|
||||
|
||||
shop = strings.TrimSpace(shop)
|
||||
if !shopNameRe.MatchString(shop) {
|
||||
return "", ErrInvalidShopDomain
|
||||
}
|
||||
|
||||
host := shop + ".myshopify.com"
|
||||
ips, err := resolveHostIPs(host)
|
||||
if err != nil {
|
||||
// DNS may fail offline; still return canonical host for config save / dry-run.
|
||||
// Live TestConnection will fail closed on network errors.
|
||||
return host, nil
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !allowedPublicIP(ip) {
|
||||
return "", ErrBlockedShopDomain
|
||||
}
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// AdminBaseURL builds https://{shop}.myshopify.com for Admin REST calls.
|
||||
func AdminBaseURL(shopDomain string) string {
|
||||
host := strings.TrimSpace(strings.ToLower(shopDomain))
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
host = strings.TrimRight(host, "/")
|
||||
return "https://" + host
|
||||
}
|
||||
|
||||
func resolveHostIPs(host string) ([]net.IP, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
addrs, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidShopDomain
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
func allowedPublicIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
if ip4[0] == 10 {
|
||||
return false
|
||||
}
|
||||
if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 {
|
||||
return false
|
||||
}
|
||||
if ip4[0] == 192 && ip4[1] == 168 {
|
||||
return false
|
||||
}
|
||||
if ip4[0] == 169 && ip4[1] == 254 {
|
||||
return false
|
||||
}
|
||||
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
} else if ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GuardAdminURL ensures a request URL targets the expected shop host over https (SSRF).
|
||||
func GuardAdminURL(rawURL, expectedShopHost string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ErrInvalidShopDomain
|
||||
}
|
||||
if strings.ToLower(u.Scheme) != "https" {
|
||||
return ErrBlockedShopDomain
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
want := strings.ToLower(strings.TrimSpace(expectedShopHost))
|
||||
want = strings.TrimPrefix(want, "https://")
|
||||
want = strings.TrimPrefix(want, "http://")
|
||||
if host != want {
|
||||
return ErrBlockedShopDomain
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ErrBlockedShopDomain
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNormalizeShopDomainNameOnly(t *testing.T) {
|
||||
got, err := NormalizeShopDomain("My-Store")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "my-store.myshopify.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeShopDomainFullHost(t *testing.T) {
|
||||
got, err := NormalizeShopDomain("https://demo-shop.myshopify.com/admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "demo-shop.myshopify.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeShopDomainRejectsCustomDomain(t *testing.T) {
|
||||
_, err := NormalizeShopDomain("https://shop.example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeShopDomainRejectsIP(t *testing.T) {
|
||||
_, err := NormalizeShopDomain("192.168.1.10")
|
||||
if !errors.Is(err, ErrBlockedShopDomain) && !errors.Is(err, ErrInvalidShopDomain) {
|
||||
t.Fatalf("expected blocked/invalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeShopDomainRejectsLocalhost(t *testing.T) {
|
||||
_, err := NormalizeShopDomain("localhost")
|
||||
if !errors.Is(err, ErrBlockedShopDomain) {
|
||||
t.Fatalf("expected blocked, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAdminURL(t *testing.T) {
|
||||
if err := GuardAdminURL("https://demo.myshopify.com/admin/api/2024-10/shop.json", "demo.myshopify.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := GuardAdminURL("http://demo.myshopify.com/admin/api/2024-10/shop.json", "demo.myshopify.com"); err == nil {
|
||||
t.Fatal("expected http blocked")
|
||||
}
|
||||
if err := GuardAdminURL("https://evil.example.com/admin/api/2024-10/shop.json", "demo.myshopify.com"); err == nil {
|
||||
t.Fatal("expected host mismatch blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "development")
|
||||
key := DeriveKey("test-passphrase", "fallback")
|
||||
enc, err := EncryptSecret(key, "shpat_test_token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc == "shpat_test_token" {
|
||||
t.Fatal("expected ciphertext")
|
||||
}
|
||||
plain, err := DecryptSecret(key, enc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain != "shpat_test_token" {
|
||||
t.Fatalf("got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyncOptionsClampsLimits(t *testing.T) {
|
||||
raw := []byte(`{"sync_limit":99999,"batch_size":500,"orders_sync_limit":99999,"schedule_interval_hours":999}`)
|
||||
opt := parseSyncOptions(raw)
|
||||
if opt.SyncLimit != defaultSyncLimit {
|
||||
t.Fatalf("sync_limit=%d", opt.SyncLimit)
|
||||
}
|
||||
if opt.BatchSize != defaultBatchSize {
|
||||
t.Fatalf("batch_size=%d", opt.BatchSize)
|
||||
}
|
||||
if opt.OrdersSyncLimit != 0 {
|
||||
t.Fatalf("orders_sync_limit=%d", opt.OrdersSyncLimit)
|
||||
}
|
||||
if opt.ScheduleIntervalHours != 0 {
|
||||
t.Fatalf("schedule_interval_hours=%d", opt.ScheduleIntervalHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyncOptionsPrunesProductIDs(t *testing.T) {
|
||||
ids := make(map[string]int64, maxProductIDMap+50)
|
||||
for i := 0; i < maxProductIDMap+50; i++ {
|
||||
ids[fmt.Sprintf("sku-%d", i)] = int64(i + 1)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"product_ids": ids})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opt := parseSyncOptions(raw)
|
||||
if len(opt.ProductIDs) != maxProductIDMap {
|
||||
t.Fatalf("product_ids len=%d want %d", len(opt.ProductIDs), maxProductIDMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyncOptionsScheduleAndFilterParams(t *testing.T) {
|
||||
raw := []byte(`{"schedule_interval_hours":24,"match_strategy":"barcode","product_ids":{"SKU-1":11}}`)
|
||||
opt := parseSyncOptions(raw)
|
||||
if opt.ScheduleIntervalHours != 24 {
|
||||
t.Fatalf("schedule_interval_hours=%d want 24", opt.ScheduleIntervalHours)
|
||||
}
|
||||
if opt.MatchStrategy != "barcode" {
|
||||
t.Fatalf("match_strategy=%q", opt.MatchStrategy)
|
||||
}
|
||||
if opt.ProductIDs["SKU-1"] != 11 {
|
||||
t.Fatalf("product_ids=%v", opt.ProductIDs)
|
||||
}
|
||||
|
||||
maxRaw := []byte(fmt.Sprintf(`{"schedule_interval_hours":%d}`, maxScheduleIntervalH))
|
||||
if got := parseSyncOptions(maxRaw).ScheduleIntervalHours; got != maxScheduleIntervalH {
|
||||
t.Fatalf("max schedule kept=%d want %d", got, maxScheduleIntervalH)
|
||||
}
|
||||
neg := parseSyncOptions([]byte(`{"schedule_interval_hours":-3,"match_strategy":""}`))
|
||||
if neg.ScheduleIntervalHours != 0 {
|
||||
t.Fatalf("negative schedule=%d", neg.ScheduleIntervalHours)
|
||||
}
|
||||
if neg.MatchStrategy != "sku" {
|
||||
t.Fatalf("empty match_strategy default=%q", neg.MatchStrategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveScheduleIntervalAndDue(t *testing.T) {
|
||||
if got := resolveScheduleInterval(0, 0); got != 6*time.Hour {
|
||||
t.Fatalf("default interval=%s", got)
|
||||
}
|
||||
if got := resolveScheduleInterval(12, time.Hour); got != 12*time.Hour {
|
||||
t.Fatalf("custom interval=%s", got)
|
||||
}
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
if !isDueForSchedule(nil, now, time.Hour) {
|
||||
t.Fatal("nil last should be due")
|
||||
}
|
||||
recent := now.Add(-30 * time.Minute)
|
||||
if isDueForSchedule(&recent, now, time.Hour) {
|
||||
t.Fatal("recent sync should not be due")
|
||||
}
|
||||
stale := now.Add(-2 * time.Hour)
|
||||
if !isDueForSchedule(&stale, now, time.Hour) {
|
||||
t.Fatal("stale sync should be due")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScheduleRejectsInvalidInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Service{} // Pool nil — validation must fail before any DB I/O
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
_, err := s.UpdateSchedule(t.Context(), cid, -1, false)
|
||||
if !errors.Is(err, ErrInvalidScheduleInterval) {
|
||||
t.Fatalf("negative hours: err=%v", err)
|
||||
}
|
||||
_, err = s.UpdateSchedule(t.Context(), cid, maxScheduleIntervalH+1, false)
|
||||
if !errors.Is(err, ErrInvalidScheduleInterval) {
|
||||
t.Fatalf("over-max hours: err=%v", err)
|
||||
}
|
||||
msg, ok := ClientError(ErrInvalidScheduleInterval)
|
||||
if !ok || msg == "" {
|
||||
t.Fatalf("ClientError mapping missing for ErrInvalidScheduleInterval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEnqueueScheduledPaused(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
if shouldEnqueueScheduled(true, 6, nil, now, 6*time.Hour) {
|
||||
t.Fatal("paused schedule must not enqueue")
|
||||
}
|
||||
if !shouldEnqueueScheduled(false, 6, nil, now, 6*time.Hour) {
|
||||
t.Fatal("unpaused with nil last should enqueue")
|
||||
}
|
||||
recent := now.Add(-30 * time.Minute)
|
||||
if shouldEnqueueScheduled(false, 6, &recent, now, 6*time.Hour) {
|
||||
t.Fatal("recent sync within interval must not enqueue")
|
||||
}
|
||||
opt := parseSyncOptions([]byte(`{"schedule_interval_hours":12,"schedule_paused":true}`))
|
||||
if !opt.SchedulePaused || opt.ScheduleIntervalHours != 12 {
|
||||
t.Fatalf("parse paused=%v hours=%d", opt.SchedulePaused, opt.ScheduleIntervalHours)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderListFilter(t *testing.T) {
|
||||
got := normalizeOrderListFilter(OrderListFilter{Limit: 0, Offset: -5})
|
||||
if got.Limit != 50 || got.Offset != 0 {
|
||||
t.Fatalf("defaults got limit=%d offset=%d", got.Limit, got.Offset)
|
||||
}
|
||||
got = normalizeOrderListFilter(OrderListFilter{Limit: 500, Offset: 10})
|
||||
if got.Limit != 50 || got.Offset != 10 {
|
||||
t.Fatalf("over-max clamp got limit=%d offset=%d", got.Limit, got.Offset)
|
||||
}
|
||||
got = normalizeOrderListFilter(OrderListFilter{Limit: 25, Offset: 3, Status: "paid", Email: "a@b.c"})
|
||||
if got.Limit != 25 || got.Offset != 3 || got.Status != "paid" || got.Email != "a@b.c" {
|
||||
t.Fatalf("preserve got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneStringInt64Map(t *testing.T) {
|
||||
m := map[string]int64{"a": 1, "b": 2, "c": 3}
|
||||
pruneStringInt64Map(m, 2)
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("len=%d want 2", len(m))
|
||||
}
|
||||
pruneStringInt64Map(m, 10)
|
||||
if len(m) != 2 {
|
||||
t.Fatalf("no-op prune changed len=%d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAPIVersion(t *testing.T) {
|
||||
if got := normalizeAPIVersion("2024-10"); got != "2024-10" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := normalizeAPIVersion("../evil"); got != defaultAPIVersion {
|
||||
t.Fatalf("expected default, got %q", got)
|
||||
}
|
||||
if got := normalizeAPIVersion(""); got != defaultAPIVersion {
|
||||
t.Fatalf("expected default, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopifySKUSearchQuery(t *testing.T) {
|
||||
got := shopifySKUSearchQuery(`ABC" OR sku:evil`)
|
||||
want := `sku:"ABC\" OR sku:evil"`
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunClient(t *testing.T) {
|
||||
c := NewClient("demo.myshopify.com", "dry-run", "2024-10", nil)
|
||||
if !c.DryRun {
|
||||
t.Fatal("expected dry-run")
|
||||
}
|
||||
shop, err := c.TestConnection(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shop == nil || shop.Name == "" {
|
||||
t.Fatal("expected shop info")
|
||||
}
|
||||
p, err := c.CreateProduct(t.Context(), ProductPayload{Title: "Test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ID <= 0 {
|
||||
t.Fatalf("expected dry product id, got %d", p.ID)
|
||||
}
|
||||
orders, next, err := c.ListOrdersPage(t.Context(), "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(orders) != 1 || next != "" {
|
||||
t.Fatalf("unexpected orders=%d next=%q", len(orders), next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package shopify
|
||||
|
||||
import "errors"
|
||||
|
||||
// ClientError reports whether err is a known client-facing Shopify config/sync error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrInvalidShopDomain),
|
||||
errors.Is(err, ErrBlockedShopDomain),
|
||||
errors.Is(err, ErrNotConfigured),
|
||||
errors.Is(err, ErrNotEnabled),
|
||||
errors.Is(err, ErrMissingCreds),
|
||||
errors.Is(err, ErrInvalidSyncScope),
|
||||
errors.Is(err, ErrInvalidScheduleInterval),
|
||||
errors.Is(err, ErrInvalidClientCredentials),
|
||||
errors.Is(err, ErrTokenExchangeFailed):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
authModeLegacyToken = "legacy_token"
|
||||
authModeClientCredentials = "client_credentials"
|
||||
tokenRefreshSkew = 2 * time.Minute
|
||||
maxTokenBodyBytes = 1 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidClientCredentials = errors.New("shopify client id and secret are required together")
|
||||
ErrTokenExchangeFailed = errors.New("shopify token exchange failed")
|
||||
)
|
||||
|
||||
// AccessTokenResult is the Admin API token from client_credentials grant.
|
||||
type AccessTokenResult struct {
|
||||
AccessToken string
|
||||
Scope string
|
||||
ExpiresIn int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type tokenExchangeResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
// ExchangeClientCredentials requests a short-lived Admin API token (≈24h).
|
||||
// shopHost must be a normalized *.myshopify.com host. Uses SSRF-safe dialing via httpClient.
|
||||
func ExchangeClientCredentials(ctx context.Context, httpClient *http.Client, shopHost, clientID, clientSecret string) (AccessTokenResult, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return AccessTokenResult{}, ErrInvalidClientCredentials
|
||||
}
|
||||
shopHost = strings.TrimSpace(strings.ToLower(shopHost))
|
||||
shopHost = strings.TrimPrefix(shopHost, "https://")
|
||||
shopHost = strings.TrimPrefix(shopHost, "http://")
|
||||
tokenURL := AdminBaseURL(shopHost) + "/admin/oauth/access_token"
|
||||
if err := GuardAdminURL(tokenURL, shopHost); err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = security.SafeHTTPClient(defaultTimeout, false)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("client_secret", clientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||||
|
||||
res, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
limited := io.LimitReader(res.Body, maxTokenBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
if len(raw) > maxTokenBodyBytes {
|
||||
return AccessTokenResult{}, fmt.Errorf("%w: response too large", ErrTokenExchangeFailed)
|
||||
}
|
||||
|
||||
parsed, err := parseAccessTokenResponse(raw)
|
||||
if err != nil {
|
||||
return AccessTokenResult{}, err
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
detail := firstNonEmpty(parsed.ErrorDesc, parsed.Error, strings.TrimSpace(string(raw)))
|
||||
if len(detail) > 400 {
|
||||
detail = detail[:400] + "…"
|
||||
}
|
||||
return AccessTokenResult{}, fmt.Errorf("%w: HTTP %d: %s", ErrTokenExchangeFailed, res.StatusCode, detail)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return AccessTokenResult{}, fmt.Errorf("%w: empty access_token", ErrTokenExchangeFailed)
|
||||
}
|
||||
expiresIn := parsed.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 86399
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return AccessTokenResult{
|
||||
AccessToken: parsed.AccessToken,
|
||||
Scope: parsed.Scope,
|
||||
ExpiresIn: expiresIn,
|
||||
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAccessTokenResponse(raw []byte) (tokenExchangeResponse, error) {
|
||||
var parsed tokenExchangeResponse
|
||||
if len(raw) == 0 {
|
||||
return parsed, fmt.Errorf("%w: empty body", ErrTokenExchangeFailed)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return parsed, fmt.Errorf("%w: invalid json", ErrTokenExchangeFailed)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func tokenNeedsRefresh(expiresAt *time.Time, now time.Time) bool {
|
||||
if expiresAt == nil {
|
||||
return true
|
||||
}
|
||||
return !expiresAt.After(now.Add(tokenRefreshSkew))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseAccessTokenResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := parseAccessTokenResponse([]byte(`{"access_token":"tok_abc","scope":"read_products","expires_in":86399}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if parsed.AccessToken != "tok_abc" || parsed.Scope != "read_products" || parsed.ExpiresIn != 86399 {
|
||||
t.Fatalf("unexpected parse: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessTokenResponseEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := parseAccessTokenResponse(nil)
|
||||
if !errors.Is(err, ErrTokenExchangeFailed) {
|
||||
t.Fatalf("want ErrTokenExchangeFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeClientCredentialsRequiresPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ExchangeClientCredentials(t.Context(), nil, "demo.myshopify.com", "id-only", "")
|
||||
if !errors.Is(err, ErrInvalidClientCredentials) {
|
||||
t.Fatalf("want ErrInvalidClientCredentials, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenNeedsRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
if !tokenNeedsRefresh(nil, now) {
|
||||
t.Fatal("nil expiry should refresh")
|
||||
}
|
||||
soon := now.Add(30 * time.Second)
|
||||
if !tokenNeedsRefresh(&soon, now) {
|
||||
t.Fatal("within skew should refresh")
|
||||
}
|
||||
later := now.Add(time.Hour)
|
||||
if tokenNeedsRefresh(&later, now) {
|
||||
t.Fatal("fresh token should not refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientErrorTokenExchange(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg, ok := ClientError(ErrTokenExchangeFailed)
|
||||
if !ok || msg == "" {
|
||||
t.Fatalf("ClientError should map token exchange: ok=%v msg=%q", ok, msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOrdersSyncLimit = 500
|
||||
defaultPullPageSize = 50
|
||||
)
|
||||
|
||||
type OrdersSyncSummary struct {
|
||||
Pages int `json:"pages"`
|
||||
Fetched int `json:"fetched"`
|
||||
Upserted int `json:"upserted"`
|
||||
ItemsSaved int `json:"items_saved"`
|
||||
Failed int `json:"failed"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type OrderListFilter struct {
|
||||
Status string
|
||||
Email string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type OrderRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExternalID int64 `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
Currency string `json:"currency"`
|
||||
Total *string `json:"total"`
|
||||
CustomerEmail *string `json:"customer_email"`
|
||||
CustomerName *string `json:"customer_name"`
|
||||
OrderedAt *time.Time `json:"ordered_at"`
|
||||
}
|
||||
|
||||
// SyncOrders pulls Shopify orders in pages and upserts company-scoped rows.
|
||||
func (s *Service) SyncOrders(ctx context.Context, companyID uuid.UUID) (OrdersSyncSummary, error) {
|
||||
client, _, opt, err := s.clientFor(ctx, companyID)
|
||||
if err != nil {
|
||||
return OrdersSyncSummary{}, err
|
||||
}
|
||||
limit := opt.OrdersSyncLimit
|
||||
if limit <= 0 || limit > maxOrdersSyncLimit {
|
||||
limit = defaultOrdersSyncLimit
|
||||
}
|
||||
summary := OrdersSyncSummary{DryRun: client.DryRun}
|
||||
pageInfo := ""
|
||||
fetched := 0
|
||||
for fetched < limit {
|
||||
summary.Pages++
|
||||
pageSize := defaultPullPageSize
|
||||
if remaining := limit - fetched; remaining < pageSize {
|
||||
pageSize = remaining
|
||||
}
|
||||
orders, next, err := client.ListOrdersPage(ctx, pageInfo, pageSize)
|
||||
if err != nil {
|
||||
opt.LastOrdersSyncStatus = "failed"
|
||||
opt.LastOrdersSyncError = err.Error()
|
||||
opt.PendingOrdersSync = false
|
||||
_ = s.saveSyncOptions(ctx, companyID, opt)
|
||||
return summary, err
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
break
|
||||
}
|
||||
for _, order := range orders {
|
||||
fetched++
|
||||
summary.Fetched++
|
||||
items, err := s.upsertOrder(ctx, companyID, order)
|
||||
if err != nil {
|
||||
summary.Failed++
|
||||
continue
|
||||
}
|
||||
summary.Upserted++
|
||||
summary.ItemsSaved += items
|
||||
}
|
||||
if next == "" || len(orders) < pageSize {
|
||||
break
|
||||
}
|
||||
pageInfo = next
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
opt.LastOrdersSyncedAt = &now
|
||||
opt.PendingOrdersSync = false
|
||||
if summary.Failed == 0 {
|
||||
opt.LastOrdersSyncStatus = "success"
|
||||
opt.LastOrdersSyncError = ""
|
||||
} else if summary.Upserted == 0 {
|
||||
opt.LastOrdersSyncStatus = "failed"
|
||||
opt.LastOrdersSyncError = "all order upserts failed"
|
||||
} else {
|
||||
opt.LastOrdersSyncStatus = "partial"
|
||||
opt.LastOrdersSyncError = "some order upserts failed"
|
||||
}
|
||||
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Service) upsertOrder(ctx context.Context, companyID uuid.UUID, order Order) (int, error) {
|
||||
email := strings.TrimSpace(order.Email)
|
||||
name := ""
|
||||
var customerID *int64
|
||||
if order.Customer != nil {
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(order.Customer.Email)
|
||||
}
|
||||
name = strings.TrimSpace(order.Customer.FirstName + " " + order.Customer.LastName)
|
||||
if order.Customer.ID > 0 {
|
||||
id := order.Customer.ID
|
||||
customerID = &id
|
||||
}
|
||||
}
|
||||
status := firstNonEmpty(order.FinancialStatus, order.FulfillmentStatus, "unknown")
|
||||
orderedAt := parseShopifyTime(order.CreatedAt)
|
||||
payload := order.Raw
|
||||
if len(payload) == 0 {
|
||||
payload, _ = json.Marshal(order)
|
||||
}
|
||||
var total any
|
||||
if order.TotalPrice != "" {
|
||||
if r, ok := new(big.Rat).SetString(order.TotalPrice); ok {
|
||||
total = r.FloatString(2)
|
||||
} else {
|
||||
total = order.TotalPrice
|
||||
}
|
||||
}
|
||||
|
||||
var orderID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO shopify_orders (
|
||||
company_id, external_id, status, currency, total, customer_id, customer_email, customer_name,
|
||||
ordered_at, payload, synced_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),NULLIF($8,''),$9,$10,now(),now())
|
||||
ON CONFLICT (company_id, external_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
currency = EXCLUDED.currency,
|
||||
total = EXCLUDED.total,
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
customer_email = EXCLUDED.customer_email,
|
||||
customer_name = EXCLUDED.customer_name,
|
||||
ordered_at = EXCLUDED.ordered_at,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = now(),
|
||||
updated_at = now()
|
||||
RETURNING id`,
|
||||
companyID, order.ID, status, order.Currency, total, customerID, email, name, orderedAt, payload,
|
||||
).Scan(&orderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
itemsSaved := 0
|
||||
for _, li := range order.LineItems {
|
||||
var lineTotal any
|
||||
if li.Price != "" {
|
||||
if r, ok := new(big.Rat).SetString(li.Price); ok {
|
||||
qty := li.Quantity
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
r.Mul(r, big.NewRat(int64(qty), 1))
|
||||
lineTotal = r.FloatString(2)
|
||||
} else {
|
||||
lineTotal = li.Price
|
||||
}
|
||||
}
|
||||
itemPayload, _ := json.Marshal(li)
|
||||
var productID, variantID *int64
|
||||
if li.ProductID > 0 {
|
||||
id := li.ProductID
|
||||
productID = &id
|
||||
}
|
||||
if li.VariantID > 0 {
|
||||
id := li.VariantID
|
||||
variantID = &id
|
||||
}
|
||||
title := firstNonEmpty(li.Name, li.Title)
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO shopify_order_items (
|
||||
company_id, order_id, external_id, product_id, variant_id, sku, name, quantity, total, payload, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,now())
|
||||
ON CONFLICT (company_id, order_id, external_id) DO UPDATE SET
|
||||
product_id = EXCLUDED.product_id,
|
||||
variant_id = EXCLUDED.variant_id,
|
||||
sku = EXCLUDED.sku,
|
||||
name = EXCLUDED.name,
|
||||
quantity = EXCLUDED.quantity,
|
||||
total = EXCLUDED.total,
|
||||
payload = EXCLUDED.payload,
|
||||
updated_at = now()`,
|
||||
companyID, orderID, li.ID, productID, variantID, li.SKU, title, maxInt(li.Quantity, 1), lineTotal, itemPayload,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
itemsSaved++
|
||||
}
|
||||
return itemsSaved, nil
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseShopifyTime(v string) *time.Time {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02T15:04:05-07:00"}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOrderListFilter(f OrderListFilter) OrderListFilter {
|
||||
if f.Limit <= 0 || f.Limit > 200 {
|
||||
f.Limit = 50
|
||||
}
|
||||
if f.Offset < 0 {
|
||||
f.Offset = 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (s *Service) ListOrders(ctx context.Context, companyID uuid.UUID, f OrderListFilter) ([]OrderRow, int, error) {
|
||||
f = normalizeOrderListFilter(f)
|
||||
where := []string{"company_id = $1"}
|
||||
args := []any{companyID}
|
||||
n := 2
|
||||
if f.Status != "" {
|
||||
where = append(where, "status = $"+itoa(n))
|
||||
args = append(args, f.Status)
|
||||
n++
|
||||
}
|
||||
if f.Email != "" {
|
||||
where = append(where, "lower(customer_email) = lower($"+itoa(n)+")")
|
||||
args = append(args, f.Email)
|
||||
n++
|
||||
}
|
||||
if f.Since != nil {
|
||||
where = append(where, "ordered_at >= $"+itoa(n))
|
||||
args = append(args, *f.Since)
|
||||
n++
|
||||
}
|
||||
clause := strings.Join(where, " AND ")
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM shopify_orders WHERE `+clause, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, external_id, status, currency, total::text, customer_email, customer_name, ordered_at
|
||||
FROM shopify_orders
|
||||
WHERE `+clause+`
|
||||
ORDER BY ordered_at DESC NULLS LAST, external_id DESC
|
||||
LIMIT $`+itoa(n)+` OFFSET $`+itoa(n+1), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]OrderRow, 0)
|
||||
for rows.Next() {
|
||||
var row OrderRow
|
||||
if err := rows.Scan(&row.ID, &row.ExternalID, &row.Status, &row.Currency, &row.Total, &row.CustomerEmail, &row.CustomerName, &row.OrderedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
metaNamespace = "descrybe"
|
||||
metaProductKey = "product_id"
|
||||
)
|
||||
|
||||
// SyncSummary summarizes a product sync attempt.
|
||||
type SyncSummary struct {
|
||||
Total int `json:"total"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
type syncProductRow struct {
|
||||
ID uuid.UUID
|
||||
ProductID *string
|
||||
Name *string
|
||||
Category *string
|
||||
Description *string
|
||||
ProcessedName *string
|
||||
ProcessedDescription *string
|
||||
MetaDescription *string
|
||||
Attributes []byte
|
||||
ProcessedAttributes []byte
|
||||
GTIN *string
|
||||
MappedData []byte
|
||||
}
|
||||
|
||||
func (s *Service) loadProductsForSync(ctx context.Context, companyID uuid.UUID, opt SyncOptions) ([]syncProductRow, error) {
|
||||
limit := opt.SyncLimit
|
||||
if limit <= 0 || limit > maxSyncLimit {
|
||||
limit = defaultSyncLimit
|
||||
}
|
||||
args := []any{companyID}
|
||||
where := []string{"p.company_id = $1"}
|
||||
if status := strings.TrimSpace(opt.SyncFilterStatus); status != "" {
|
||||
if status == "needs_review" {
|
||||
where = append(where, "p.status IN ('needs_review', 'processed')")
|
||||
} else {
|
||||
args = append(args, status)
|
||||
where = append(where, fmt.Sprintf("p.status = $%d", len(args)))
|
||||
}
|
||||
}
|
||||
if cat := strings.TrimSpace(opt.SyncFilterCategory); cat != "" {
|
||||
args = append(args, cat)
|
||||
n := len(args)
|
||||
where = append(where, fmt.Sprintf("(p.category = $%d OR lower(p.category) = lower($%d))", n, n))
|
||||
}
|
||||
if len(opt.SyncOnlyIDs) > 0 {
|
||||
ids := make([]uuid.UUID, 0, len(opt.SyncOnlyIDs))
|
||||
for _, raw := range opt.SyncOnlyIDs {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
args = append(args, ids)
|
||||
where = append(where, fmt.Sprintf("p.id = ANY($%d::uuid[])", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
limN := len(args)
|
||||
q := fmt.Sprintf(`
|
||||
SELECT p.id, p.product_id, p.name, p.category, p.description, p.processed_name, p.processed_description,
|
||||
p.meta_description, p.attributes, p.processed_attributes, r.gtin, r.mapped_data
|
||||
FROM processed_products p
|
||||
LEFT JOIN raw_products r ON r.id = p.raw_product_id AND r.company_id = p.company_id
|
||||
WHERE %s
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT $%d`, strings.Join(where, " AND "), limN)
|
||||
rows, err := s.Pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]syncProductRow, 0)
|
||||
for rows.Next() {
|
||||
var row syncProductRow
|
||||
if err := rows.Scan(
|
||||
&row.ID, &row.ProductID, &row.Name, &row.Category, &row.Description,
|
||||
&row.ProcessedName, &row.ProcessedDescription, &row.MetaDescription,
|
||||
&row.Attributes, &row.ProcessedAttributes, &row.GTIN, &row.MappedData,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*s)
|
||||
}
|
||||
|
||||
func mappedString(mapped []byte, keys ...string) string {
|
||||
if len(mapped) == 0 {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(mapped, &m) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
s := strings.TrimSpace(fmt.Sprint(t))
|
||||
if s != "" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mappedImages(mapped []byte) []ProductImage {
|
||||
if len(mapped) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(mapped, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m["images"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]ProductImage, 0)
|
||||
switch t := raw.(type) {
|
||||
case []any:
|
||||
for _, item := range t {
|
||||
switch u := item.(type) {
|
||||
case string:
|
||||
if u != "" {
|
||||
out = append(out, ProductImage{Src: u})
|
||||
}
|
||||
case map[string]any:
|
||||
if src, ok := u["src"].(string); ok && src != "" {
|
||||
out = append(out, ProductImage{Src: src})
|
||||
}
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if t != "" {
|
||||
out = append(out, ProductImage{Src: t})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (row syncProductRow) toPayload() ProductPayload {
|
||||
title := firstNonEmpty(deref(row.ProcessedName), deref(row.Name), "Product")
|
||||
body := firstNonEmpty(deref(row.ProcessedDescription), deref(row.Description))
|
||||
sku := firstNonEmpty(mappedString(row.MappedData, "sku", "SKU"), deref(row.ProductID), row.ID.String())
|
||||
price := mappedString(row.MappedData, "price", "regular_price")
|
||||
if price == "" {
|
||||
price = "0.00"
|
||||
}
|
||||
ean := firstNonEmpty(mappedString(row.MappedData, "ean", "gtin", "EAN"), deref(row.GTIN))
|
||||
productType := deref(row.Category)
|
||||
|
||||
return ProductPayload{
|
||||
Title: title,
|
||||
BodyHTML: body,
|
||||
ProductType: productType,
|
||||
Status: "active",
|
||||
Tags: "descrybe",
|
||||
Variants: []ProductVariant{{
|
||||
SKU: sku,
|
||||
Price: price,
|
||||
Barcode: ean,
|
||||
}},
|
||||
Images: mappedImages(row.MappedData),
|
||||
Metafields: []Metafield{{
|
||||
Namespace: metaNamespace,
|
||||
Key: metaProductKey,
|
||||
Value: row.ID.String(),
|
||||
Type: "single_line_text_field",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// SyncCompany pushes processed products to Shopify (create/update by cached id or SKU).
|
||||
// SKU resolution is batched via GraphQL; create/update remain per-product (Shopify Admin REST).
|
||||
func (s *Service) SyncCompany(ctx context.Context, companyID uuid.UUID) (SyncSummary, error) {
|
||||
client, _, opt, err := s.clientFor(ctx, companyID)
|
||||
if err != nil {
|
||||
return SyncSummary{}, err
|
||||
}
|
||||
rows, err := s.loadProductsForSync(ctx, companyID, opt)
|
||||
if err != nil {
|
||||
return SyncSummary{}, err
|
||||
}
|
||||
summary := SyncSummary{Total: len(rows), DryRun: client.DryRun}
|
||||
|
||||
type pending struct {
|
||||
key string
|
||||
sku string
|
||||
payload ProductPayload
|
||||
}
|
||||
known := make([]pending, 0, len(rows))
|
||||
needSKU := make([]pending, 0)
|
||||
skus := make([]string, 0)
|
||||
|
||||
for _, row := range rows {
|
||||
payload := row.toPayload()
|
||||
key := row.ID.String()
|
||||
sku := ""
|
||||
if len(payload.Variants) > 0 {
|
||||
sku = payload.Variants[0].SKU
|
||||
}
|
||||
item := pending{key: key, sku: sku, payload: payload}
|
||||
if opt.ProductIDs[key] > 0 {
|
||||
known = append(known, item)
|
||||
continue
|
||||
}
|
||||
if opt.MatchStrategy == "sku" && sku != "" {
|
||||
needSKU = append(needSKU, item)
|
||||
skus = append(skus, sku)
|
||||
continue
|
||||
}
|
||||
// No cached id and no SKU match → create.
|
||||
known = append(known, item)
|
||||
}
|
||||
|
||||
foundBySKU := map[string]Product{}
|
||||
if len(skus) > 0 {
|
||||
var lookupErr error
|
||||
foundBySKU, lookupErr = client.FindProductsBySKUs(ctx, skus)
|
||||
if lookupErr != nil {
|
||||
summary.Failed += len(needSKU)
|
||||
needSKU = nil
|
||||
}
|
||||
}
|
||||
|
||||
pushOne := func(item pending, existingID int64) {
|
||||
var result *Product
|
||||
var pushErr error
|
||||
if existingID > 0 {
|
||||
result, pushErr = client.UpdateProduct(ctx, existingID, item.payload)
|
||||
if pushErr == nil {
|
||||
summary.Updated++
|
||||
}
|
||||
} else {
|
||||
result, pushErr = client.CreateProduct(ctx, item.payload)
|
||||
if pushErr == nil {
|
||||
summary.Created++
|
||||
}
|
||||
}
|
||||
if pushErr != nil {
|
||||
summary.Failed++
|
||||
return
|
||||
}
|
||||
if result != nil && result.ID > 0 {
|
||||
opt.ProductIDs[item.key] = result.ID
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range known {
|
||||
pushOne(item, opt.ProductIDs[item.key])
|
||||
}
|
||||
for _, item := range needSKU {
|
||||
if p, ok := foundBySKU[item.sku]; ok && p.ID > 0 {
|
||||
pushOne(item, p.ID)
|
||||
continue
|
||||
}
|
||||
pushOne(item, 0)
|
||||
}
|
||||
|
||||
if summary.Failed == 0 {
|
||||
opt.LastSyncStatus = "success"
|
||||
opt.LastSyncError = ""
|
||||
} else if summary.Created+summary.Updated == 0 {
|
||||
opt.LastSyncStatus = "failed"
|
||||
opt.LastSyncError = "all product pushes failed"
|
||||
} else {
|
||||
opt.LastSyncStatus = "partial"
|
||||
opt.LastSyncError = "some product pushes failed"
|
||||
}
|
||||
sumCopy := summary
|
||||
opt.LastSyncSummary = &sumCopy
|
||||
opt.PendingSync = false
|
||||
clearOneShotSyncFilters(&opt)
|
||||
pruneStringInt64Map(opt.ProductIDs, maxProductIDMap)
|
||||
raw, err := json.Marshal(opt)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs
|
||||
SET sync_options = $2, last_synced_at = now(), updated_at = now()
|
||||
WHERE company_id = $1`, companyID, raw)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("shopify not configured")
|
||||
ErrNotEnabled = errors.New("shopify sync is disabled")
|
||||
ErrMissingCreds = errors.New("shopify credentials missing")
|
||||
ErrInvalidSyncScope = errors.New("invalid product sync scope")
|
||||
ErrInvalidScheduleInterval = errors.New("schedule_interval_hours must be between 0 and 168")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Key []byte
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ShopDomain string `json:"shop_domain"`
|
||||
APIVersion string `json:"api_version"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
|
||||
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||||
LastTestStatus *string `json:"last_test_status,omitempty"`
|
||||
HasCredentials bool `json:"has_credentials"`
|
||||
HasClientCredentials bool `json:"has_client_credentials"`
|
||||
AuthMode string `json:"auth_mode,omitempty"`
|
||||
PendingSync bool `json:"pending_sync"`
|
||||
PendingOrdersSync bool `json:"pending_orders_sync"`
|
||||
MatchStrategy string `json:"match_strategy"`
|
||||
LastSyncStatus string `json:"last_sync_status,omitempty"`
|
||||
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||
LastSyncSummary *SyncSummary `json:"last_sync_summary,omitempty"`
|
||||
ProductMapCount int `json:"product_map_count"`
|
||||
SyncLimit int `json:"sync_limit,omitempty"`
|
||||
LastOrdersSyncedAt *time.Time `json:"last_orders_synced_at,omitempty"`
|
||||
LastOrdersSyncStatus string `json:"last_orders_sync_status,omitempty"`
|
||||
LastOrdersSyncError string `json:"last_orders_sync_error,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
ReviewsSupported bool `json:"reviews_supported"`
|
||||
ScheduleIntervalHours int `json:"schedule_interval_hours"`
|
||||
SchedulePaused bool `json:"schedule_paused"`
|
||||
}
|
||||
|
||||
// UpdateInput is the PUT /api/shopify body (secrets never echoed back).
|
||||
type UpdateInput struct {
|
||||
ShopDomain string
|
||||
AccessToken string
|
||||
APIVersion string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
IsEnabled bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, key []byte) *Service {
|
||||
return &Service{
|
||||
Pool: pool,
|
||||
Key: key,
|
||||
// Dial-time SSRF; no loopback (Admin API is always public *.myshopify.com).
|
||||
HTTPClient: security.SafeHTTPClient(defaultTimeout, false),
|
||||
}
|
||||
}
|
||||
|
||||
type storedConfig struct {
|
||||
shopDomain, tokenEnc, apiVersion string
|
||||
enabled bool
|
||||
syncOptions []byte
|
||||
lastSync, lastTest *time.Time
|
||||
status *string
|
||||
}
|
||||
|
||||
func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (storedConfig, error) {
|
||||
var sc storedConfig
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT shop_domain, access_token, api_version, is_enabled, sync_options, last_synced_at, last_test_at, last_test_status
|
||||
FROM shopify_configs WHERE company_id = $1`, companyID).Scan(
|
||||
&sc.shopDomain, &sc.tokenEnc, &sc.apiVersion, &sc.enabled, &sc.syncOptions, &sc.lastSync, &sc.lastTest, &sc.status,
|
||||
)
|
||||
return sc, err
|
||||
}
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (Config, error) {
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Config{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
token, _ := DecryptSecret(s.Key, sc.tokenEnc)
|
||||
apiVersion := normalizeAPIVersion(sc.apiVersion)
|
||||
hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != ""
|
||||
authMode := strings.TrimSpace(opt.AuthMode)
|
||||
if authMode == "" && hasClientCreds {
|
||||
authMode = authModeClientCredentials
|
||||
}
|
||||
if authMode == "" && token != "" {
|
||||
authMode = authModeLegacyToken
|
||||
}
|
||||
return Config{
|
||||
ShopDomain: sc.shopDomain,
|
||||
APIVersion: apiVersion,
|
||||
IsEnabled: sc.enabled,
|
||||
Configured: true,
|
||||
LastSyncedAt: sc.lastSync,
|
||||
LastTestAt: sc.lastTest,
|
||||
LastTestStatus: sc.status,
|
||||
HasCredentials: token != "" || hasClientCreds,
|
||||
HasClientCredentials: hasClientCreds,
|
||||
AuthMode: authMode,
|
||||
PendingSync: opt.PendingSync,
|
||||
PendingOrdersSync: opt.PendingOrdersSync,
|
||||
MatchStrategy: opt.MatchStrategy,
|
||||
LastSyncStatus: opt.LastSyncStatus,
|
||||
LastSyncError: opt.LastSyncError,
|
||||
LastSyncSummary: opt.LastSyncSummary,
|
||||
ProductMapCount: len(opt.ProductIDs),
|
||||
LastOrdersSyncedAt: opt.LastOrdersSyncedAt,
|
||||
LastOrdersSyncStatus: opt.LastOrdersSyncStatus,
|
||||
LastOrdersSyncError: opt.LastOrdersSyncError,
|
||||
DryRun: opt.DryRun || strings.EqualFold(token, dryRunToken),
|
||||
ReviewsSupported: false,
|
||||
ScheduleIntervalHours: opt.ScheduleIntervalHours,
|
||||
SchedulePaused: opt.SchedulePaused,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (Config, error) {
|
||||
normalized, err := NormalizeShopDomain(in.ShopDomain)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
apiVersion := normalizeAPIVersion(in.APIVersion)
|
||||
clientID := strings.TrimSpace(in.ClientID)
|
||||
clientSecret := strings.TrimSpace(in.ClientSecret)
|
||||
accessToken := strings.TrimSpace(in.AccessToken)
|
||||
|
||||
if (clientID == "") != (clientSecret == "") {
|
||||
return Config{}, ErrInvalidClientCredentials
|
||||
}
|
||||
|
||||
sc, loadErr := s.loadStored(ctx, companyID)
|
||||
opt := SyncOptions{}
|
||||
if loadErr == nil {
|
||||
opt = parseSyncOptions(sc.syncOptions)
|
||||
} else if !errors.Is(loadErr, pgx.ErrNoRows) {
|
||||
return Config{}, loadErr
|
||||
}
|
||||
opt.DryRun = in.DryRun
|
||||
|
||||
tokenEnc := ""
|
||||
switch {
|
||||
case clientID != "" && clientSecret != "":
|
||||
secretEnc, encErr := EncryptSecret(s.Key, clientSecret)
|
||||
if encErr != nil {
|
||||
return Config{}, encErr
|
||||
}
|
||||
opt.AuthMode = authModeClientCredentials
|
||||
opt.ClientID = clientID
|
||||
opt.ClientSecretEnc = secretEnc
|
||||
if in.DryRun || strings.EqualFold(accessToken, dryRunToken) {
|
||||
tokenEnc, err = EncryptSecret(s.Key, dryRunToken)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
opt.TokenExpiresAt = nil
|
||||
} else {
|
||||
tok, exErr := ExchangeClientCredentials(ctx, s.HTTPClient, normalized, clientID, clientSecret)
|
||||
if exErr != nil {
|
||||
return Config{}, exErr
|
||||
}
|
||||
tokenEnc, err = EncryptSecret(s.Key, tok.AccessToken)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
exp := tok.ExpiresAt
|
||||
opt.TokenExpiresAt = &exp
|
||||
}
|
||||
case accessToken != "":
|
||||
tokenEnc, err = EncryptSecret(s.Key, accessToken)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
opt.AuthMode = authModeLegacyToken
|
||||
opt.ClientID = ""
|
||||
opt.ClientSecretEnc = ""
|
||||
opt.TokenExpiresAt = nil
|
||||
}
|
||||
|
||||
rawOpt, err := json.Marshal(opt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO shopify_configs (company_id, shop_domain, access_token, api_version, is_enabled, sync_options, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
ON CONFLICT (company_id) DO UPDATE SET
|
||||
shop_domain = EXCLUDED.shop_domain,
|
||||
access_token = CASE WHEN EXCLUDED.access_token <> '' THEN EXCLUDED.access_token ELSE shopify_configs.access_token END,
|
||||
api_version = EXCLUDED.api_version,
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
sync_options = EXCLUDED.sync_options,
|
||||
updated_at = now()`,
|
||||
companyID, normalized, tokenEnc, apiVersion, in.IsEnabled, rawOpt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.GetConfig(ctx, companyID)
|
||||
}
|
||||
|
||||
// UpdateSchedule sets auto product-sync interval hours and pause flag on sync_options.
|
||||
// hours 0 means the worker default (6h). Manual sync remains available when paused.
|
||||
func (s *Service) UpdateSchedule(ctx context.Context, companyID uuid.UUID, hours int, paused bool) (Config, error) {
|
||||
if hours < 0 || hours > maxScheduleIntervalH {
|
||||
return Config{}, ErrInvalidScheduleInterval
|
||||
}
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Config{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
opt.ScheduleIntervalHours = hours
|
||||
opt.SchedulePaused = paused
|
||||
raw, err := json.Marshal(opt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET sync_options = $2, updated_at = now() WHERE company_id = $1`,
|
||||
companyID, raw)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return s.GetConfig(ctx, companyID)
|
||||
}
|
||||
|
||||
func (s *Service) clientFor(ctx context.Context, companyID uuid.UUID) (*Client, storedConfig, SyncOptions, error) {
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, sc, SyncOptions{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return nil, sc, SyncOptions{}, err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
token, err := DecryptSecret(s.Key, sc.tokenEnc)
|
||||
if err != nil {
|
||||
return nil, sc, SyncOptions{}, err
|
||||
}
|
||||
if sc.shopDomain == "" {
|
||||
return nil, sc, SyncOptions{}, ErrMissingCreds
|
||||
}
|
||||
|
||||
hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != ""
|
||||
if token == "" && !hasClientCreds {
|
||||
return nil, sc, SyncOptions{}, ErrMissingCreds
|
||||
}
|
||||
|
||||
if !opt.DryRun && hasClientCreds && (token == "" || strings.EqualFold(token, dryRunToken) || tokenNeedsRefresh(opt.TokenExpiresAt, time.Now().UTC())) {
|
||||
secret, decErr := DecryptSecret(s.Key, opt.ClientSecretEnc)
|
||||
if decErr != nil {
|
||||
return nil, sc, SyncOptions{}, decErr
|
||||
}
|
||||
tok, exErr := ExchangeClientCredentials(ctx, s.HTTPClient, sc.shopDomain, opt.ClientID, secret)
|
||||
if exErr != nil {
|
||||
return nil, sc, SyncOptions{}, exErr
|
||||
}
|
||||
tokenEnc, encErr := EncryptSecret(s.Key, tok.AccessToken)
|
||||
if encErr != nil {
|
||||
return nil, sc, SyncOptions{}, encErr
|
||||
}
|
||||
exp := tok.ExpiresAt
|
||||
opt.AuthMode = authModeClientCredentials
|
||||
opt.TokenExpiresAt = &exp
|
||||
rawOpt, marshalErr := json.Marshal(opt)
|
||||
if marshalErr != nil {
|
||||
return nil, sc, SyncOptions{}, marshalErr
|
||||
}
|
||||
if _, execErr := s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET access_token = $2, sync_options = $3, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, tokenEnc, rawOpt); execErr != nil {
|
||||
return nil, sc, SyncOptions{}, execErr
|
||||
}
|
||||
sc.tokenEnc = tokenEnc
|
||||
sc.syncOptions = rawOpt
|
||||
token = tok.AccessToken
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return nil, sc, SyncOptions{}, ErrMissingCreds
|
||||
}
|
||||
|
||||
client := NewClient(sc.shopDomain, token, sc.apiVersion, s.HTTPClient)
|
||||
if opt.DryRun {
|
||||
client.DryRun = true
|
||||
}
|
||||
return client, sc, opt, nil
|
||||
}
|
||||
|
||||
func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||||
client, _, _, err := s.clientFor(ctx, companyID)
|
||||
status := "ok"
|
||||
message := "connection successful"
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
message = err.Error()
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
return map[string]any{"status": status, "message": message}, err
|
||||
}
|
||||
shop, err := client.TestConnection(ctx)
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
message = "connection failed"
|
||||
_, execErr := s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
if execErr != nil {
|
||||
return map[string]any{"status": status, "message": message}, execErr
|
||||
}
|
||||
return map[string]any{"status": status, "message": message, "dry_run": client.DryRun}, err
|
||||
}
|
||||
if _, err := s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status); err != nil {
|
||||
return map[string]any{"status": status, "message": message}, err
|
||||
}
|
||||
out := map[string]any{"status": status, "message": message, "dry_run": client.DryRun}
|
||||
if shop != nil {
|
||||
out["shop_name"] = shop.Name
|
||||
out["shop_domain"] = shop.Domain
|
||||
out["currency"] = shop.Currency
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) EnqueueSync(ctx context.Context, companyID uuid.UUID, scopes ...ProductSyncScope) (map[string]any, error) {
|
||||
if err := s.requireEnabledCreds(ctx, companyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
if len(scopes) > 0 {
|
||||
if err := applyProductSyncScope(&opt, scopes[0]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
clearOneShotSyncFilters(&opt)
|
||||
}
|
||||
opt.PendingSync = true
|
||||
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "accepted", "message": "shopify product sync queued"}, nil
|
||||
}
|
||||
|
||||
func (s *Service) EnqueueOrdersSync(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||||
if err := s.requireEnabledCreds(ctx, companyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
opt.PendingOrdersSync = true
|
||||
if err := s.saveSyncOptions(ctx, companyID, opt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "accepted", "message": "shopify orders sync queued"}, nil
|
||||
}
|
||||
|
||||
func (s *Service) requireEnabledCreds(ctx context.Context, companyID uuid.UUID) error {
|
||||
sc, err := s.loadStored(ctx, companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sc.enabled {
|
||||
return ErrNotEnabled
|
||||
}
|
||||
token, err := DecryptSecret(s.Key, sc.tokenEnc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt := parseSyncOptions(sc.syncOptions)
|
||||
hasClientCreds := opt.ClientID != "" && opt.ClientSecretEnc != ""
|
||||
if sc.shopDomain == "" || (token == "" && !hasClientCreds) {
|
||||
return ErrMissingCreds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) saveSyncOptions(ctx context.Context, companyID uuid.UUID, opt SyncOptions) error {
|
||||
pruneStringInt64Map(opt.ProductIDs, maxProductIDMap)
|
||||
raw, err := json.Marshal(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE shopify_configs SET sync_options = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClaimNextPendingJob claims the next pending Shopify sync (products or orders).
|
||||
func (s *Service) ClaimNextPendingJob(ctx context.Context) (uuid.UUID, string, error) {
|
||||
var companyID uuid.UUID
|
||||
var kind string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
WITH candidate AS (
|
||||
SELECT company_id,
|
||||
CASE
|
||||
WHEN COALESCE(sync_options->>'pending_sync', 'false') = 'true' THEN 'products'
|
||||
WHEN COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true' THEN 'orders'
|
||||
ELSE ''
|
||||
END AS kind
|
||||
FROM shopify_configs
|
||||
WHERE is_enabled = true
|
||||
AND (
|
||||
COALESCE(sync_options->>'pending_sync', 'false') = 'true'
|
||||
OR COALESCE(sync_options->>'pending_orders_sync', 'false') = 'true'
|
||||
)
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE shopify_configs c
|
||||
SET sync_options = CASE candidate.kind
|
||||
WHEN 'products' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_sync}', 'false'::jsonb, true)
|
||||
WHEN 'orders' THEN jsonb_set(COALESCE(c.sync_options, '{}'::jsonb), '{pending_orders_sync}', 'false'::jsonb, true)
|
||||
ELSE c.sync_options
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM candidate
|
||||
WHERE c.company_id = candidate.company_id AND candidate.kind <> ''
|
||||
RETURNING c.company_id, candidate.kind`).Scan(&companyID, &kind)
|
||||
return companyID, kind, err
|
||||
}
|
||||
|
||||
// EnqueueDueScheduled marks enabled Shopify configs pending when last_synced_at is stale.
|
||||
func (s *Service) EnqueueDueScheduled(ctx context.Context, defaultInterval time.Duration) (int, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT company_id, sync_options, last_synced_at
|
||||
FROM shopify_configs
|
||||
WHERE is_enabled = true
|
||||
AND COALESCE(sync_options->>'pending_sync', 'false') <> 'true'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
n := 0
|
||||
now := time.Now().UTC()
|
||||
for rows.Next() {
|
||||
var companyID uuid.UUID
|
||||
var raw []byte
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&companyID, &raw, &last); err != nil {
|
||||
return n, err
|
||||
}
|
||||
opt := parseSyncOptions(raw)
|
||||
if !shouldEnqueueScheduled(opt.SchedulePaused, opt.ScheduleIntervalHours, last, now, defaultInterval) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.EnqueueSync(ctx, companyID); err != nil {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBatchSize = 25
|
||||
defaultSyncLimit = 200
|
||||
maxBatchSize = 100
|
||||
maxSyncLimit = 1000
|
||||
maxOrdersSyncLimit = 5000
|
||||
maxProductIDMap = 5000
|
||||
maxScheduleIntervalH = 168 // 7 days
|
||||
)
|
||||
|
||||
// SyncOptions is stored as JSON on shopify_configs.sync_options.
|
||||
type SyncOptions struct {
|
||||
ProductIDs map[string]int64 `json:"product_ids"`
|
||||
MatchStrategy string `json:"match_strategy"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
SyncLimit int `json:"sync_limit"`
|
||||
PendingSync bool `json:"pending_sync"`
|
||||
PendingOrdersSync bool `json:"pending_orders_sync"`
|
||||
LastSyncStatus string `json:"last_sync_status"`
|
||||
LastSyncError string `json:"last_sync_error"`
|
||||
LastSyncSummary *SyncSummary `json:"last_sync_summary,omitempty"`
|
||||
LastOrdersSyncStatus string `json:"last_orders_sync_status"`
|
||||
LastOrdersSyncError string `json:"last_orders_sync_error"`
|
||||
LastOrdersSyncedAt *time.Time `json:"last_orders_synced_at,omitempty"`
|
||||
OrdersSyncLimit int `json:"orders_sync_limit"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
ScheduleIntervalHours int `json:"schedule_interval_hours"`
|
||||
SchedulePaused bool `json:"schedule_paused"`
|
||||
// One-shot selection for the next product sync (cleared when sync finishes).
|
||||
SyncFilterStatus string `json:"sync_filter_status,omitempty"`
|
||||
SyncFilterCategory string `json:"sync_filter_category,omitempty"`
|
||||
SyncOnlyIDs []string `json:"sync_only_ids,omitempty"`
|
||||
|
||||
// Auth (Dev Dashboard client_credentials). Legacy permanent tokens leave these empty.
|
||||
AuthMode string `json:"auth_mode,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecretEnc string `json:"client_secret_enc,omitempty"`
|
||||
TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func parseSyncOptions(raw []byte) SyncOptions {
|
||||
opt := SyncOptions{
|
||||
ProductIDs: map[string]int64{},
|
||||
MatchStrategy: "sku",
|
||||
BatchSize: defaultBatchSize,
|
||||
SyncLimit: defaultSyncLimit,
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return opt
|
||||
}
|
||||
_ = json.Unmarshal(raw, &opt)
|
||||
if opt.ProductIDs == nil {
|
||||
opt.ProductIDs = map[string]int64{}
|
||||
}
|
||||
pruneStringInt64Map(opt.ProductIDs, maxProductIDMap)
|
||||
if opt.MatchStrategy == "" {
|
||||
opt.MatchStrategy = "sku"
|
||||
}
|
||||
if opt.BatchSize <= 0 || opt.BatchSize > maxBatchSize {
|
||||
opt.BatchSize = defaultBatchSize
|
||||
}
|
||||
if opt.SyncLimit <= 0 || opt.SyncLimit > maxSyncLimit {
|
||||
opt.SyncLimit = defaultSyncLimit
|
||||
}
|
||||
if opt.OrdersSyncLimit < 0 || opt.OrdersSyncLimit > maxOrdersSyncLimit {
|
||||
opt.OrdersSyncLimit = 0
|
||||
}
|
||||
if opt.ScheduleIntervalHours < 0 || opt.ScheduleIntervalHours > maxScheduleIntervalH {
|
||||
opt.ScheduleIntervalHours = 0
|
||||
}
|
||||
opt.SyncOnlyIDs = pruneSyncOnlyIDs(opt.SyncOnlyIDs, maxSyncOnlyIDs)
|
||||
return opt
|
||||
}
|
||||
|
||||
// resolveScheduleInterval maps schedule_interval_hours to a duration (default when hours<=0).
|
||||
func resolveScheduleInterval(hours int, defaultInterval time.Duration) time.Duration {
|
||||
if defaultInterval <= 0 {
|
||||
defaultInterval = 6 * time.Hour
|
||||
}
|
||||
if hours > 0 {
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
return defaultInterval
|
||||
}
|
||||
|
||||
// isDueForSchedule reports whether last sync is missing or older than interval.
|
||||
func isDueForSchedule(last *time.Time, now time.Time, interval time.Duration) bool {
|
||||
if last == nil {
|
||||
return true
|
||||
}
|
||||
return now.Sub(last.UTC()) >= interval
|
||||
}
|
||||
|
||||
// shouldEnqueueScheduled reports whether auto product sync should run now.
|
||||
// Paused schedules never enqueue; interval 0 uses defaultInterval (worker default 6h).
|
||||
func shouldEnqueueScheduled(paused bool, hours int, last *time.Time, now time.Time, defaultInterval time.Duration) bool {
|
||||
if paused {
|
||||
return false
|
||||
}
|
||||
return isDueForSchedule(last, now, resolveScheduleInterval(hours, defaultInterval))
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pruneStringInt64Map(m map[string]int64, max int) {
|
||||
if max <= 0 || len(m) <= max {
|
||||
return
|
||||
}
|
||||
n := len(m) - max
|
||||
for k := range m {
|
||||
delete(m, k)
|
||||
n--
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShopifySKUSearchQueryOR(t *testing.T) {
|
||||
got := shopifySKUSearchQueryOR([]string{"A", "B\"C", ""})
|
||||
want := `sku:"A" OR sku:"B\"C"`
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProductsBySKUsBatchesOneGraphQLCall(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/admin/api/2024-10/graphql.json" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
calls.Add(1)
|
||||
var body struct {
|
||||
Variables struct {
|
||||
Q string `json:"q"`
|
||||
N int `json:"n"`
|
||||
} `json:"variables"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Variables.N != 3 {
|
||||
t.Fatalf("expected n=3 got %d", body.Variables.N)
|
||||
}
|
||||
if !strings.Contains(body.Variables.Q, `sku:"SKU-1"`) || !strings.Contains(body.Variables.Q, `sku:"SKU-2"`) {
|
||||
t.Fatalf("query missing SKUs: %q", body.Variables.Q)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{
|
||||
"data": {
|
||||
"productVariants": {
|
||||
"edges": [
|
||||
{"node":{"sku":"SKU-1","product":{"id":"gid://shopify/Product/101","title":"One"}}},
|
||||
{"node":{"sku":"SKU-2","product":{"id":"gid://shopify/Product/102","title":"Two"}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("demo.myshopify.com", "tok", "2024-10", srv.Client())
|
||||
c.HTTP = rewriteShopifyHost(srv, c.HTTP)
|
||||
|
||||
found, err := c.FindProductsBySKUs(t.Context(), []string{"SKU-1", "SKU-2", "SKU-MISSING"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("expected 1 GraphQL call, got %d", calls.Load())
|
||||
}
|
||||
if found["SKU-1"].ID != 101 || found["SKU-2"].ID != 102 {
|
||||
t.Fatalf("unexpected map: %+v", found)
|
||||
}
|
||||
if _, ok := found["SKU-MISSING"]; ok {
|
||||
t.Fatal("missing SKU should be absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopifyRESTRetries429(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n == 1 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, `{"errors":"throttle"}`)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"shop":{"id":1,"name":"Demo","domain":"demo.myshopify.com","currency":"USD"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient("demo.myshopify.com", "tok", "2024-10", srv.Client())
|
||||
c.HTTP = rewriteShopifyHost(srv, c.HTTP)
|
||||
|
||||
start := time.Now()
|
||||
shop, err := c.TestConnection(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shop == nil || shop.Name != "Demo" {
|
||||
t.Fatalf("unexpected shop %+v", shop)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", calls.Load())
|
||||
}
|
||||
if time.Since(start) > 3*time.Second {
|
||||
t.Fatal("retry waited too long")
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteShopifyHost routes myshopify Admin API calls to the test server.
|
||||
func rewriteShopifyHost(srv *httptest.Server, base *http.Client) *http.Client {
|
||||
rt := base.Transport
|
||||
if rt == nil {
|
||||
rt = http.DefaultTransport
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
u := *req.URL
|
||||
su, _ := http.NewRequest(req.Method, srv.URL+u.Path, req.Body)
|
||||
su.URL.RawQuery = u.RawQuery
|
||||
su.Header = req.Header.Clone()
|
||||
su = su.WithContext(req.Context())
|
||||
return rt.RoundTrip(su)
|
||||
}),
|
||||
Timeout: base.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
@@ -0,0 +1,97 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSyncOnlyIDs = 500
|
||||
maxCategoryFilterLen = 200
|
||||
)
|
||||
|
||||
// ProductSyncScope is an optional one-shot filter for EnqueueSync / POST /sync.
|
||||
// Empty fields mean "no filter" (sync recent processed products up to SyncLimit).
|
||||
type ProductSyncScope struct {
|
||||
Limit int `json:"sync_limit,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
ProductIDs []string `json:"product_ids,omitempty"`
|
||||
}
|
||||
|
||||
func clearOneShotSyncFilters(opt *SyncOptions) {
|
||||
if opt == nil {
|
||||
return
|
||||
}
|
||||
opt.SyncFilterStatus = ""
|
||||
opt.SyncFilterCategory = ""
|
||||
opt.SyncOnlyIDs = nil
|
||||
}
|
||||
|
||||
func normalizeSyncFilterStatus(status string) (string, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(status))
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch s {
|
||||
case "completed", "needs_review", "error", "processing", "processed":
|
||||
return s, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid status", ErrInvalidSyncScope)
|
||||
}
|
||||
}
|
||||
|
||||
func applyProductSyncScope(opt *SyncOptions, scope ProductSyncScope) error {
|
||||
if opt == nil {
|
||||
return fmt.Errorf("%w: missing options", ErrInvalidSyncScope)
|
||||
}
|
||||
clearOneShotSyncFilters(opt)
|
||||
if scope.Limit > 0 {
|
||||
if scope.Limit > maxSyncLimit {
|
||||
return fmt.Errorf("%w: sync_limit max %d", ErrInvalidSyncScope, maxSyncLimit)
|
||||
}
|
||||
opt.SyncLimit = scope.Limit
|
||||
}
|
||||
status, err := normalizeSyncFilterStatus(scope.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt.SyncFilterStatus = status
|
||||
cat := strings.TrimSpace(scope.Category)
|
||||
if len(cat) > maxCategoryFilterLen {
|
||||
return fmt.Errorf("%w: category too long", ErrInvalidSyncScope)
|
||||
}
|
||||
opt.SyncFilterCategory = cat
|
||||
if len(scope.ProductIDs) > maxSyncOnlyIDs {
|
||||
return fmt.Errorf("%w: product_ids max %d", ErrInvalidSyncScope, maxSyncOnlyIDs)
|
||||
}
|
||||
cleaned := make([]string, 0, len(scope.ProductIDs))
|
||||
seen := make(map[string]struct{}, len(scope.ProductIDs))
|
||||
for _, raw := range scope.ProductIDs {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invalid product_ids", ErrInvalidSyncScope)
|
||||
}
|
||||
key := parsed.String()
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
cleaned = append(cleaned, key)
|
||||
}
|
||||
opt.SyncOnlyIDs = cleaned
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneSyncOnlyIDs(ids []string, max int) []string {
|
||||
if max <= 0 || len(ids) <= max {
|
||||
return ids
|
||||
}
|
||||
return ids[:max]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package shopify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestApplyProductSyncScope(t *testing.T) {
|
||||
opt := SyncOptions{SyncLimit: defaultSyncLimit}
|
||||
id := uuid.New().String()
|
||||
err := applyProductSyncScope(&opt, ProductSyncScope{
|
||||
Limit: 50,
|
||||
Status: "Completed",
|
||||
Category: " Lamps ",
|
||||
ProductIDs: []string{id, id, " "},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opt.SyncLimit != 50 {
|
||||
t.Fatalf("limit=%d", opt.SyncLimit)
|
||||
}
|
||||
if opt.SyncFilterStatus != "completed" {
|
||||
t.Fatalf("status=%q", opt.SyncFilterStatus)
|
||||
}
|
||||
if opt.SyncFilterCategory != "Lamps" {
|
||||
t.Fatalf("category=%q", opt.SyncFilterCategory)
|
||||
}
|
||||
if len(opt.SyncOnlyIDs) != 1 || opt.SyncOnlyIDs[0] != id {
|
||||
t.Fatalf("ids=%v", opt.SyncOnlyIDs)
|
||||
}
|
||||
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{Status: "nope"})
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("want ErrInvalidSyncScope, got %v", err)
|
||||
}
|
||||
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{Limit: maxSyncLimit + 1})
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("want ErrInvalidSyncScope for limit, got %v", err)
|
||||
}
|
||||
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{ProductIDs: []string{"not-a-uuid"}})
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("want ErrInvalidSyncScope for ids, got %v", err)
|
||||
}
|
||||
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{Category: strings.Repeat("c", maxCategoryFilterLen+1)})
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("want ErrInvalidSyncScope for category length, got %v", err)
|
||||
}
|
||||
|
||||
tooMany := make([]string, maxSyncOnlyIDs+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = uuid.New().String()
|
||||
}
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{ProductIDs: tooMany})
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("want ErrInvalidSyncScope for product_ids max, got %v", err)
|
||||
}
|
||||
|
||||
err = applyProductSyncScope(&opt, ProductSyncScope{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opt.SyncFilterStatus != "" || opt.SyncFilterCategory != "" || len(opt.SyncOnlyIDs) != 0 {
|
||||
t.Fatalf("empty scope should clear one-shot filters: %#v", opt)
|
||||
}
|
||||
if opt.SyncLimit != 50 {
|
||||
t.Fatalf("empty scope should keep prior sync_limit preference; got %d", opt.SyncLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSyncFilterStatus(t *testing.T) {
|
||||
got, err := normalizeSyncFilterStatus(" needs_review ")
|
||||
if err != nil || got != "needs_review" {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
_, err = normalizeSyncFilterStatus("draft")
|
||||
if !errors.Is(err, ErrInvalidSyncScope) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid status") {
|
||||
t.Fatalf("msg=%q", err.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user