Files
descrybe/apps/api/internal/httpapi/v1_domain_crud_integration_test.go
T

497 lines
17 KiB
Go
Raw Normal View History

package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestV1DomainResourceCRUD exercises public /api/v1 catalog+feed CRUD with
// semi-real merchant data against a live DATABASE_URL (skips when unset).
func TestV1DomainResourceCRUD(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
if dsn == "" {
t.Skip("DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pg, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("postgres: %v", err)
}
defer pg.Close()
companyID := uuid.New()
userID := uuid.New()
prefix := companyID.String()[:8]
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
companyID, "merchant-crud-"+prefix)
if err != nil {
t.Fatalf("seed company: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
})
s := &Server{
Config: config.Config{WebOrigin: "http://localhost:5173"},
Pool: pg,
Catalog: &catalog.Service{Pool: pg},
Feeds: &feeds.Service{Pool: pg},
}
h := mountV1DomainTestRouter(s)
withTenant := func(r *http.Request) *http.Request {
c := context.WithValue(r.Context(), ctxCompanyID, companyID)
c = context.WithValue(c, ctxUserID, userID)
c = context.WithValue(c, ctxRole, "api")
return r.WithContext(c)
}
do := func(method, path, body string) *httptest.ResponseRecorder {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withTenant(req))
return rec
}
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err)
}
return out
}
// --- Categories CRUD ---
catUnique := "electronics-" + prefix
rec := do(http.MethodPost, "/api/v1/categories", fmt.Sprintf(
`{"name":"Electronics","unique_id":%q,"description":"Consumer electronics for Nordic merchants"}`, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create category status=%d body=%s", rec.Code, rec.Body.String())
}
createdCat := decode(t, rec)
catData, _ := createdCat["data"].(map[string]any)
if catData["unique_id"] != catUnique || catData["name"] != "Electronics" {
t.Fatalf("create category data=%v", catData)
}
catUUID, err := uuid.Parse(fmt.Sprint(catData["id"]))
if err != nil {
t.Fatalf("category id: %v", err)
}
childUnique := "headphones-" + prefix
rec = do(http.MethodPost, "/api/v1/categories/create", fmt.Sprintf(
`{"name":"Headphones","unique_id":%q,"parent_id":%q}`, childUnique, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create child category status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/v1/categories?page=1&limit=25&search=Electronics", "")
if rec.Code != http.StatusOK {
t.Fatalf("list categories status=%d body=%s", rec.Code, rec.Body.String())
}
listCat := decode(t, rec)
if _, ok := listCat["data"]; !ok {
t.Fatalf("list categories missing data envelope: %v", listCat)
}
meta, _ := listCat["meta"].(map[string]any)
if meta["page"].(float64) != 1 || meta["limit"].(float64) != 25 {
t.Fatalf("list categories meta=%v", meta)
}
rec = do(http.MethodGet, "/api/v1/categories/"+catUUID.String(), "")
if rec.Code != http.StatusOK {
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 {
t.Fatalf("get category=%v", gotCat)
}
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
`{"name":"Electronics & Audio"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch category status=%d body=%s", rec.Code, rec.Body.String())
}
patchedCat := decode(t, rec)
if patchedCat["name"] != "Electronics & Audio" {
t.Fatalf("patched category=%v", patchedCat)
}
// --- Attributes CRUD ---
rec = do(http.MethodPost, "/api/v1/attributes", fmt.Sprintf(
`{"name":"Color","attribute_key":"color_%s","value_type":"string","category_unique_id":%q,"required":true}`,
prefix, catUnique))
if rec.Code != http.StatusCreated {
t.Fatalf("create attribute status=%d body=%s", rec.Code, rec.Body.String())
}
attrEnv := decode(t, rec)
attrData, _ := attrEnv["data"].(map[string]any)
if attrData["key"] == nil || attrData["category_unique_id"] != catUnique || attrData["required"] != true {
t.Fatalf("create attribute data=%v", attrData)
}
attrID := fmt.Sprint(attrData["id"])
rec = do(http.MethodGet, "/api/v1/attributes?page=1&limit=25&categoryId="+catUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("list attributes status=%d body=%s", rec.Code, rec.Body.String())
}
listAttr := decode(t, rec)
if _, ok := listAttr["data"]; !ok {
t.Fatalf("list attributes missing data: %v", listAttr)
}
rec = do(http.MethodPatch, "/api/v1/attributes/"+attrID, `{"name":"Colour"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch attribute status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Feeds CRUD ---
// Legacy create: name + item_path without URL (avoids live SSRF DNS for merchant hosts).
rec = do(http.MethodPost, "/api/v1/feeds", `{
"name":"Main catalog XML",
"item_path":"channel/item",
"feed_type":"xml",
"sync_interval_minutes":60,
"is_active":true
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create feed status=%d body=%s", rec.Code, rec.Body.String())
}
feedEnv := decode(t, rec)
feedData, _ := feedEnv["data"].(map[string]any)
if feedData["name"] != "Main catalog XML" || feedData["item_path"] != "channel/item" {
t.Fatalf("create feed data=%v", feedData)
}
if feedData["is_active"] != true {
t.Fatalf("create feed is_active should be true after flip, got %v", feedData)
}
feedID := fmt.Sprint(feedData["id"])
rec = do(http.MethodGet, "/api/v1/feeds?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list feeds status=%d body=%s", rec.Code, rec.Body.String())
}
listFeeds := decode(t, rec)
if _, ok := listFeeds["data"]; !ok {
t.Fatalf("list feeds missing data: %v", listFeeds)
}
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusOK {
t.Fatalf("get feed status=%d body=%s", rec.Code, rec.Body.String())
}
getFeed := decode(t, rec)
getFeedData, _ := getFeed["data"].(map[string]any)
if getFeedData["id"] != feedID {
t.Fatalf("get feed=%v", getFeed)
}
rec = do(http.MethodPatch, "/api/v1/feeds/"+feedID, `{"name":"Main catalog XML (Nordic)"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch feed status=%d body=%s", rec.Code, rec.Body.String())
}
patchFeed := decode(t, rec)
if _, hasData := patchFeed["data"]; hasData {
t.Fatalf("PATCH /feeds/{id} should be flat PresentFeed JSON, got envelope: %v", patchFeed)
}
if patchFeed["name"] != "Main catalog XML (Nordic)" {
t.Fatalf("patched feed=%v", patchFeed)
}
rec = do(http.MethodPut, "/api/v1/feeds/"+feedID+"/mappings",
`{"mappings":{"title":"g:title","gtin":"g:gtin","description":"g:description"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put mappings status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Export feeds CRUD ---
rec = do(http.MethodPost, "/api/v1/export-feeds", `{
"name":"Google Shopping XML",
"format":"xml",
"source_feed_id":`+fmt.Sprintf("%q", feedID)+`
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create export feed status=%d body=%s", rec.Code, rec.Body.String())
}
expEnv := decode(t, rec)
expData, _ := expEnv["data"].(map[string]any)
if expData["name"] != "Google Shopping XML" || expData["format"] != "xml" {
t.Fatalf("create export=%v", expData)
}
if expData["public_token"] == nil || expData["public_token"] == "" {
t.Fatalf("export missing public_token: %v", expData)
}
oldPublicToken := fmt.Sprint(expData["public_token"])
expID := fmt.Sprint(expData["id"])
rec = do(http.MethodPost, "/api/v1/export-feeds/"+expID+"/rotate-token", "")
if rec.Code != http.StatusOK {
t.Fatalf("rotate export token status=%d body=%s", rec.Code, rec.Body.String())
}
rotated := decode(t, rec)
newPublicToken := fmt.Sprint(rotated["public_token"])
if newPublicToken == "" || newPublicToken == "<nil>" || newPublicToken == oldPublicToken {
t.Fatalf("rotate did not replace public_token old=%q new=%q body=%v", oldPublicToken, newPublicToken, rotated)
}
if len(newPublicToken) != 64 {
t.Fatalf("rotated public_token len=%d want 64", len(newPublicToken))
}
rec = do(http.MethodGet, "/api/v1/export-feeds?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list export feeds status=%d body=%s", rec.Code, rec.Body.String())
}
listExp := decode(t, rec)
if _, ok := listExp["data"]; !ok {
t.Fatalf("list export missing data: %v", listExp)
}
rec = do(http.MethodGet, "/api/v1/export-feeds/"+expID, "")
if rec.Code != http.StatusOK {
t.Fatalf("get export feed status=%d body=%s", rec.Code, rec.Body.String())
}
getExp := decode(t, rec)
if _, hasData := getExp["data"]; hasData {
t.Fatalf("GET /export-feeds/{id} should be flat JSON, got envelope: %v", getExp)
}
rec = do(http.MethodPatch, "/api/v1/export-feeds/"+expID, `{"name":"Google Shopping XML v2","is_active":true}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch export status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodPut, "/api/v1/export-feeds/"+expID+"/template",
`{"template":{"root":"rss/channel","item":"item","mappings":{"title":"title"}}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put template status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Products list + seed get/patch ---
rec = do(http.MethodGet, "/api/v1/products?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list products status=%d body=%s", rec.Code, rec.Body.String())
}
prodList := decode(t, rec)
if _, ok := prodList["data"]; !ok {
t.Fatalf("list products missing data: %v", prodList)
}
productID := uuid.New()
_, err = pg.Exec(ctx, `
INSERT INTO processed_products (
id, company_id, product_id, name, category, description, status,
processed_name, processed_description, attributes, processed_attributes, feed_id
) VALUES (
$1, $2, $3, $4, $5, $6, 'completed',
$7, $8, '{}'::jsonb, '{}'::jsonb, $9
)`,
productID, companyID, "SKU-1001", "Wireless earbuds", catUnique,
"Original catalog description",
"Acme Wireless Earbuds ANC Black",
"Noise-cancelling wireless earbuds with 24h battery life.",
uuid.MustParse(feedID),
)
if err != nil {
t.Fatalf("seed processed product: %v", err)
}
rec = do(http.MethodGet, "/api/v1/products/"+productID.String(), "")
if rec.Code != http.StatusOK {
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" {
t.Fatalf("get product=%v", gotProd)
}
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch product status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/api/v1/products/quality?page=1&limit=25", "")
if rec.Code != http.StatusOK {
t.Fatalf("list quality status=%d body=%s", rec.Code, rec.Body.String())
}
// --- Campaigns / marketing calendar ---
rec = do(http.MethodGet, "/api/v1/campaigns?year=2026", "")
if rec.Code != http.StatusOK {
t.Fatalf("list campaigns status=%d body=%s", rec.Code, rec.Body.String())
}
camp := decode(t, rec)
campData, _ := camp["data"].(map[string]any)
if campData["year"].(float64) != 2026 {
t.Fatalf("campaigns data=%v", campData)
}
presets, _ := campData["presets"].([]any)
if len(presets) == 0 {
t.Fatalf("expected seasonal presets, got %v", campData)
}
rec = do(http.MethodGet, "/api/v1/marketing/calendar?year=2026", "")
if rec.Code != http.StatusOK {
t.Fatalf("marketing calendar status=%d body=%s", rec.Code, rec.Body.String())
}
cal := decode(t, rec)
if _, hasData := cal["data"]; hasData {
t.Fatalf("GET /marketing/calendar should be flat JSON per OpenAPI, got envelope: %v", cal)
}
rec = do(http.MethodPost, "/api/v1/campaigns/prepare",
`{"preset_id":"black_friday","year":2026,"format":"csv"}`)
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("prepare campaign status=%d body=%s", rec.Code, rec.Body.String())
}
prep := decode(t, rec)
prepData, _ := prep["data"].(map[string]any)
if prepData["preset_id"] != "black_friday" {
t.Fatalf("prepare data=%v", prepData)
}
// --- Deletes (reverse dependency order) ---
rec = do(http.MethodDelete, "/api/v1/export-feeds/"+expID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete export status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodDelete, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete feed status=%d body=%s", rec.Code, rec.Body.String())
}
delFeed := decode(t, rec)
if delFeed["deleted"] != true {
t.Fatalf("delete feed response=%v", delFeed)
}
rec = do(http.MethodDelete, "/api/v1/attributes/"+attrID, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete attribute status=%d body=%s", rec.Code, rec.Body.String())
}
delAttr := decode(t, rec)
delAttrData, _ := delAttr["data"].(map[string]any)
if delAttrData["message"] != "Attribute deleted successfully" {
t.Fatalf("delete attribute=%v", delAttr)
}
rec = do(http.MethodDelete, "/api/v1/categories/"+childUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete child category status=%d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodDelete, "/api/v1/categories/"+catUnique, "")
if rec.Code != http.StatusOK {
t.Fatalf("delete category status=%d body=%s", rec.Code, rec.Body.String())
}
delCat := decode(t, rec)
delCatData, _ := delCat["data"].(map[string]any)
if delCatData["message"] != "Category deleted successfully" {
t.Fatalf("delete category=%v", delCat)
}
// Confirm 404 after delete
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("get deleted feed status=%d want 404 body=%s", rec.Code, rec.Body.String())
}
}
func mountV1DomainTestRouter(s *Server) http.Handler {
r := chi.NewRouter()
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.Patch("/products/{id}", s.handleUpdateProduct)
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
r.Get("/campaigns", s.handleV1ListCampaigns)
r.Post("/campaigns/prepare", s.handleV1PrepareCampaign)
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory)
r.Get("/categories/{id}", s.handleGetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
r.Get("/attributes", s.handleV1ListAttributes)
r.Post("/attributes", s.handleV1CreateAttribute)
r.Post("/attributes/create", s.handleV1CreateAttribute)
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
r.Delete("/attributes/{id}", s.handleV1DeleteAttribute)
r.Get("/feeds", s.handleV1ListFeeds)
r.Post("/feeds", s.handleV1CreateFeed)
r.Get("/feeds/{id}", s.handleV1GetFeed)
r.Patch("/feeds/{id}", s.handleUpdateFeed)
r.Delete("/feeds/{id}", s.handleDeleteFeed)
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
r.Get("/export-feeds", s.handleV1ListExportFeeds)
r.Post("/export-feeds", s.handleV1CreateExportFeed)
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
})
return r
}
func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
needles := []string{
"/categories:",
"/attributes:",
"/feeds:",
"/export-feeds:",
"/export-feeds/{id}/rotate-token:",
"/campaigns:",
"/marketing/calendar:",
"/products:",
"/feeds/{id}/sync-process-sample:",
"Flat category JSON",
"flat PresentFeed",
"flat ProcessedProduct",
"CategoryDetail",
"FeedDeleted",
"FeedMappings",
"is_active:",
}
for _, n := range needles {
if !strings.Contains(body, n) {
t.Fatalf("openapi missing %q", n)
}
}
}