712 lines
25 KiB
Go
712 lines
25 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestV1OKDataMetaEnvelope(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
v1OK(rec, http.StatusOK, []map[string]any{{"id": "1"}}, map[string]any{
|
|
"page": 1, "limit": 25, "total": 1, "totalPages": 1,
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := body["data"]; !ok {
|
|
t.Fatalf("missing data: %s", rec.Body.String())
|
|
}
|
|
meta, ok := body["meta"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing meta: %s", rec.Body.String())
|
|
}
|
|
if meta["page"].(float64) != 1 || meta["limit"].(float64) != 25 {
|
|
t.Fatalf("meta=%v", meta)
|
|
}
|
|
}
|
|
|
|
func TestV1OKOmitsNilMeta(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
v1OK(rec, http.StatusOK, map[string]any{"process_id": "abc"}, nil)
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := body["meta"]; ok {
|
|
t.Fatalf("meta should be omitted: %s", rec.Body.String())
|
|
}
|
|
data, _ := body["data"].(map[string]any)
|
|
if data["process_id"] != "abc" {
|
|
t.Fatalf("data=%v", data)
|
|
}
|
|
}
|
|
|
|
func TestV1ErrCodedEnvelope(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
v1Err(rec, http.StatusBadRequest, "validation_error", "'items' array is required")
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Code != "validation_error" || !strings.Contains(body.Error.Message, "items") {
|
|
t.Fatalf("got %+v", body.Error)
|
|
}
|
|
}
|
|
|
|
func TestV1ErrFromProcessingDoesNotDoubleWrite(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
v1ErrFromProcessing(rec, errors.New("unexpected backend failure"))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
raw := rec.Body.Bytes()
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("body must be a single JSON object: %v raw=%q", err, string(raw))
|
|
}
|
|
if body.Error.Code != "validation_error" {
|
|
t.Fatalf("got %+v", body.Error)
|
|
}
|
|
if strings.Contains(string(raw), `{"error":"could not start processing job"}`) {
|
|
t.Fatalf("flat Error envelope must not precede coded body: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessRequiresItemsOrRawIDs(t *testing.T) {
|
|
t.Parallel()
|
|
s := &Server{}
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uuid.MustParse("22222222-2222-2222-2222-222222222222"))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(`{"processing_type":"full"}`))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"code":"validation_error"`) {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "items") {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessRejectsMissingEAN(t *testing.T) {
|
|
t.Parallel()
|
|
s := &Server{}
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uuid.MustParse("22222222-2222-2222-2222-222222222222"))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(`{"items":[{"title":"x"}]}`))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "ean") {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessItemsEANReturnsProcessID(t *testing.T) {
|
|
t.Parallel()
|
|
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
rawID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
uid := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
|
|
|
var sawEANs []string
|
|
var enqueued uuid.UUID
|
|
s := &Server{
|
|
testEnsureRawV1Items: func(_ context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) {
|
|
if companyID != cid {
|
|
t.Fatalf("company=%s", companyID)
|
|
}
|
|
for _, it := range items {
|
|
sawEANs = append(sawEANs, it.EAN)
|
|
}
|
|
return []uuid.UUID{rawID}, []catalog.EnsureRawResult{{RawProductID: rawID, EAN: items[0].EAN}}, nil, nil
|
|
},
|
|
testStartJobs: func(_ context.Context, companyID, userID uuid.UUID, rawIDs []uuid.UUID, processingType string) ([]processing.Job, error) {
|
|
if companyID != cid || userID != uid {
|
|
t.Fatalf("tenant cid=%s uid=%s", companyID, userID)
|
|
}
|
|
if len(rawIDs) != 1 || rawIDs[0] != rawID {
|
|
t.Fatalf("rawIDs=%v", rawIDs)
|
|
}
|
|
if processingType != "full" {
|
|
t.Fatalf("type=%q", processingType)
|
|
}
|
|
return []processing.Job{{ID: jobID, Status: "pending", TotalProducts: 1, ProcessingType: "full"}}, nil
|
|
},
|
|
testEnqueueJob: func(_ context.Context, id uuid.UUID) error {
|
|
enqueued = id
|
|
return nil
|
|
},
|
|
}
|
|
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uid)
|
|
body := `{"processing_type":"full","items":[{"ean":"1234567890123","title":"Wireless earbuds"}]}`
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
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"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if envelope.Data.ProcessID != jobID.String() {
|
|
t.Fatalf("process_id=%q want %s", envelope.Data.ProcessID, jobID)
|
|
}
|
|
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)
|
|
}
|
|
if len(sawEANs) != 1 || sawEANs[0] != "1234567890123" {
|
|
t.Fatalf("eans=%v", sawEANs)
|
|
}
|
|
if strings.Contains(rec.Body.String(), `"meta"`) {
|
|
t.Fatalf("start response should omit meta: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessUnauthorizedWithoutCompany(t *testing.T) {
|
|
t.Parallel()
|
|
s := &Server{}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(`{"items":[{"ean":"1"}]}`))
|
|
s.handleV1StartProcess(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"code":"unauthorized"`) {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouterV1ProductsProcessAuthUsesCodedError(t *testing.T) {
|
|
t.Parallel()
|
|
s := testAPIServer()
|
|
h := s.Router()
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(`{"items":[{"ean":"1"}]}`)))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"code":"unauthorized"`) || !strings.Contains(rec.Body.String(), `"message":"Unauthorized"`) {
|
|
t.Fatalf("want coded envelope, got %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessRejectsInvalidProcessingTypes(t *testing.T) {
|
|
t.Parallel()
|
|
s := &Server{}
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uuid.MustParse("22222222-2222-2222-2222-222222222222"))
|
|
|
|
rec := httptest.NewRecorder()
|
|
body := `{"processing_types":["both"],"items":[{"ean":"1234567890123"}]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "validation_error") {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessRejectsTooManyRawIDs(t *testing.T) {
|
|
// Not parallel: mutates process-wide SetTestStartProductCap.
|
|
processing.SetTestStartProductCap(2)
|
|
t.Cleanup(func() { processing.SetTestStartProductCap(0) })
|
|
|
|
s := &Server{
|
|
testEnsureRawV1Items: func(context.Context, uuid.UUID, []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) {
|
|
t.Fatal("ensureRaw must not run for oversized payloads")
|
|
return nil, nil, nil, nil
|
|
},
|
|
testStartJobs: func(context.Context, uuid.UUID, uuid.UUID, []uuid.UUID, string) ([]processing.Job, error) {
|
|
t.Fatal("startJobs must not run for oversized payloads")
|
|
return nil, nil
|
|
},
|
|
}
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uuid.MustParse("22222222-2222-2222-2222-222222222222"))
|
|
|
|
body := fmt.Sprintf(
|
|
`{"processing_type":"full","raw_product_ids":[%q,%q,%q]}`,
|
|
uuid.New().String(), uuid.New().String(), uuid.New().String(),
|
|
)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "too many products") {
|
|
t.Fatalf("body=%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1GetProcessPassesCompanyScope(t *testing.T) {
|
|
t.Parallel()
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
var sawCompany, sawJob uuid.UUID
|
|
s := &Server{
|
|
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
|
|
sawCompany = companyID
|
|
sawJob = id
|
|
return processing.Job{}, errors.New("not found")
|
|
},
|
|
}
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", jobID.String())
|
|
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
|
|
s.handleV1GetProcess(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if sawCompany != cid || sawJob != jobID {
|
|
t.Fatalf("scoped call company=%s job=%s", sawCompany, sawJob)
|
|
}
|
|
}
|
|
|
|
func TestHandleV1GetProcessCompletedIncludesProcessedItems(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) {
|
|
if companyID != cid || id != jobID {
|
|
t.Fatalf("scoped call company=%s job=%s", companyID, id)
|
|
}
|
|
return processing.Job{
|
|
ID: jobID,
|
|
CompanyID: cid,
|
|
Status: "completed",
|
|
ProcessingType: "full",
|
|
TotalProducts: 1,
|
|
}, nil
|
|
},
|
|
testLoadV1ProcessJobItems: func(_ context.Context, companyID, id uuid.UUID, processingType string) ([]processing.V1ProcessJobItem, error) {
|
|
if companyID != cid || id != jobID || processingType != "full" {
|
|
t.Fatalf("load scope company=%s job=%s type=%s", companyID, id, processingType)
|
|
}
|
|
return []processing.V1ProcessJobItem{{
|
|
"ean": "0123456789012",
|
|
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
"status": "processed",
|
|
"title": "Acme Widget",
|
|
}}, nil
|
|
},
|
|
}
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", jobID.String())
|
|
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
|
|
s.handleV1GetProcess(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data["status"] != "COMPLETED" {
|
|
t.Fatalf("status=%v", body.Data["status"])
|
|
}
|
|
if body.Data["total_items"].(float64) != 1 {
|
|
t.Fatalf("total_items=%v", body.Data["total_items"])
|
|
}
|
|
items, ok := body.Data["items"].([]any)
|
|
if !ok || len(items) != 1 {
|
|
t.Fatalf("items=%v", body.Data["items"])
|
|
}
|
|
item, ok := items[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("item=%T", items[0])
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetProcessingJobCompletedIncludesItems(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: "completed",
|
|
ProcessingType: "full",
|
|
TotalProducts: 1,
|
|
}, nil
|
|
},
|
|
testLoadV1ProcessJobItems: func(context.Context, uuid.UUID, uuid.UUID, string) ([]processing.V1ProcessJobItem, error) {
|
|
return []processing.V1ProcessJobItem{{
|
|
"ean": "1", "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "status": "processed", "title": "T",
|
|
}}, 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.handleGetProcessingJob(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["status"] != "completed" {
|
|
t.Fatalf("status=%v", body["status"])
|
|
}
|
|
if body["total_items"].(float64) != 1 {
|
|
t.Fatalf("total_items=%v", body["total_items"])
|
|
}
|
|
items, ok := body["items"].([]any)
|
|
if !ok || len(items) != 1 {
|
|
t.Fatalf("items=%v", body["items"])
|
|
}
|
|
}
|
|
|
|
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{}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/process", nil)
|
|
s.handleV1ListProcessJobs(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleV1StartProcessGatesBeforeEnsureRaw(t *testing.T) {
|
|
t.Parallel()
|
|
ensureCalled := false
|
|
s := &Server{
|
|
Billing: &billing.Service{}, // Pool nil → entitlements lookup fails before EnsureRaw
|
|
testEnsureRawV1Items: func(context.Context, uuid.UUID, []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) {
|
|
ensureCalled = true
|
|
return nil, nil, nil, nil
|
|
},
|
|
}
|
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
|
ctx = context.WithValue(ctx, ctxUserID, uuid.MustParse("22222222-2222-2222-2222-222222222222"))
|
|
body := `{"processing_type":"enhance","items":[{"ean":"1234567890123","title":"x"}]}`
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/products/process", strings.NewReader(body))
|
|
req = req.WithContext(ctx)
|
|
s.handleV1StartProcess(rec, req)
|
|
if ensureCalled {
|
|
t.Fatal("EnsureRaw must not run when billing gate fails")
|
|
}
|
|
if rec.Code == http.StatusOK || rec.Code == http.StatusCreated || rec.Code == http.StatusAccepted {
|
|
t.Fatalf("expected gate failure, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var env map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("response json: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if _, ok := env["error"]; !ok {
|
|
if _, ok := env["code"]; !ok {
|
|
t.Fatalf("expected error envelope, got %s", rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssertV1ProcessGatesNilBilling(t *testing.T) {
|
|
t.Parallel()
|
|
s := &Server{}
|
|
if err := s.assertV1ProcessGates(context.Background(), uuid.New(), "enhance", 2); err != nil {
|
|
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"])
|
|
}
|
|
}
|