This commit is contained in:
2026-08-17 21:20:45 +02:00
parent 321f11e817
commit 6fcdc74843
157 changed files with 2895 additions and 7544 deletions
@@ -9,7 +9,7 @@ import (
func TestAcceptInviteURL(t *testing.T) {
t.Parallel()
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
want := "http://localhost:28472/accept-invite?token=abc123"
want := "http://localhost:28472/accept-invite#token=abc123"
if got != want {
t.Fatalf("AcceptInviteURL = %q want %q", got, want)
}
@@ -7,6 +7,7 @@ import (
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -403,6 +404,7 @@ func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
if detailed {
attachProductQuality(items)
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), items...)
resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed}
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
if nextCursor != "" {
@@ -438,6 +440,7 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
return
}
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
JSON(w, http.StatusOK, item)
}
@@ -458,5 +461,28 @@ func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError)
return
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
JSON(w, http.StatusOK, item)
}
// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard
// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta).
func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
if !omit {
return
}
for _, item := range items {
if item == nil {
continue
}
delete(item, "meta_title")
delete(item, "meta_description")
if loc, ok := item["localized_content"].(company.LocalizedContent); ok {
for lang, fields := range loc {
fields.MetaTitle = ""
fields.MetaDescription = ""
loc[lang] = fields
}
}
}
}
@@ -0,0 +1,76 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
func catalogSEOItem() map[string]any {
return map[string]any{
"name": "Widget",
"meta_title": "T",
"meta_description": "D",
"localized_content": company.LocalizedContent{
"sl": {ProcessedName: "N", MetaTitle: "LT", MetaDescription: "LD", EnhanceInputHash: "abc"},
},
}
}
func assertCatalogSEOOmitted(t *testing.T, item map[string]any) {
t.Helper()
if _, ok := item["meta_title"]; ok {
t.Fatalf("must omit meta_title: %v", item)
}
if _, ok := item["meta_description"]; ok {
t.Fatalf("must omit meta_description: %v", item)
}
loc := item["localized_content"].(company.LocalizedContent)
if loc["sl"].MetaTitle != "" || loc["sl"].MetaDescription != "" {
t.Fatalf("localized meta leaked: %#v", loc["sl"])
}
if loc["sl"].ProcessedName != "N" {
t.Fatalf("stripped too much: %#v", loc["sl"])
}
}
func TestStripCatalogSEOMetaIfOmitted(t *testing.T) {
t.Parallel()
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", false), item)
if item["meta_title"] != "T" {
t.Fatalf("ordinary company must keep meta: %v", item["meta_title"])
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(a1, "", "", false), item)
assertCatalogSEOOmitted(t, item)
}
func TestStripCatalogSEOMetaIfOmitted_demoByName(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
omit := processing.CompanyOmitsSEOMetaLookup(other, "", " platform demo ", false)
if !omit {
t.Fatal("Platform Demo by name must omit SEO meta")
}
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(omit, item)
assertCatalogSEOOmitted(t, item)
}
func TestStripCatalogSEOMetaIfOmitted_a1PromptMarkers(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
omit := processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", true)
if !omit {
t.Fatal("A1-style prompt markers must omit SEO meta")
}
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(omit, item)
assertCatalogSEOOmitted(t, item)
}
@@ -24,6 +24,23 @@ func presentV1Category(item map[string]any) map[string]any {
}
}
// handleV1GetCategory serves GET /api/v1/categories/{id} as the public list DTO
// (no prompts, formulas, or has_prompt). Dashboard GET stays on handleGetCategory.
func (s *Server) handleV1GetCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
v1OK(w, http.StatusOK, presentV1Category(item), nil)
}
func presentV1Attribute(item map[string]any) map[string]any {
out := map[string]any{
"id": item["id"],
@@ -17,6 +17,9 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
cat := presentV1Category(map[string]any{
"id": id, "unique_id": "electronics", "name": "Electronics",
"created_at": ts, "updated_at": ts,
"prompt": "--- Title --- secret", "prompts": map[string]string{"en": "x"},
"has_prompt": true, "title_template": []any{"--- Title ---"},
"description_template": []any{"x"},
})
if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" {
t.Fatalf("category=%v", cat)
@@ -24,6 +27,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
if cat["created_at"] != "2026-07-01T08:00:00Z" {
t.Fatalf("created_at=%v", cat["created_at"])
}
for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} {
if _, ok := cat[leak]; ok {
t.Fatalf("public category leaked %q: %v", leak, cat)
}
}
attr := presentV1Attribute(map[string]any{
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
@@ -41,11 +49,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
body := string(v1OpenAPIYAML)
needles := []string{
"LegacyCategoriesResponse",
"LegacyAttributesResponse",
"LegacyCategoryCreateResponse",
"LegacyAttributeCreateResponse",
"LegacySuccessMessage",
"CategoriesEnvelope",
"AttributesEnvelope",
"CategoryCreateEnvelope",
"AttributeCreateEnvelope",
"SuccessMessage",
"category_unique_id",
"attribute_key",
"parent_id",
@@ -10,7 +10,7 @@ import (
func TestInviteAcceptURLAlwaysShareable(t *testing.T) {
t.Parallel()
url := mail.AcceptInviteURL("https://app.example.com/", "tok_abc")
want := "https://app.example.com/accept-invite?token=tok_abc"
want := "https://app.example.com/accept-invite#token=tok_abc"
if url != want {
t.Fatalf("AcceptInviteURL = %q want %q", url, want)
}
+29
View File
@@ -413,6 +413,35 @@ func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) {
}
}
func TestRouterMetricsHiddenInProductionForSpoofedLoopbackXFF(t *testing.T) {
t.Parallel()
s := testAPIServer()
s.Config.AppEnv = "production"
s.Config.MetricsPublic = false
s.Config.TrustedProxies = []string{"10.0.0.0/8"}
h := s.Router()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "10.0.0.5:443"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("trusted proxy spoofed loopback XFF status=%d want 404", rec.Code)
}
s.Config.MetricsPublic = true
hPub := s.Router()
reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil)
reqPub.RemoteAddr = "10.0.0.5:443"
reqPub.Header.Set("X-Forwarded-For", "127.0.0.1")
recPub := httptest.NewRecorder()
hPub.ServeHTTP(recPub, reqPub)
if recPub.Code != http.StatusOK {
t.Fatalf("METRICS_PUBLIC trusted proxy XFF status=%d", recPub.Code)
}
}
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
t.Helper()
var body map[string]any
@@ -119,6 +119,7 @@ func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusInternalServerError, "load failed")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
return
}
@@ -1,6 +1,7 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -8,6 +9,10 @@ import (
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
@@ -88,6 +93,49 @@ func presentV1Product(item map[string]any) map[string]any {
}
}
func presentV1ProductDetail(item map[string]any) map[string]any {
out := presentV1Product(item)
desc := firstNonEmpty(asMapString(item["processed_description"]), asMapString(item["description"]))
if desc == "" {
out["description"] = nil
} else {
out["description"] = desc
}
out["attributes"] = firstPublicAttributes(item)
mapped, _ := item["mapped_data"].(map[string]any)
main, more := catalog.ExtractProductImages(mapped, nil)
if main == "" {
out["main_image"] = nil
} else {
out["main_image"] = main
}
if len(more) > 0 {
out["more_images"] = more
} else {
out["more_images"] = nil
}
if eprel, ok := item["eprel"]; ok {
out["eprel"] = eprel
} else {
out["eprel"] = nil
}
return out
}
func firstPublicAttributes(item map[string]any) any {
for _, key := range []string{"processed_attributes", "attributes"} {
v := item[key]
if v == nil {
continue
}
if m, ok := v.(map[string]any); ok && len(m) == 0 {
continue
}
return v
}
return map[string]any{}
}
func nullIfEmptyAny(v any) any {
if v == nil {
return nil
@@ -150,6 +198,36 @@ func (s *Server) handleV1ListProducts(w http.ResponseWriter, r *http.Request) {
v1OK(w, http.StatusOK, data, v1ProductListMeta(page, limit, total))
}
// handleV1GetProduct serves GET /api/v1/products/{id} as a public DTO aligned with
// process items (name, description, attributes, images, eprel). Dashboard GET stays
// on handleGetProduct.
func (s *Server) handleV1GetProduct(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
item, err = s.Catalog.GetRawProduct(r.Context(), cid, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
v1OK(w, http.StatusOK, presentV1ProductDetail(item), nil)
}
// handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy
// { data, meta } envelope (quality rows + page/limit/total).
func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +56,51 @@ func TestPresentV1ProductFields(t *testing.T) {
}
}
func TestPresentV1ProductDetailOmitsMappedData(t *testing.T) {
ts := time.Date(2026, 8, 1, 10, 15, 0, 0, time.UTC)
row := map[string]any{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"product_id": "SKU-1001",
"name": "Wireless earbuds",
"processed_name": "Acme Wireless Earbuds",
"category": "electronics/audio",
"status": "completed",
"feed_id": "22222222-2222-2222-2222-222222222222",
"description": "raw desc",
"processed_description": "A detailed product description that is long enough for scoring.",
"attributes": map[string]any{"color": "Black"},
"processed_attributes": map[string]any{"color": "Midnight"},
"mapped_data": map[string]any{"image": "https://example.com/earbuds.jpg", "main_image": "https://example.com/main.jpg"},
"has_processed_name": true,
"has_eprel": false,
"eprel": nil,
"created_at": ts,
"updated_at": ts,
}
out := presentV1ProductDetail(row)
for _, key := range []string{"name", "description", "attributes", "main_image", "more_images", "eprel"} {
if _, ok := out[key]; !ok {
t.Fatalf("missing field %q", key)
}
}
for _, leak := range []string{
"mapped_data", "has_processed_name", "has_processed_description",
"has_attributes", "has_processed_attributes", "has_eprel",
"processed_name", "processed_description", "processed_attributes",
} {
if _, ok := out[leak]; ok {
t.Fatalf("unexpected internal field %q", leak)
}
}
if out["description"] != "raw desc" && out["description"] != "A detailed product description that is long enough for scoring." {
t.Fatalf("description=%v", out["description"])
}
attrs, _ := out["attributes"].(map[string]any)
if attrs["color"] != "Midnight" {
t.Fatalf("attributes=%v", out["attributes"])
}
}
func TestV1ProductStatusAll(t *testing.T) {
if got := v1ProductStatus("all"); got != "" {
t.Fatalf("all -> %q want empty", got)
@@ -99,7 +144,7 @@ func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) {
"name: sortBy",
"name: feedId",
"totalPages",
"LegacyLimit",
"PublicLimit",
"required: [data, meta]",
} {
if !strings.Contains(body, needle) {
+4 -5
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
@@ -66,7 +65,7 @@ func (s *Server) mountV1(r chi.Router) {
// Not an alias of POST/GET /process (flat ProcessingJob).
r.Post("/products/process", s.handleV1StartProcess)
r.Get("/products/process/{id}", s.handleV1GetProcess)
r.Get("/products/{id}", s.handleGetProduct)
r.Get("/products/{id}", s.handleV1GetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
// Content calendar — separate from email /api/campaigns (session UI).
@@ -79,7 +78,7 @@ func (s *Server) mountV1(r chi.Router) {
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias
r.Get("/categories/{id}", s.handleGetCategory)
r.Get("/categories/{id}", s.handleV1GetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
@@ -113,7 +112,7 @@ func (s *Server) mountV1(r chi.Router) {
// Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations.
r.Post("/process", s.handleStartProcessingJob)
r.Get("/process", s.handleV1ListProcessJobs)
r.Get("/process/{id}", s.handleGetProcessingJob)
r.Get("/process/{id}", s.handleV1GetProcessJob)
r.Post("/process/{id}/cancel", s.handleCancelProcessingJob)
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
r.Post("/process/{id}/retry", s.handleRetryProcessingJob)
@@ -133,7 +132,7 @@ func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
JSON(w, http.StatusOK, map[string]any{"jobs": presentV1JobList(items), "limit": limit})
}
func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -118,7 +118,7 @@ func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
"Retry-After",
"rate limit exceeded",
"code: unauthorized",
"LegacyAPIError",
"CodedAPIError",
"security: []",
} {
if !strings.Contains(body, want) {
@@ -154,9 +154,9 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
"raw_product_ids",
"items[].ean",
"process_id",
"LegacyStartProcessByEAN",
"StartProcessByEAN",
"StartProcessByRawIDs",
"LegacyProcessCompleted",
"ProcessCompletedExample",
"X-API-Key",
"https://descrybe.io/api/v1",
"BearerAuth",
@@ -185,7 +185,7 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
"CSRFHeader",
"code: unauthorized",
"message: Unauthorized",
"legacy envelope",
"coded envelope",
"/process/{id}/retry:",
} {
if !strings.Contains(body, needle) {
@@ -123,12 +123,18 @@ func TestV1DomainResourceCRUD(t *testing.T) {
t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String())
}
gotCat := decode(t, rec)
if _, hasData := gotCat["data"]; hasData {
t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat)
}
if gotCat["unique_id"] != catUnique {
gotCatData, _ := gotCat["data"].(map[string]any)
if gotCatData["unique_id"] != catUnique {
t.Fatalf("get category=%v", gotCat)
}
for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} {
if _, ok := gotCatData[leak]; ok {
t.Fatalf("v1 GET category leaked %q: %v", leak, gotCatData)
}
if _, ok := gotCat[leak]; ok {
t.Fatalf("v1 GET category envelope leaked %q: %v", leak, gotCat)
}
}
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
`{"name":"Electronics & Audio"}`)
@@ -323,12 +329,15 @@ func TestV1DomainResourceCRUD(t *testing.T) {
t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String())
}
gotProd := decode(t, rec)
if _, hasData := gotProd["data"]; hasData {
t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd)
}
if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" {
gotProdData, _ := gotProd["data"].(map[string]any)
if fmt.Sprint(gotProdData["product_id"]) != "SKU-1001" {
t.Fatalf("get product=%v", gotProd)
}
for _, leak := range []string{"mapped_data", "has_processed_name", "has_eprel", "processed_name"} {
if _, ok := gotProdData[leak]; ok {
t.Fatalf("v1 GET product leaked %q: %v", leak, gotProdData)
}
}
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
@@ -427,7 +436,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler {
r.Route("/api/v1", func(r chi.Router) {
r.Get("/products", s.handleV1ListProducts)
r.Get("/products/quality", s.handleV1ListProductQuality)
r.Get("/products/{id}", s.handleGetProduct)
r.Get("/products/{id}", s.handleV1GetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
@@ -438,7 +447,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler {
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory)
r.Get("/categories/{id}", s.handleGetCategory)
r.Get("/categories/{id}", s.handleV1GetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
@@ -480,9 +489,9 @@ func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
"/marketing/calendar:",
"/products:",
"/feeds/{id}/sync-process-sample:",
"Flat category JSON",
"no prompts or formulas",
"flat PresentFeed",
"flat ProcessedProduct",
"without mapped_data",
"CategoryDetail",
"FeedDeleted",
"FeedMappings",
+1 -1
View File
@@ -9,7 +9,7 @@ func TestV1OpenAPIFeedsLegacyContract(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
for _, needle := range []string{
"Legacy public contract — { data: Feed[], meta: { page, limit, total } }",
"Paged list — { data: Feed[], meta: { page, limit, total } }",
"required: [name, item_path]",
"jobId:",
"FeedSyncResponse",
File diff suppressed because it is too large Load Diff
+160 -6
View File
@@ -415,25 +415,25 @@ func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) {
}
}
// TestV1OpenAPIYAMLLegacyProcessItemEprelShape locks LegacyProcessItem.eprel
// TestV1OpenAPIYAMLProcessItemEprelShape locks ProcessItem.eprel
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
comps, _ := root["components"].(map[string]any)
schemas, _ := comps["schemas"].(map[string]any)
item, _ := schemas["LegacyProcessItem"].(map[string]any)
item, _ := schemas["ProcessItem"].(map[string]any)
itemProps, _ := item["properties"].(map[string]any)
eprel, _ := itemProps["eprel"].(map[string]any)
eprelProps, _ := eprel["properties"].(map[string]any)
if eprelProps == nil {
t.Fatal("LegacyProcessItem.eprel.properties missing")
t.Fatal("ProcessItem.eprel.properties missing")
}
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
for _, key := range want {
if _, ok := eprelProps[key]; !ok {
t.Fatalf("LegacyProcessItem.eprel missing property %q (live shape)", key)
t.Fatalf("ProcessItem.eprel missing property %q (live shape)", key)
}
}
if len(eprelProps) != len(want) {
@@ -441,7 +441,161 @@ func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
for k := range eprelProps {
keys = append(keys, k)
}
t.Fatalf("LegacyProcessItem.eprel properties = %v, want exactly %v", keys, want)
t.Fatalf("ProcessItem.eprel properties = %v, want exactly %v", keys, want)
}
}
func TestV1OpenAPIYAMLNoLegacyWording(t *testing.T) {
t.Parallel()
assertOpenAPIYAMLOmitsToken(t, "legacy")
}
func TestV1OpenAPIYAMLNoA1Wording(t *testing.T) {
t.Parallel()
// Case-sensitive: lowercase "a1" appears in UUIDs and must stay allowed.
assertOpenAPIYAMLOmitsToken(t, "A1")
}
func TestV1OpenAPIYAMLOmitsInternalPromptWording(t *testing.T) {
t.Parallel()
for _, token := range []string{
"role-section prompts",
"plain description, meta",
"after AI/manual edit",
} {
assertOpenAPIYAMLOmitsToken(t, token)
}
}
func TestV1OpenAPIYAMLProcessItemCompletedExamplesOmitLeakyShape(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
leaky := []string{"id", "title", "meta_title", "meta_description"}
assertProcessItems := func(where string, items []any) {
t.Helper()
if len(items) == 0 {
t.Fatalf("%s: expected completed ProcessItem example", where)
}
for i, raw := range items {
item, _ := raw.(map[string]any)
if item == nil {
t.Fatalf("%s[%d]: not an object", where, i)
}
if _, ok := item["ean"]; !ok {
t.Fatalf("%s[%d]: missing ean", where, i)
}
for _, k := range leaky {
if _, ok := item[k]; ok {
t.Fatalf("%s[%d] must omit %q (do not teach leaky ProcessItem shape)", where, i, k)
}
}
}
}
comps, _ := root["components"].(map[string]any)
examples, _ := comps["examples"].(map[string]any)
completed, _ := examples["ProcessCompletedExample"].(map[string]any)
val, _ := completed["value"].(map[string]any)
data, _ := val["data"].(map[string]any)
items, _ := data["items"].([]any)
assertProcessItems("ProcessCompletedExample", items)
paths, _ := root["paths"].(map[string]any)
productsProcess := openAPIJSONExamples(t, paths, "/products/process/{id}", "get", "200")
prodCompleted, _ := productsProcess["completed"].(map[string]any)
prodVal, _ := prodCompleted["value"].(map[string]any)
prodData, _ := prodVal["data"].(map[string]any)
prodItems, _ := prodData["items"].([]any)
assertProcessItems("GET /products/process/{id} completed", prodItems)
legacyProcess := openAPIJSONExamples(t, paths, "/process/{id}", "get", "200")
legacyCompleted, _ := legacyProcess["completed"].(map[string]any)
legacyVal, _ := legacyCompleted["value"].(map[string]any)
legacyItems, _ := legacyVal["items"].([]any)
assertProcessItems("GET /process/{id} completed", legacyItems)
}
func openAPIJSONExamples(t *testing.T, paths map[string]any, path, method, status string) map[string]any {
t.Helper()
p, _ := paths[path].(map[string]any)
op, _ := p[method].(map[string]any)
resps, _ := op["responses"].(map[string]any)
resp, _ := resps[status].(map[string]any)
content, _ := resp["content"].(map[string]any)
appJSON, _ := content["application/json"].(map[string]any)
examples, _ := appJSON["examples"].(map[string]any)
if examples == nil {
t.Fatalf("%s %s %s: missing application/json examples", method, path, status)
}
return examples
}
func assertOpenAPIYAMLOmitsToken(t *testing.T, token string) {
t.Helper()
doc := string(v1OpenAPIYAML)
if token == "legacy" {
doc = strings.ToLower(doc)
}
if i := strings.Index(doc, token); i >= 0 {
start := i - 40
if start < 0 {
start = 0
}
end := i + 40
if end > len(doc) {
end = len(doc)
}
t.Fatalf("public OpenAPI YAML must not mention %q (near %q)", token, doc[start:end])
}
}
func TestV1OpenAPIYAMLEmptySecurityOnlyOnPublicProbes(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
sec, _ := root["security"].([]any)
if len(sec) == 0 {
t.Fatal("document-level security must require API key schemes")
}
var hasBearer, hasAPIKey bool
for _, item := range sec {
m, _ := item.(map[string]any)
if _, ok := m["BearerAuth"]; ok {
hasBearer = true
}
if _, ok := m["ApiKeyAuth"]; ok {
hasAPIKey = true
}
}
if !hasBearer || !hasAPIKey {
t.Fatal("document-level security must include BearerAuth and ApiKeyAuth")
}
paths, _ := root["paths"].(map[string]any)
methods := []string{"get", "post", "put", "patch", "delete"}
var bad []string
for p, raw := range paths {
item, _ := raw.(map[string]any)
for _, m := range methods {
op, _ := item[m].(map[string]any)
if op == nil {
continue
}
rawSec, ok := op["security"]
if !ok {
continue
}
arr, _ := rawSec.([]any)
if len(arr) != 0 {
continue
}
if p != "/health" && p != "/openapi.yaml" {
bad = append(bad, m+" "+p)
}
}
}
if len(bad) > 0 {
t.Fatalf("empty security (no API key) on non-probe operations: %v", bad)
}
}
@@ -227,6 +227,7 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
v1Err(w, http.StatusInternalServerError, "internal_server_error", "Internal server error")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
data := map[string]any{
"status": status,
"process_id": job.ID.String(),
@@ -245,8 +246,9 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
v1OK(w, http.StatusOK, data, nil)
case "FAILED":
errMsg := "Processing failed"
if job.Error != nil && *job.Error != "" {
errMsg = *job.Error
if job.Error != nil && strings.TrimSpace(*job.Error) != "" {
log.Printf("httpapi: v1 process job %s failed: %s", job.ID, redactForLog(*job.Error))
errMsg = publicV1JobError(*job.Error)
}
v1OK(w, http.StatusOK, map[string]any{
"status": status,
@@ -269,6 +271,99 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
}
}
func publicV1JobError(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "Processing failed"
}
out := processing.PublicV1Error(raw)
if strings.TrimSpace(out) == "" {
return "Processing failed"
}
return out
}
func presentV1Job(job processing.Job) map[string]any {
out := map[string]any{
"id": job.ID,
"status": job.Status,
"total_products": job.TotalProducts,
"processed_products": job.ProcessedProducts,
"processing_type": processing.ProcessingTypeForAPIResponse(job.ProcessingType),
"created_at": formatV1Timestamp(job.CreatedAt),
}
if job.StartedAt != nil {
out["started_at"] = formatV1Timestamp(*job.StartedAt)
}
if job.CompletedAt != nil {
out["completed_at"] = formatV1Timestamp(*job.CompletedAt)
}
if job.Error != nil && strings.TrimSpace(*job.Error) != "" {
out["error"] = publicV1JobError(*job.Error)
}
return out
}
func presentV1JobList(jobs []processing.Job) []any {
if len(jobs) == 0 {
return []any{}
}
formatted := processing.FormatListJobsResponse(jobs)
out := make([]any, len(formatted))
for i, row := range formatted {
base := presentV1Job(jobs[i])
raw, err := json.Marshal(row)
if err != nil {
out[i] = base
continue
}
var extra map[string]any
if err := json.Unmarshal(raw, &extra); err != nil {
out[i] = base
continue
}
for _, k := range []string{"sibling_job_ids", "job_count", "total_products_queued"} {
if v, ok := extra[k]; ok {
base[k] = v
}
}
out[i] = base
}
return out
}
// handleV1GetProcessJob serves GET /api/v1/process/{id} with a public job DTO
// (no current_step, step notes, or company_id). Dashboard GET stays on handleGetProcessingJob.
func (s *Server) handleV1GetProcessJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
job, err := s.getV1ProcessJob(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
out := presentV1Job(job)
if processing.JobStatusIncludesProducts(job.Status) {
items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType)
if loadErr != nil {
Error(w, http.StatusInternalServerError, "load failed")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
out["items"] = items
out["total_items"] = len(items)
}
JSON(w, http.StatusOK, out)
}
func (s *Server) ensureRawV1Items(ctx context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) {
if s != nil && s.testEnsureRawV1Items != nil {
return s.testEnsureRawV1Items(ctx, companyID, items)
@@ -337,7 +337,6 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
productID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
if companyID != cid || id != jobID {
@@ -357,7 +356,7 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
}
return []processing.V1ProcessJobItem{{
"ean": "0123456789012",
"id": productID,
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"status": "processed",
"title": "Acme Widget",
}}, nil
@@ -393,9 +392,12 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
if !ok {
t.Fatalf("item=%T", items[0])
}
if item["status"] != "processed" || item["id"] != productID || item["ean"] != "0123456789012" {
if item["status"] != "processed" || item["ean"] != "0123456789012" {
t.Fatalf("item=%v", item)
}
if _, ok := item["id"]; ok {
t.Fatalf("id must be omitted from public process items: %v", item)
}
if _, ok := body.Data["processed_at"]; !ok {
t.Fatalf("missing processed_at: %v", body.Data)
}
@@ -447,6 +449,90 @@ func TestHandleGetProcessingJobCompletedIncludesItems(t *testing.T) {
}
}
func TestHandleV1GetProcessJobOmitsAIEnhance(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
note := "GPT: rewrite title with --- Title --- markers"
errMsg := "openai: invalid api-key sk-live-secret"
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "processing",
ProcessingType: "full",
CurrentStep: "ai_enhance",
StepProgress: []processing.StepProgress{{
Step: "ai_enhance", Status: "running", Note: note,
}},
Error: &errMsg,
TotalProducts: 2,
ProcessedProducts: 1,
}, nil
},
}
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcessJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
raw := rec.Body.String()
if strings.Contains(raw, "ai_enhance") {
t.Fatalf("public process poll leaked ai_enhance: %s", raw)
}
if strings.Contains(raw, "GPT:") || strings.Contains(raw, "--- Title ---") {
t.Fatalf("public process poll leaked step notes: %s", raw)
}
if strings.Contains(raw, "current_step") || strings.Contains(raw, "step_progress") || strings.Contains(raw, "company_id") {
t.Fatalf("public process poll leaked internals: %s", raw)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "processing" {
t.Fatalf("status=%v", body["status"])
}
if body["total_products"].(float64) != 2 || body["processed_products"].(float64) != 1 {
t.Fatalf("counts=%v", body)
}
}
func TestHandleGetProcessingJobKeepsCurrentStep(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "processing",
CurrentStep: "ai_enhance",
}, nil
},
}
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/processing/jobs/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleGetProcessingJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ai_enhance") {
t.Fatalf("dashboard GET should keep current_step: %s", rec.Body.String())
}
}
func TestHandleV1ListProcessJobsUnauthorizedWithoutCompany(t *testing.T) {
t.Parallel()
s := &Server{}
@@ -500,3 +586,126 @@ func TestAssertV1ProcessGatesNilBilling(t *testing.T) {
t.Fatalf("nil billing must no-op: %v", err)
}
}
func processJobWithSEOMetaItems(cid, jobID uuid.UUID) *Server {
return &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "completed",
ProcessingType: "full",
TotalProducts: 1,
}, nil
},
testLoadV1ProcessJobItems: func(context.Context, uuid.UUID, uuid.UUID, string) ([]processing.V1ProcessJobItem, error) {
return []processing.V1ProcessJobItem{{
"ean": "0123456789012",
"status": "processed",
"name": "Acme Widget",
"meta_title": "Acme | Widget",
"meta_description": "A widget for tests",
}}, nil
},
}
}
func getProcessJSONItem(t *testing.T, rec *httptest.ResponseRecorder, wrapped bool) map[string]any {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
data := raw
if wrapped {
var ok bool
data, ok = raw["data"].(map[string]any)
if !ok {
t.Fatalf("data=%T body=%s", raw["data"], rec.Body.String())
}
}
items, ok := data["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("items=%v", data["items"])
}
item, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("item=%T", items[0])
}
return item
}
func TestA1ProcessEndpointsOmitSEOMeta(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := processJobWithSEOMetaItems(cid, jobID)
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
legacyRec := httptest.NewRecorder()
legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcess(legacyRec, legacyReq)
legacyItem := getProcessJSONItem(t, legacyRec, true)
if _, ok := legacyItem["meta_title"]; ok {
t.Fatalf("GET /products/process/{id} A1 meta_title=%v", legacyItem["meta_title"])
}
if _, ok := legacyItem["meta_description"]; ok {
t.Fatalf("GET /products/process/{id} A1 meta_description=%v", legacyItem["meta_description"])
}
jobRec := httptest.NewRecorder()
jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
jrctx := chi.NewRouteContext()
jrctx.URLParams.Add("id", jobID.String())
jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx))
s.handleGetProcessingJob(jobRec, jobReq)
jobItem := getProcessJSONItem(t, jobRec, false)
if _, ok := jobItem["meta_title"]; ok {
t.Fatalf("GET /process/{id} A1 meta_title=%v", jobItem["meta_title"])
}
if _, ok := jobItem["meta_description"]; ok {
t.Fatalf("GET /process/{id} A1 meta_description=%v", jobItem["meta_description"])
}
}
func TestNonA1ProcessEndpointsKeepSEOMeta(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := processJobWithSEOMetaItems(cid, jobID)
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
legacyRec := httptest.NewRecorder()
legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcess(legacyRec, legacyReq)
legacyItem := getProcessJSONItem(t, legacyRec, true)
if legacyItem["meta_title"] != "Acme | Widget" {
t.Fatalf("non-A1 GET /products/process/{id} meta_title=%v", legacyItem["meta_title"])
}
if legacyItem["meta_description"] != "A widget for tests" {
t.Fatalf("non-A1 GET /products/process/{id} meta_description=%v", legacyItem["meta_description"])
}
jobRec := httptest.NewRecorder()
jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
jrctx := chi.NewRouteContext()
jrctx.URLParams.Add("id", jobID.String())
jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx))
s.handleGetProcessingJob(jobRec, jobReq)
jobItem := getProcessJSONItem(t, jobRec, false)
if jobItem["meta_title"] != "Acme | Widget" {
t.Fatalf("non-A1 GET /process/{id} meta_title=%v", jobItem["meta_title"])
}
if jobItem["meta_description"] != "A widget for tests" {
t.Fatalf("non-A1 GET /process/{id} meta_description=%v", jobItem["meta_description"])
}
}