- Category enhance prompts (Sync A1 / seed-a1 / repair) now use the same three role sections as the rest of the platform: Title / Description / Meta. The retired "--- Attributes ---" role section is removed from the canonical template, the legacy-split overlay, and the web prompt editor; Category/Attrs stay as plain product-context lines (attribute extraction remains pipeline-level via AppendAttributeConstraints). - Stored prompts still carrying an attributes section are detected by CategoryEnhancePromptNeedsRepair and rewritten on the next Sync. - Legacy wp_product_categories.sql splits now also seed a default Slovenian metaTitle rule (dumps only carried <metaDescription>), so every A1 category gets all four prompt areas: title formula, description sections, meta title, meta description. - Role-sectioned prompts no longer imply the A1 SEO-omit cohort (CompanyOmitsSEOMeta): sectioned prompts are the canonical prompt-editor output for every tenant, and the sniff silently disabled SEO meta for any company that saved a category prompt. - Verified locally: repair-category-prompts -apply updated 238 A1 + Platform Demo categories from scripts/seed/wp_product_categories.sql (216 legacy splits), idempotent on re-run, zero attributes sections left in DB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
9.2 KiB
Go
280 lines
9.2 KiB
Go
package processing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestParseV1ProcessingTypeFullAndSteps(t *testing.T) {
|
|
storage, resp, err := ParseV1ProcessingType(nil)
|
|
if err != nil || storage != "full" || resp != "full" {
|
|
t.Fatalf("nil: storage=%q resp=%v err=%v", storage, resp, err)
|
|
}
|
|
storage, resp, err = ParseV1ProcessingType("title")
|
|
if err != nil || storage != "title" || resp != "title" {
|
|
t.Fatalf("title: storage=%q resp=%v err=%v", storage, resp, err)
|
|
}
|
|
storage, _, err = ParseV1ProcessingType("normalize_only")
|
|
if err != nil || storage != "normalize_only" {
|
|
t.Fatalf("normalize_only: storage=%q err=%v", storage, err)
|
|
}
|
|
storage, resp, err = ParseV1ProcessingType([]any{"title", "attributes"})
|
|
if err != nil || storage != `["title","attributes"]` {
|
|
t.Fatalf("array: storage=%q resp=%v err=%v", storage, resp, err)
|
|
}
|
|
arr, ok := resp.([]string)
|
|
if !ok || len(arr) != 2 {
|
|
t.Fatalf("resp=%v", resp)
|
|
}
|
|
_, _, err = ParseV1ProcessingType("nope")
|
|
if err == nil {
|
|
t.Fatal("expected invalid type error")
|
|
}
|
|
}
|
|
|
|
func TestMapJobStatusForV1(t *testing.T) {
|
|
if got := MapJobStatusForV1("pending"); got != "PENDING" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if got := MapJobStatusForV1("running"); got != "PROCESSING" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if got := MapJobStatusForV1("completed"); got != "COMPLETED" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
for _, syn := range []string{"success", "done", "finished", "processed"} {
|
|
if got := MapJobStatusForV1(syn); got != "COMPLETED" {
|
|
t.Fatalf("%s -> %q", syn, got)
|
|
}
|
|
}
|
|
if !JobStatusIncludesProducts("completed") || JobStatusIncludesProducts("running") {
|
|
t.Fatal("JobStatusIncludesProducts mismatch")
|
|
}
|
|
}
|
|
|
|
func TestMapV1JobItemStatus(t *testing.T) {
|
|
if got := MapV1JobItemStatus("processed", true); got != "processed" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if got := MapV1JobItemStatus("failed", false); got != "failed" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if got := MapV1JobItemStatus("", false); got != "not_found" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if got := MapV1JobItemStatus("", true); got != "processed" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProjectV1ProcessJobItemsPartial(t *testing.T) {
|
|
items := []V1ProcessJobItem{{
|
|
"ean": "123", "status": "processed",
|
|
"name": "T", "meta_title": "MT",
|
|
"description": "D", "attributes": map[string]any{"brand": "X"},
|
|
"main_image": "https://example.com/a.jpg", "more_images": []string{"https://example.com/b.jpg"},
|
|
"eprel": nil, "category": "cat", "category_name": "Cat",
|
|
}}
|
|
out := ProjectV1ProcessJobItems("title", items)
|
|
if len(out) != 1 {
|
|
t.Fatalf("len=%d", len(out))
|
|
}
|
|
raw, _ := json.Marshal(out[0])
|
|
var got map[string]any
|
|
_ = json.Unmarshal(raw, &got)
|
|
if got["name"] != "T" || got["ean"] != "123" {
|
|
t.Fatalf("got=%v", got)
|
|
}
|
|
if _, ok := got["title"]; ok {
|
|
t.Fatalf("duplicate title should be omitted when name exists: %v", got)
|
|
}
|
|
if _, ok := got["attributes"]; ok {
|
|
t.Fatalf("attributes should be projected out: %v", got)
|
|
}
|
|
if got["main_image"] != "https://example.com/a.jpg" {
|
|
t.Fatalf("images always included: %v", got)
|
|
}
|
|
if got["status"] != "processed" {
|
|
t.Fatalf("status should be preserved: %v", got)
|
|
}
|
|
for _, junk := range []string{"id", "processed_product_id", "raw_product_id"} {
|
|
if _, ok := got[junk]; ok {
|
|
t.Fatalf("%s must be omitted from V1 process items: %v", junk, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProjectV1ProcessJobItemsTitleDerivesName(t *testing.T) {
|
|
items := []V1ProcessJobItem{{
|
|
"ean": "123", "status": "processed", "title": "OnlyTitle", "meta_title": "MT",
|
|
}}
|
|
out := ProjectV1ProcessJobItems("title", items)
|
|
if len(out) != 1 {
|
|
t.Fatalf("len=%d", len(out))
|
|
}
|
|
if out[0]["name"] != "OnlyTitle" {
|
|
t.Fatalf("name should derive from title when absent: %v", out[0]["name"])
|
|
}
|
|
if _, ok := out[0]["title"]; ok {
|
|
t.Fatalf("title omitted when identical to name: %v", out[0])
|
|
}
|
|
}
|
|
|
|
func TestV1PlainDescriptionStandalone(t *testing.T) {
|
|
got := v1PlainDescription("Line1<br>Line2 & ok")
|
|
if strings.Contains(got, "<") {
|
|
t.Fatalf("html left: %q", got)
|
|
}
|
|
if !strings.Contains(got, "Line1") || !strings.Contains(got, "Line2") || !strings.Contains(got, "& ok") {
|
|
t.Fatalf("got=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestV1PlainDescriptionEdgeCases(t *testing.T) {
|
|
nbsp := "\u00a0"
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{name: "empty", in: "", want: ""},
|
|
{name: "whitespace", in: " \n\t ", want: ""},
|
|
{name: "plain", in: "Hello world", want: "Hello world"},
|
|
{name: "html_br", in: "Hello<br>World<br/>& more</p><b>bold</b>", want: "Hello\nWorld\n& more\nbold"},
|
|
{name: "one_element_json_array_html", in: `["Hello<br>World"]`, want: "Hello\nWorld"},
|
|
{name: "multi_element_array", in: `["Para one.", "Para <b>two</b>."]`, want: "Para one.\nPara two."},
|
|
{name: "nested_array", in: `[["inner<br>x"]]`, want: "inner\nx"},
|
|
{name: "json_quoted_string", in: `"Plain & quoted"`, want: "Plain & quoted"},
|
|
{name: "empty_array", in: `[]`, want: ""},
|
|
{name: "array_with_nulls", in: `[null, "keep", null, ""]`, want: "keep"},
|
|
{name: "array_with_object_ignored", in: `[{"text":"x"}, "ok"]`, want: "ok"},
|
|
{name: "invalid_bracket_text", in: `[not json`, want: "[not json"},
|
|
{name: "nbsp_and_entities", in: "A" + nbsp + "B C", want: "A B C"},
|
|
{name: "literal_unicode_escape", in: `A\u00a0B`, want: "A B"},
|
|
{name: "double_encoded_array", in: `"[\"Hello<br>World\"]"`, want: "Hello\nWorld"},
|
|
{name: "whitespace_around_array", in: " [\" spaced \"] ", want: "spaced"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := v1PlainDescription(tc.in)
|
|
if got != tc.want {
|
|
t.Fatalf("in=%q got=%q want=%q", tc.in, got, tc.want)
|
|
}
|
|
if strings.Contains(got, "<") {
|
|
t.Fatalf("html left: %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyV1ProcessItemIDsDualMode(t *testing.T) {
|
|
processed := mustParseTestUUID(t, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
|
raw := mustParseTestUUID(t, "cccccccc-cccc-cccc-cccc-cccccccccccc")
|
|
item := V1ProcessJobItem{"ean": "1", "status": "processed"}
|
|
applyV1ProcessItemIDs(item, &processed, &raw)
|
|
for _, junk := range []string{"id", "processed_product_id", "raw_product_id"} {
|
|
if _, ok := item[junk]; ok {
|
|
t.Fatalf("%s must stay omitted from V1 process items: %v", junk, item)
|
|
}
|
|
}
|
|
missing := V1ProcessJobItem{"ean": "2", "status": "not_found"}
|
|
applyV1ProcessItemIDs(missing, nil, &raw)
|
|
if _, ok := missing["id"]; ok {
|
|
t.Fatalf("id must stay absent: %v", missing)
|
|
}
|
|
if _, ok := missing["raw_product_id"]; ok {
|
|
t.Fatalf("raw_product_id must stay omitted: %v", missing)
|
|
}
|
|
}
|
|
|
|
func TestV1ProcessJobItemMarshalJSONOmitsInternals(t *testing.T) {
|
|
item := V1ProcessJobItem{
|
|
"ean": "1",
|
|
"status": "processed",
|
|
"name": "T",
|
|
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
"ai_enhance": true,
|
|
"finish_reason": "stop",
|
|
"enhance_input_hash": "deadbeef",
|
|
"field_sources": map[string]any{"enhance_input_hash": "deadbeef"},
|
|
"prompt_tokens": 9,
|
|
"worker": "river",
|
|
}
|
|
raw, err := json.Marshal(item)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(raw)
|
|
for _, junk := range []string{
|
|
`"id"`, `"ai_enhance"`, `"finish_reason"`, `"enhance_input_hash"`,
|
|
`"field_sources"`, `"prompt_tokens"`, `"worker"`, "deadbeef",
|
|
} {
|
|
if strings.Contains(s, junk) {
|
|
t.Fatalf("internal %q leaked in JSON: %s", junk, s)
|
|
}
|
|
}
|
|
if !strings.Contains(s, `"ean":"1"`) || !strings.Contains(s, `"name":"T"`) {
|
|
t.Fatalf("public fields missing: %s", s)
|
|
}
|
|
}
|
|
|
|
func TestFormatJobStatusResponseAddsItems(t *testing.T) {
|
|
jobID := mustParseTestUUID(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
job := Job{ID: jobID, Status: "completed", TotalProducts: 1}
|
|
items := []V1ProcessJobItem{{"ean": "1", "status": "processed", "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}}
|
|
out, ok := FormatJobStatusResponse(job, items, true).(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("type=%T", FormatJobStatusResponse(job, items, true))
|
|
}
|
|
if out["status"] != "completed" {
|
|
t.Fatalf("status=%v", out["status"])
|
|
}
|
|
if n, ok := out["total_items"].(int); !ok || n != 1 {
|
|
t.Fatalf("total_items=%v (%T)", out["total_items"], out["total_items"])
|
|
}
|
|
arr, ok := out["items"].([]V1ProcessJobItem)
|
|
if !ok || len(arr) != 1 {
|
|
t.Fatalf("items=%v (%T)", out["items"], out["items"])
|
|
}
|
|
}
|
|
|
|
func mustParseTestUUID(t *testing.T, s string) uuid.UUID {
|
|
t.Helper()
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestCompanyOmitsSEOMetaLookup(t *testing.T) {
|
|
t.Parallel()
|
|
a1 := mustParseTestUUID(t, "604f23a8-b66e-4b21-8b45-0d72b68f4790")
|
|
other := mustParseTestUUID(t, "11111111-1111-1111-1111-111111111111")
|
|
cases := []struct {
|
|
name string
|
|
id uuid.UUID
|
|
legacy string
|
|
coName string
|
|
wantOmit bool
|
|
}{
|
|
{name: "ordinary", id: other, coName: "Acme", wantOmit: false},
|
|
{name: "a1_uuid", id: a1, wantOmit: true},
|
|
{name: "demo_by_name", id: other, coName: "Platform Demo", wantOmit: true},
|
|
{name: "demo_by_name_case", id: other, coName: " platform DEMO ", wantOmit: true},
|
|
{name: "a1_legacy_id", id: other, legacy: "97e1a309-3d23-4aa2-b518-8e8d7afdfec7", wantOmit: true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := CompanyOmitsSEOMetaLookup(tc.id, tc.legacy, tc.coName)
|
|
if got != tc.wantOmit {
|
|
t.Fatalf("got %v want %v", got, tc.wantOmit)
|
|
}
|
|
})
|
|
}
|
|
}
|