fix
This commit is contained in:
@@ -27,6 +27,7 @@ func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
|
||||
body string
|
||||
}{
|
||||
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
|
||||
{name: "update_api_key", fn: s.handleUpdateAPIKey, body: `{"name":"renamed"}`},
|
||||
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
|
||||
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
|
||||
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
|
||||
|
||||
@@ -29,11 +29,13 @@ const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
|
||||
//
|
||||
// Body (JSON): confirm=true required; backfill_categories (default true);
|
||||
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
|
||||
// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
|
||||
// base64 of wp_product_categories.sql (primary for category prompts).
|
||||
// skip_dump_backfill (default false). Category prompts load from repo seed
|
||||
// (scripts/seed/wp_product_categories.sql) via ResolveWPCategoryPromptsPath;
|
||||
// wp_product_categories_sql_b64 remains accepted but unused by the admin UI.
|
||||
//
|
||||
// Body (multipart/form-data): same fields as form values; file field
|
||||
// wp_product_categories or wp_categories_sql for the SQL dump upload.
|
||||
// Body (multipart/form-data): same fields as form values; optional file field
|
||||
// wp_product_categories / wp_categories_sql is still accepted for API clients
|
||||
// but the admin Sync A1 UI no longer uploads.
|
||||
//
|
||||
// Flash (UI): result → flash.admin.syncA1Success.
|
||||
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -116,8 +118,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
if len(parsed.WPCategoriesSQL) > 0 {
|
||||
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
|
||||
} else if strings.TrimSpace(result.WPCategoriesPath) == "" {
|
||||
note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
|
||||
} else if strings.TrimSpace(result.WPCategoriesPath) != "" {
|
||||
note = "Applied category prompts from repo seed (" + result.WPCategoriesPath + ") + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
|
||||
} else {
|
||||
note += " Place scripts/seed/wp_product_categories.sql on the API host (or set SEED_A1_WP_CATEGORIES) for force-applied category prompts."
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -80,6 +83,61 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "settings.api_keys", "capability.api_access") {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
Error(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
if len(name) > 120 {
|
||||
Error(w, http.StatusBadRequest, "name too long")
|
||||
return
|
||||
}
|
||||
var (
|
||||
outID uuid.UUID
|
||||
outName string
|
||||
prefix string
|
||||
lastUsed any
|
||||
created any
|
||||
)
|
||||
err = s.Pool.QueryRow(r.Context(), `
|
||||
UPDATE api_keys
|
||||
SET name = $3, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2 AND revoked_at IS NULL
|
||||
RETURNING id, name, key_prefix, last_used_at, created_at`,
|
||||
id, cid, name).Scan(&outID, &outName, &prefix, &lastUsed, &created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"id": outID, "name": outName, "key_prefix": prefix, "last_used_at": lastUsed, "created_at": created,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleUpdateAPIKeyRejectsEmptyName(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
keyID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", keyID.String())
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/"+keyID.String(), bytes.NewBufferString(`{"name":" "}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUpdateAPIKey(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateAPIKeyRejectsInvalidID(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "not-a-uuid")
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/api-keys/not-a-uuid", bytes.NewBufferString(`{"name":"ok"}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUpdateAPIKey(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,15 @@ func TestAuthSessionCoreEndpoints(t *testing.T) {
|
||||
t.Fatalf("create api-key payload=%v", created)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/api-keys/"+keyID, `{"name":"auth-smoke-key-renamed"}`, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rename api-key status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
renamed := decode(t, rec)
|
||||
if fmt.Sprint(renamed["name"]) != "auth-smoke-key-renamed" {
|
||||
t.Fatalf("rename api-key name=%v", renamed["name"])
|
||||
}
|
||||
|
||||
// Public v1 with the new key (CSRF skipped).
|
||||
v1 := httptest.NewRecorder()
|
||||
v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil)
|
||||
|
||||
@@ -453,6 +453,7 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Get("/api-keys", s.handleListAPIKeys)
|
||||
r.Post("/api-keys", s.handleCreateAPIKey)
|
||||
r.Patch("/api-keys/{id}", s.handleUpdateAPIKey)
|
||||
r.Delete("/api-keys/{id}", s.handleRevokeAPIKey)
|
||||
|
||||
r.Get("/billing/credits", s.handleCreditsOverview)
|
||||
|
||||
@@ -118,8 +118,9 @@ info:
|
||||
- GET /products data[].id = processed_products.id (enriched row)
|
||||
- GET /products data[].raw_product_id = raw_products.id (use this for raw_product_ids)
|
||||
- POST ... raw_product_ids[] must be raw_products.id — never PresentProduct.id
|
||||
- GET /products/process/{id} COMPLETED items[].id = processed_products.id (legacy);
|
||||
additive processed_product_id (same as id) and raw_product_id (raw_products.id)
|
||||
- GET /products/process/{id} COMPLETED items[] omit internal UUIDs (id /
|
||||
processed_product_id / raw_product_id). Use GET /products when a UUID is needed.
|
||||
Display name is items[].name (title omitted when identical).
|
||||
|
||||
Note: Dashboard JSON under /api/* uses session cookies + CSRF and is separate
|
||||
from this public API-key surface. Other legacy path aliases
|
||||
@@ -895,9 +896,10 @@ paths:
|
||||
Completed jobs return items[] (EAN-keyed enrichment). In-progress and failed
|
||||
jobs omit items. Not the same shape as GET /process/{id} (flat ProcessingJob).
|
||||
|
||||
On COMPLETED items, id is the processed_products UUID (legacy). Additive aliases:
|
||||
processed_product_id (same value as id), raw_product_id (raw_products.id), and
|
||||
name (same value as title) for dual-mode clients / scorecards.
|
||||
On COMPLETED items, name is the product display name (title is omitted when
|
||||
identical). Internal UUIDs (id / processed_product_id / raw_product_id) are
|
||||
omitted — use GET /products for those. A1 cohort / Platform Demo / A1-prompt
|
||||
companies omit meta_title and meta_description even if stored.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -925,17 +927,11 @@ paths:
|
||||
processing_type: full
|
||||
items:
|
||||
- ean: '8606019604493'
|
||||
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
|
||||
status: processed
|
||||
category: Cookers
|
||||
category_id: '50'
|
||||
category_name: Cookers
|
||||
title: VOX electric cooker EHT 6020 WG
|
||||
name: VOX electric cooker EHT 6020 WG
|
||||
meta_title: VOX electric cooker EHT 6020 WG | 50
|
||||
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
|
||||
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven."
|
||||
attributes:
|
||||
brand: Vox
|
||||
@@ -6207,12 +6203,24 @@ components:
|
||||
process_id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
description: |
|
||||
Job lifecycle status on enqueue. Always pending (or processing if the
|
||||
worker already picked it up). Never COMPLETED on start — poll GET
|
||||
/products/process/{id} for completion.
|
||||
example: pending
|
||||
message:
|
||||
type: string
|
||||
total_items:
|
||||
type: integer
|
||||
description: Number of products accepted into the job (enqueue size).
|
||||
processed_items:
|
||||
type: integer
|
||||
description: |
|
||||
Count of items that finished processing. Always 0 on start while the
|
||||
job is pending/processing; increments as the worker completes products.
|
||||
example: 0
|
||||
job_count:
|
||||
type: integer
|
||||
description: Present when StartJob auto-splits
|
||||
@@ -6282,35 +6290,19 @@ components:
|
||||
expose category as the human-readable display name (category_id holds
|
||||
categories.unique_id), a description string that may include category formula
|
||||
HTML (h1/h2/h3/h4, p, ul — never a JSON array), optional SEO meta_title /
|
||||
meta_description (plain text; omitted for A1 cohort), optional eprel object or
|
||||
null, clean attributes, images, and dual-mode ids.
|
||||
Product display name is title; additive name mirrors the same processed title
|
||||
(dual-mode for scorecards / legacy clients that read name).
|
||||
Property order prefers human-readable fields first (ean, title, category,
|
||||
description, attributes, images, eprel) with internal ids last.
|
||||
meta_description (plain text; omitted for A1 cohort, Platform Demo, and any
|
||||
company with A1-style category role-section prompts — even if stored in DB),
|
||||
optional eprel object or null, clean attributes, and images.
|
||||
Product display name is name (primary). title is omitted when identical to name.
|
||||
Internal UUIDs (id, processed_product_id, raw_product_id) are omitted from this
|
||||
public shape — use catalog APIs when a product UUID is required.
|
||||
Property order prefers human-readable fields first (ean, name, category,
|
||||
description, attributes, images, eprel).
|
||||
required:
|
||||
- ean
|
||||
properties:
|
||||
ean:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Legacy field: processed_products.id when enrichment succeeded.
|
||||
Do not treat as raw_products.id. Same value as processed_product_id.
|
||||
processed_product_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
Explicit alias of id (processed_products.id). Prefer this name in new
|
||||
dual-mode clients; id remains for backward compatibility.
|
||||
raw_product_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: |
|
||||
raw_products.id for this job line. Use with POST /process raw_product_ids
|
||||
or dashboard catalog APIs. Present whenever the job product row exists.
|
||||
category:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -6330,32 +6322,32 @@ components:
|
||||
nullable: true
|
||||
description: |
|
||||
Human-readable category display name (mirrors category when both are set).
|
||||
title:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Product display name (processed title). Primary legacy field; same value
|
||||
as name when present.
|
||||
name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Additive alias of title (same processed display name). Prefer title in
|
||||
new clients; name remains for scorecards and legacy readers.
|
||||
Product display name (processed title). Primary field for clients.
|
||||
title:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
Optional legacy alias of name. Omitted when identical to name.
|
||||
meta_title:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
SEO title. Filled from processing meta or synthesized from title / category
|
||||
when empty so successful items are not left with null meta.
|
||||
Omitted for A1 cohort (SEO meta is not used).
|
||||
Omitted for A1 cohort, Platform Demo, and companies with A1-style category
|
||||
prompts (SEO meta is not used).
|
||||
meta_description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: |
|
||||
SEO description (word-safe truncate). Distinct from body description when
|
||||
possible; synthesized from plain description when DB meta is empty.
|
||||
Omitted for A1 cohort (SEO meta is not used).
|
||||
Omitted for A1 cohort, Platform Demo, and companies with A1-style category
|
||||
prompts (SEO meta is not used).
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -6477,17 +6469,11 @@ components:
|
||||
processing_type: full
|
||||
items:
|
||||
- ean: '8606019604493'
|
||||
id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
processed_product_id: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
raw_product_id: cccccccc-cccc-cccc-cccc-cccccccccccc
|
||||
status: processed
|
||||
category: Cookers
|
||||
category_id: '50'
|
||||
category_name: Cookers
|
||||
title: VOX electric cooker EHT 6020 WG
|
||||
name: VOX electric cooker EHT 6020 WG
|
||||
meta_title: VOX electric cooker EHT 6020 WG | 50
|
||||
meta_description: Affordable electric cooker with four plates and a 65 L fan oven.
|
||||
description: "Vox Electronics electric cooker EHT 6020 WG offers strong value with four electric hobs and a 65 L fan oven. Energy class A with practical everyday capacity."
|
||||
attributes:
|
||||
brand: Vox
|
||||
|
||||
@@ -160,11 +160,14 @@ func (s *Server) handleV1StartProcess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
primary := jobs[0]
|
||||
// processed_items is the completed count — stay 0 until the worker finishes.
|
||||
// total_items is the enqueue size; status stays pending/processing until poll COMPLETED.
|
||||
resp := map[string]any{
|
||||
"process_id": primary.ID.String(),
|
||||
"status": "pending",
|
||||
"message": fmt.Sprintf("Processing started for %d product(s)", len(rawIDs)),
|
||||
"total_items": totalItems,
|
||||
"processed_items": len(rawIDs),
|
||||
"processed_items": 0,
|
||||
}
|
||||
if len(jobs) > 1 {
|
||||
siblings := make([]string, 0, len(jobs)-1)
|
||||
|
||||
@@ -194,6 +194,7 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
TotalItems int `json:"total_items"`
|
||||
ProcessedItems int `json:"processed_items"`
|
||||
@@ -205,8 +206,11 @@ func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
|
||||
if envelope.Data.ProcessID != jobID.String() {
|
||||
t.Fatalf("process_id=%q want %s", envelope.Data.ProcessID, jobID)
|
||||
}
|
||||
if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 1 {
|
||||
t.Fatalf("counts=%+v", envelope.Data)
|
||||
if envelope.Data.Status != "pending" {
|
||||
t.Fatalf("status=%q want pending", envelope.Data.Status)
|
||||
}
|
||||
if envelope.Data.TotalItems != 1 || envelope.Data.ProcessedItems != 0 {
|
||||
t.Fatalf("counts=%+v (processed_items must be 0 on start)", envelope.Data)
|
||||
}
|
||||
if enqueued != jobID {
|
||||
t.Fatalf("enqueued=%s", enqueued)
|
||||
|
||||
Reference in New Issue
Block a user