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 == "" || 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) } } }