From 89691fef9121dc94151a2c5539677cbec3531d32 Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Fri, 5 Jun 2026 19:16:20 +0530 Subject: [PATCH 1/7] feat: redesign product schemas to support variants, pricing, polymorphic taxonomies, and seo --- product/graphql.go | 33 +++-- product/handlers.go | 256 +++++++++++++++++++++++++++++++++++---- product/model.go | 236 +++++++++++++++++++++++++++++++++--- product/module.go | 2 +- product/product.graphqls | 135 ++++++++++++++++++++- product/product_test.go | 113 ++++++++++------- product/repository.go | 43 +++++-- search/search_test.go | 48 +++++--- seo/seo.go | 10 ++ taxonomy/model.go | 61 ++++++++++ taxonomy/module.go | 66 ++++++++++ taxonomy/repository.go | 148 ++++++++++++++++++++++ 12 files changed, 1011 insertions(+), 140 deletions(-) create mode 100644 seo/seo.go create mode 100644 taxonomy/model.go create mode 100644 taxonomy/module.go create mode 100644 taxonomy/repository.go diff --git a/product/graphql.go b/product/graphql.go index 1c8d5d3..764f777 100644 --- a/product/graphql.go +++ b/product/graphql.go @@ -37,16 +37,14 @@ func (m *Module) CreateProduct(ctx context.Context, input CreateProductInput) (* return nil, fmt.Errorf("workflow engine does not support synchronous execution") } - desc := "" - if input.Description != nil { - desc = *input.Description + // Convert input struct to map[string]any for workflow context + inputBytes, err := json.Marshal(input) + if err != nil { + return nil, err } - - workflowInput := map[string]any{ - "id": input.ID, - "name": input.Name, - "description": desc, - "price": input.Price, + var workflowInput map[string]any + if err := json.Unmarshal(inputBytes, &workflowInput); err != nil { + return nil, err } execID := "create_prod_" + uuid.New().String() @@ -79,18 +77,15 @@ func (m *Module) UpdateProduct(ctx context.Context, id string, input UpdateProdu return nil, fmt.Errorf("workflow engine does not support synchronous execution") } - workflowInput := map[string]any{ - "id": id, - } - if input.Name != nil { - workflowInput["name"] = *input.Name - } - if input.Description != nil { - workflowInput["description"] = *input.Description + inputBytes, err := json.Marshal(input) + if err != nil { + return nil, err } - if input.Price != nil { - workflowInput["price"] = *input.Price + var workflowInput map[string]any + if err := json.Unmarshal(inputBytes, &workflowInput); err != nil { + return nil, err } + workflowInput["id"] = id execID := "update_prod_" + uuid.New().String() results, err := executor.ExecuteSync(ctx, execID, "product.update", workflowInput) diff --git a/product/handlers.go b/product/handlers.go index ece966a..ca033dc 100644 --- a/product/handlers.go +++ b/product/handlers.go @@ -2,8 +2,10 @@ package product import ( "context" + "encoding/json" "fmt" + "github.com/GoHyperrr/commerce/seo" "github.com/GoHyperrr/mdk" ) @@ -19,14 +21,31 @@ func (m *Module) ValidateProduct(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("missing product input") } - name, _ := productData["name"].(string) - if name == "" { + ba, err := json.Marshal(productData) + if err != nil { + return nil, fmt.Errorf("failed to process input: %w", err) + } + + var in CreateProductInput + if err := json.Unmarshal(ba, &in); err != nil { + return nil, fmt.Errorf("failed to parse product input: %w", err) + } + + if in.Name == "" { return nil, fmt.Errorf("product name is required") } - price, _ := productData["price"].(float64) - if price <= 0 { - return nil, fmt.Errorf("product price must be positive") + if in.Handle == "" { + return nil, fmt.Errorf("product handle is required") + } + + for _, v := range in.Variants { + if v.Title == "" { + return nil, fmt.Errorf("variant title is required") + } + if v.Price < 0 { + return nil, fmt.Errorf("variant price cannot be negative") + } } return productData, nil @@ -39,7 +58,6 @@ func (m *Module) PersistProduct(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("invalid input type") } - // Result from previous step "validate" resRaw, ok := data["validate"] if !ok { return nil, fmt.Errorf("missing validated product data") @@ -49,11 +67,133 @@ func (m *Module) PersistProduct(ctx context.Context, input any) (any, error) { return nil, fmt.Errorf("invalid validated product data format") } + ba, err := json.Marshal(validatedData) + if err != nil { + return nil, err + } + var in CreateProductInput + if err := json.Unmarshal(ba, &in); err != nil { + return nil, err + } + + pDetails := ProductDetails{} + if in.Details != nil { + pDetails = ProductDetails{ + SKU: in.Details.SKU, + Barcode: in.Details.Barcode, + HSNCode: in.Details.HSNCode, + Weight: in.Details.Weight, + Length: in.Details.Length, + Width: in.Details.Width, + Height: in.Details.Height, + FileUrl: in.Details.FileUrl, + MaxDownloads: in.Details.MaxDownloads, + DownloadExpirationHours: in.Details.DownloadExpirationHours, + } + } + + pSEO := seo.SEO{} + if in.SEO != nil { + if in.SEO.MetaTitle != nil { + pSEO.MetaTitle = *in.SEO.MetaTitle + } + if in.SEO.MetaDescription != nil { + pSEO.MetaDescription = *in.SEO.MetaDescription + } + if in.SEO.MetaKeywords != nil { + pSEO.MetaKeywords = *in.SEO.MetaKeywords + } + if in.SEO.MetaImage != nil { + pSEO.MetaImage = *in.SEO.MetaImage + } + } + + pType := "PHYSICAL" + if in.Type != nil { + pType = *in.Type + } + pStatus := "DRAFT" + if in.Status != nil { + pStatus = *in.Status + } + pAISystemContext := "" + if in.AISystemContext != nil { + pAISystemContext = *in.AISystemContext + } + p := &Product{ - ID: validatedData["id"].(string), - Name: validatedData["name"].(string), - Description: validatedData["description"].(string), - Price: validatedData["price"].(float64), + ID: in.ID, + Name: in.Name, + Handle: in.Handle, + Description: "", + Type: pType, + Status: pStatus, + Details: pDetails, + SEO: pSEO, + Metadata: in.Metadata, + AISystemContext: pAISystemContext, + } + if in.Description != nil { + p.Description = *in.Description + } + + for _, opt := range in.Options { + p.Options = append(p.Options, ProductOption{ + Name: opt.Name, + Values: opt.Values, + }) + } + + for _, v := range in.Variants { + vDetails := ProductDetails{} + if v.Details != nil { + vDetails = ProductDetails{ + SKU: v.Details.SKU, + Barcode: v.Details.Barcode, + HSNCode: v.Details.HSNCode, + Weight: v.Details.Weight, + Length: v.Details.Length, + Width: v.Details.Width, + Height: v.Details.Height, + FileUrl: v.Details.FileUrl, + MaxDownloads: v.Details.MaxDownloads, + DownloadExpirationHours: v.Details.DownloadExpirationHours, + } + } + + var vOpts []VariantOption + for _, o := range v.Options { + vOpts = append(vOpts, VariantOption{ + Name: o.Name, + Value: o.Value, + }) + } + + p.Variants = append(p.Variants, ProductVariant{ + Title: v.Title, + Price: v.Price, + CompareAtPrice: v.CompareAtPrice, + Details: vDetails, + Options: vOpts, + Metadata: v.Metadata, + }) + } + + for _, img := range in.Images { + alt := "" + if img.AltText != nil { + alt = *img.AltText + } + sort := 0 + if img.SortOrder != nil { + sort = *img.SortOrder + } + p.Images = append(p.Images, ProductImage{ + VariantID: img.VariantID, + URL: img.URL, + AltText: alt, + SortOrder: sort, + }) } if err := m.repo.Save(ctx, p); err != nil { @@ -81,18 +221,82 @@ func (m *Module) UpdateProductDetails(ctx context.Context, input any) (any, erro return nil, fmt.Errorf("product not found: %w", err) } - if name, ok := workflowInput["name"].(string); ok && name != "" { - p.Name = name + ba, err := json.Marshal(workflowInput) + if err != nil { + return nil, err } - if desc, ok := workflowInput["description"].(string); ok && desc != "" { - p.Description = desc + var in UpdateProductInput + if err := json.Unmarshal(ba, &in); err != nil { + return nil, err + } + + if in.Name != nil { + p.Name = *in.Name } - - if priceRaw, ok := workflowInput["price"]; ok { - if pf, ok := priceRaw.(float64); ok && pf > 0 { - p.Price = pf - } else if pi, ok := priceRaw.(int); ok && pi > 0 { - p.Price = float64(pi) + if in.Handle != nil { + p.Handle = *in.Handle + } + if in.Description != nil { + p.Description = *in.Description + } + if in.Type != nil { + p.Type = *in.Type + } + if in.Status != nil { + p.Status = *in.Status + } + if in.AISystemContext != nil { + p.AISystemContext = *in.AISystemContext + } + if in.Metadata != nil { + p.Metadata = in.Metadata + } + + if in.Details != nil { + if in.Details.SKU != nil { + p.Details.SKU = in.Details.SKU + } + if in.Details.Barcode != nil { + p.Details.Barcode = in.Details.Barcode + } + if in.Details.HSNCode != nil { + p.Details.HSNCode = in.Details.HSNCode + } + if in.Details.Weight != nil { + p.Details.Weight = in.Details.Weight + } + if in.Details.Length != nil { + p.Details.Length = in.Details.Length + } + if in.Details.Width != nil { + p.Details.Width = in.Details.Width + } + if in.Details.Height != nil { + p.Details.Height = in.Details.Height + } + if in.Details.FileUrl != nil { + p.Details.FileUrl = in.Details.FileUrl + } + if in.Details.MaxDownloads != nil { + p.Details.MaxDownloads = in.Details.MaxDownloads + } + if in.Details.DownloadExpirationHours != nil { + p.Details.DownloadExpirationHours = in.Details.DownloadExpirationHours + } + } + + if in.SEO != nil { + if in.SEO.MetaTitle != nil { + p.SEO.MetaTitle = *in.SEO.MetaTitle + } + if in.SEO.MetaDescription != nil { + p.SEO.MetaDescription = *in.SEO.MetaDescription + } + if in.SEO.MetaKeywords != nil { + p.SEO.MetaKeywords = *in.SEO.MetaKeywords + } + if in.SEO.MetaImage != nil { + p.SEO.MetaImage = *in.SEO.MetaImage } } @@ -105,8 +309,12 @@ func (m *Module) UpdateProductDetails(ctx context.Context, input any) (any, erro // ValidateProductStep wraps ValidateProduct to mdk.StepHandler. func (m *Module) ValidateProductStep(sCtx mdk.StepContext) mdk.StepResult { + var inp any = sCtx.Input + if wfInput, ok := sCtx.Input["input"]; ok { + inp = wfInput + } res, err := m.ValidateProduct(sCtx.Ctx, map[string]any{ - "input": sCtx.Input, + "input": inp, }) if err != nil { return mdk.StepResult{Err: err} @@ -127,8 +335,12 @@ func (m *Module) PersistProductStep(sCtx mdk.StepContext) mdk.StepResult { // UpdateProductDetailsStep wraps UpdateProductDetails to mdk.StepHandler. func (m *Module) UpdateProductDetailsStep(sCtx mdk.StepContext) mdk.StepResult { + var inp any = sCtx.Input + if wfInput, ok := sCtx.Input["input"]; ok { + inp = wfInput + } res, err := m.UpdateProductDetails(sCtx.Ctx, map[string]any{ - "input": sCtx.Input, + "input": inp, }) if err != nil { return mdk.StepResult{Err: err} diff --git a/product/model.go b/product/model.go index 3ec32b7..2b97505 100644 --- a/product/model.go +++ b/product/model.go @@ -1,35 +1,233 @@ package product import ( + "database/sql/driver" + "encoding/json" + "errors" "time" + "github.com/GoHyperrr/commerce/seo" + "github.com/GoHyperrr/mdk" + "github.com/google/uuid" "gorm.io/gorm" ) -// Product represents a commerce item in the catalog. +// StringList represents a slice of strings serialized as JSON text in the database. +type StringList []string + +// Value returns the driver Value. +func (sl StringList) Value() (driver.Value, error) { + if sl == nil { + return "[]", nil + } + ba, err := json.Marshal(sl) + if err != nil { + return nil, err + } + return string(ba), nil +} + +// Scan scans value into StringList. +func (sl *StringList) Scan(val interface{}) error { + if val == nil { + *sl = nil + return nil + } + var ba []byte + switch v := val.(type) { + case []byte: + ba = v + case string: + ba = []byte(v) + default: + return errors.New("failed to scan StringList: invalid type") + } + return json.Unmarshal(ba, sl) +} + +// ProductDetails groups package shipping specifications and digital download details. +type ProductDetails struct { + SKU *string `gorm:"column:sku" json:"sku,omitempty"` + Barcode *string `gorm:"column:barcode" json:"barcode,omitempty"` + HSNCode *string `gorm:"column:hsn_code" json:"hsn_code,omitempty"` + Weight *float64 `gorm:"column:weight" json:"weight,omitempty"` + Length *float64 `gorm:"column:length" json:"length,omitempty"` + Width *float64 `gorm:"column:width" json:"width,omitempty"` + Height *float64 `gorm:"column:height" json:"height,omitempty"` + FileUrl *string `gorm:"column:file_url" json:"file_url,omitempty"` + MaxDownloads *int `gorm:"column:max_downloads" json:"max_downloads,omitempty"` + DownloadExpirationHours *int `gorm:"column:download_expiration_hours" json:"download_expiration_hours,omitempty"` +} + +// Product represents the main catalog entity. type Product struct { - ID string `gorm:"primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - Description string `json:"description"` - Price float64 `gorm:"not null" json:"price"` - Currency string `gorm:"default:USD" json:"currency"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID string `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Handle string `gorm:"uniqueIndex;not null" json:"handle"` + Description string `json:"description"` + Type string `gorm:"default:PHYSICAL" json:"type"` // PHYSICAL, DIGITAL, SERVICE + Status string `gorm:"default:DRAFT" json:"status"` // DRAFT, PUBLISHED, ARCHIVED + Details ProductDetails `gorm:"embedded;embeddedPrefix:details_" json:"details"` + SEO seo.SEO `gorm:"embedded;embeddedPrefix:seo_" json:"seo"` + Metadata mdk.Metadata `gorm:"type:text" json:"metadata"` + AISystemContext string `json:"ai_system_context"` + Options []ProductOption `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"options"` + Variants []ProductVariant `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"variants"` + Images []ProductImage `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"images"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (p *Product) BeforeCreate(tx *gorm.DB) error { + if p.ID == "" { + p.ID = uuid.New().String() + } + return nil +} + +// ProductOption defines the option names (e.g., Color, Size) and allowed values for variations. +type ProductOption struct { + ID string `gorm:"primaryKey" json:"id"` + ProductID string `gorm:"index;not null" json:"product_id"` + Name string `gorm:"not null" json:"name"` + Values StringList `gorm:"type:text" json:"values"` +} + +func (po *ProductOption) BeforeCreate(tx *gorm.DB) error { + if po.ID == "" { + po.ID = uuid.New().String() + } + return nil +} + +// ProductVariant represents a stockable configuration of a product with its own price. +type ProductVariant struct { + ID string `gorm:"primaryKey" json:"id"` + ProductID string `gorm:"index;not null" json:"product_id"` + Title string `gorm:"not null" json:"title"` + Price float64 `gorm:"not null" json:"price"` + CompareAtPrice *float64 `json:"compare_at_price,omitempty"` + Details ProductDetails `gorm:"embedded;embeddedPrefix:details_" json:"details"` + Options []VariantOption `gorm:"foreignKey:VariantID;constraint:OnDelete:CASCADE" json:"options"` + Metadata mdk.Metadata `gorm:"type:text" json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +func (pv *ProductVariant) BeforeCreate(tx *gorm.DB) error { + if pv.ID == "" { + pv.ID = uuid.New().String() + } + return nil +} + +// VariantOption defines the key-value options chosen for a specific variant (e.g. Size: "XL"). +type VariantOption struct { + ID string `gorm:"primaryKey" json:"id"` + VariantID string `gorm:"index;not null" json:"variant_id"` + Name string `gorm:"not null" json:"name"` + Value string `gorm:"not null" json:"value"` +} + +func (vo *VariantOption) BeforeCreate(tx *gorm.DB) error { + if vo.ID == "" { + vo.ID = uuid.New().String() + } + return nil +} + +// ProductImage represents media links associated with the product or particular variants. +type ProductImage struct { + ID string `gorm:"primaryKey" json:"id"` + ProductID string `gorm:"index;not null" json:"product_id"` + VariantID *string `gorm:"index" json:"variant_id,omitempty"` + URL string `gorm:"not null" json:"url"` + AltText string `json:"alt_text"` + SortOrder int `gorm:"default:0" json:"sort_order"` +} + +func (pi *ProductImage) BeforeCreate(tx *gorm.DB) error { + if pi.ID == "" { + pi.ID = uuid.New().String() + } + return nil +} + +// GraphQL input mapping structures. Defined locally in the commerce/product package +// to avoid importing hyperrr-specific structures, preventing circular compilation loops. + type CreateProductInput struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Price float64 `json:"price"` - Currency *string `json:"currency,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Handle string `json:"handle"` + Description *string `json:"description,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Details *ProductDetailsInput `json:"details,omitempty"` + SEO *SEOInput `json:"seo,omitempty"` + Metadata mdk.Metadata `json:"metadata,omitempty"` + AISystemContext *string `json:"ai_system_context,omitempty"` + Options []ProductOptionInput `json:"options,omitempty"` + Variants []CreateProductVariantInput `json:"variants,omitempty"` + Images []ProductImageInput `json:"images,omitempty"` } -type UpdateProductInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Price *float64 `json:"price,omitempty"` - Currency *string `json:"currency,omitempty"` +type ProductDetailsInput struct { + SKU *string `json:"sku,omitempty"` + Barcode *string `json:"barcode,omitempty"` + HSNCode *string `json:"hsn_code,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Length *float64 `json:"length,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` + FileUrl *string `json:"file_url,omitempty"` + MaxDownloads *int `json:"max_downloads,omitempty"` + DownloadExpirationHours *int `json:"download_expiration_hours,omitempty"` } +type SEOInput struct { + MetaTitle *string `json:"metaTitle,omitempty"` + MetaDescription *string `json:"metaDescription,omitempty"` + MetaKeywords *string `json:"metaKeywords,omitempty"` + MetaImage *string `json:"metaImage,omitempty"` +} + +type ProductOptionInput struct { + Name string `json:"name"` + Values []string `json:"values"` +} + +type CreateProductVariantInput struct { + Title string `json:"title"` + Price float64 `json:"price"` + CompareAtPrice *float64 `json:"compare_at_price,omitempty"` + Details *ProductDetailsInput `json:"details,omitempty"` + Options []VariantOptionInput `json:"options,omitempty"` + Metadata mdk.Metadata `json:"metadata,omitempty"` +} + +type VariantOptionInput struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type ProductImageInput struct { + VariantID *string `json:"variant_id,omitempty"` + URL string `json:"url"` + AltText *string `json:"alt_text,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` +} + +type UpdateProductInput struct { + Name *string `json:"name,omitempty"` + Handle *string `json:"handle,omitempty"` + Description *string `json:"description,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` + Details *ProductDetailsInput `json:"details,omitempty"` + SEO *SEOInput `json:"seo,omitempty"` + Metadata mdk.Metadata `json:"metadata,omitempty"` + AISystemContext *string `json:"ai_system_context,omitempty"` +} diff --git a/product/module.go b/product/module.go index e29cc88..a1f9697 100644 --- a/product/module.go +++ b/product/module.go @@ -55,7 +55,7 @@ func (m *Module) Shutdown(ctx context.Context) error { } func (m *Module) Models() []any { - return []any{&Product{}} + return []any{&Product{}, &ProductOption{}, &ProductVariant{}, &VariantOption{}, &ProductImage{}} } func (m *Module) Routes() []mdk.Route { diff --git a/product/product.graphqls b/product/product.graphqls index 016fc5c..c8aec92 100644 --- a/product/product.graphqls +++ b/product/product.graphqls @@ -1,3 +1,5 @@ +scalar Map + extend type Query { getProduct(id: ID!): Product listProducts: [Product!]! @@ -12,22 +14,143 @@ extend type Mutation { type Product { id: ID! name: String! - description: String + handle: String! + description: String! + type: String! + status: String! + details: ProductDetails! + seo: SEO! + metadata: Map + aiSystemContext: String! + options: [ProductOption!]! + variants: [ProductVariant!]! + images: [ProductImage!]! +} + +type ProductDetails { + sku: String + barcode: String + hsnCode: String + weight: Float + length: Float + width: Float + height: Float + fileUrl: String + maxDownloads: Int + downloadExpirationHours: Int +} + +type SEO { + metaTitle: String! + metaDescription: String! + metaKeywords: String! + metaImage: String! +} + +type ProductOption { + id: ID! + productId: String! + name: String! + values: [String!]! +} + +type ProductVariant { + id: ID! + productId: String! + title: String! price: Float! - currency: String! + compareAtPrice: Float + details: ProductDetails! + options: [VariantOption!]! + metadata: Map } -input CreateProductInput { +type VariantOption { id: ID! + variantId: String! name: String! + value: String! +} + +type ProductImage { + id: ID! + productId: String! + variantId: String + url: String! + altText: String! + sortOrder: Int! +} + +input CreateProductInput { + id: ID + name: String! + handle: String! description: String + type: String + status: String + details: ProductDetailsInput + seo: SEOInput + metadata: Map + aiSystemContext: String + options: [ProductOptionInput!] + variants: [CreateProductVariantInput!] + images: [ProductImageInput!] +} + +input ProductDetailsInput { + sku: String + barcode: String + hsnCode: String + weight: Float + length: Float + width: Float + height: Float + fileUrl: String + maxDownloads: Int + downloadExpirationHours: Int +} + +input SEOInput { + metaTitle: String + metaDescription: String + metaKeywords: String + metaImage: String +} + +input ProductOptionInput { + name: String! + values: [String!]! +} + +input CreateProductVariantInput { + title: String! price: Float! - currency: String + compareAtPrice: Float + details: ProductDetailsInput + options: [VariantOptionInput!] + metadata: Map +} + +input VariantOptionInput { + name: String! + value: String! +} + +input ProductImageInput { + variantId: String + url: String! + altText: String + sortOrder: Int } input UpdateProductInput { name: String + handle: String description: String - price: Float - currency: String + type: String + status: String + details: ProductDetailsInput + seo: SEOInput + metadata: Map + aiSystemContext: String } diff --git a/product/product_test.go b/product/product_test.go index 380e905..a82f8d7 100644 --- a/product/product_test.go +++ b/product/product_test.go @@ -2,7 +2,6 @@ package product import ( "context" - "os" "testing" "github.com/GoHyperrr/mdk" @@ -11,10 +10,7 @@ import ( ) func TestProductWorkflow(t *testing.T) { - dbFile := "product_test.db" - defer os.Remove(dbFile) - - database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) + database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) rt := mdk.NewTestRuntime(database) mod := NewModule() @@ -29,8 +25,11 @@ func TestProductWorkflow(t *testing.T) { input := map[string]any{ "id": "p1", "name": "Test Product", + "handle": "test-product", "description": "Desc", - "price": 100.0, + "variants": []any{ + map[string]any{"title": "Default", "price": 100.0}, + }, } res, err := runner.ExecuteSync(context.Background(), "create_1", "product.create", input) @@ -40,9 +39,18 @@ func TestProductWorkflow(t *testing.T) { resMap := res["persist"].(map[string]any) p, ok := resMap["product"].(*Product) - if !ok || p.Name != "Test Product" { + if !ok || p.Name != "Test Product" || p.Handle != "test-product" { t.Errorf("expected Test Product, got %v", res["persist"]) } + + // Verify preloaded relations + fetched, err := mod.Repo().GetByID(context.Background(), "p1") + if err != nil { + t.Fatalf("failed to get product by ID: %v", err) + } + if len(fetched.Variants) != 1 || fetched.Variants[0].Price != 100.0 { + t.Errorf("unexpected variant: %+v", fetched.Variants) + } }) t.Run("Invalid Product", func(t *testing.T) { @@ -54,8 +62,8 @@ func TestProductWorkflow(t *testing.T) { _ = runner.Register(wf) input := map[string]any{ - "name": "", - "price": -10.0, + "name": "", + "handle": "", } _, err := runner.ExecuteSync(context.Background(), "create_invalid", "test-invalid-wf", input) @@ -65,9 +73,7 @@ func TestProductWorkflow(t *testing.T) { }) t.Run("Handler Error Cases", func(t *testing.T) { - dbFile := "prod_err_test.db" - defer os.Remove(dbFile) - database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) + database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) rt := mdk.NewTestRuntime(database) mod := NewModule() @@ -76,53 +82,80 @@ func TestProductWorkflow(t *testing.T) { // 1. ValidateProduct - Invalid Input _, err := mod.ValidateProduct(context.Background(), "string") - if err == nil { t.Error("expected error for invalid input type") } + if err == nil { + t.Error("expected error for invalid input type") + } _, err = mod.ValidateProduct(context.Background(), map[string]any{"wrong": 1}) - if err == nil { t.Error("expected error for missing workflow input") } + if err == nil { + t.Error("expected error for missing workflow input") + } // 2. PersistProduct - Invalid Input _, err = mod.PersistProduct(context.Background(), "string") - if err == nil { t.Error("expected error for invalid input type") } + if err == nil { + t.Error("expected error for invalid input type") + } _, err = mod.PersistProduct(context.Background(), map[string]any{"wrong": 1}) - if err == nil { t.Error("expected error for missing validation results") } + if err == nil { + t.Error("expected error for missing validation results") + } // 3. UpdateProductDetails - Invalid Input _, err = mod.UpdateProductDetails(context.Background(), "string") - if err == nil { t.Error("expected error for invalid input type") } + if err == nil { + t.Error("expected error for invalid input type") + } _, err = mod.UpdateProductDetails(context.Background(), map[string]any{"wrong": 1}) - if err == nil { t.Error("expected error for missing workflow input") } - + if err == nil { + t.Error("expected error for missing workflow input") + } + _, err = mod.UpdateProductDetails(context.Background(), map[string]any{"input": "invalid"}) - if err == nil { t.Error("expected error for invalid input format") } + if err == nil { + t.Error("expected error for invalid input format") + } // 4. UpdateProductDetails - Product Not Found _, err = mod.UpdateProductDetails(context.Background(), map[string]any{"input": map[string]any{"id": "ghost"}}) - if err == nil { t.Error("expected error for non-existent product") } + if err == nil { + t.Error("expected error for non-existent product") + } // 5. PersistProduct - Save Failure badDB, _ := gorm.Open(sqlite.Open("fail_save.db"), &gorm.Config{}) sqlDB, _ := badDB.DB() sqlDB.Close() - + mod.repo = NewRepository(badDB) failInput := map[string]any{ "validate": map[string]any{ - "id": "p_fail", "name": "Fail", "description": "", "price": 10.0, + "id": "p_fail", "name": "Fail", "handle": "fail", "description": "", }, } _, err = mod.PersistProduct(context.Background(), failInput) - if err == nil { t.Error("expected error for failed save in PersistProduct") } + if err == nil { + t.Error("expected error for failed save in PersistProduct") + } // 6. UpdateProductDetails - Success (All fields) mod.repo = NewRepository(database) // Ensure we use the good DB - p := &Product{ID: "p_update", Name: "Old Name", Description: "Old Desc", Price: 50.0} - database.Save(p) + p := &Product{ + ID: "p_update", + Name: "Old Name", + Handle: "old-name", + Description: "Old Desc", + Variants: []ProductVariant{ + {ID: "v_update", Title: "Default", Price: 50.0}, + }, + } + mod.Repo().Save(context.Background(), p) + updateAllInput := map[string]any{ "input": map[string]any{ "id": "p_update", "name": "New Name", + "handle": "new-name", "description": "New Desc", - "price": 75.0, }, } _, err = mod.UpdateProductDetails(context.Background(), updateAllInput) @@ -130,32 +163,22 @@ func TestProductWorkflow(t *testing.T) { t.Errorf("UpdateProductDetails failed: %v", err) } updated, _ := mod.repo.GetByID(context.Background(), "p_update") - if updated.Name != "New Name" || updated.Description != "New Desc" || updated.Price != 75.0 { + if updated.Name != "New Name" || updated.Description != "New Desc" || updated.Handle != "new-name" { t.Errorf("UpdateProductDetails did not update all fields: %+v", updated) } // 7. UpdateProductDetails - Save Failure mod.repo = NewRepository(badDB) _, err = mod.UpdateProductDetails(context.Background(), updateAllInput) - if err == nil { t.Error("expected error for failed save in UpdateProductDetails") } - mod.repo = NewRepository(database) // Restore to good DB - - // 8. UpdateProductDetails - Int price - updateIntInput := map[string]any{ - "input": map[string]any{ - "id": "p_update", - "price": 99, - }, + if err == nil { + t.Error("expected error for failed save in UpdateProductDetails") } - _, err = mod.UpdateProductDetails(context.Background(), updateIntInput) - if err != nil { t.Errorf("UpdateProductDetails failed with int price: %v", err) } - updated, _ = mod.repo.GetByID(context.Background(), "p_update") - if updated.Price != 99.0 { t.Errorf("expected 99.0, got %v", updated.Price) } + mod.repo = NewRepository(database) // Restore to good DB - // 9. PersistProduct - Invalid validated data format + // 8. PersistProduct - Invalid validated data format _, err = mod.PersistProduct(context.Background(), map[string]any{"validate": "not-a-map"}) - if err == nil { t.Error("expected error for invalid validated data format") } - - os.Remove("fail_save.db") + if err == nil { + t.Error("expected error for invalid validated data format") + } }) } diff --git a/product/repository.go b/product/repository.go index 681f148..c720d29 100644 --- a/product/repository.go +++ b/product/repository.go @@ -6,39 +6,64 @@ import ( "gorm.io/gorm" ) -// Repository handles data access for products. +// Repository handles database operations for products, variants, and catalog entities. type Repository struct { db *gorm.DB } -// NewRepository creates a new Repository. +// NewRepository creates a new product repository. func NewRepository(database *gorm.DB) *Repository { return &Repository{db: database} } -// Save persists a product to the database. +// Save persists a product and all of its nested relationships (options, variants, images). func (r *Repository) Save(ctx context.Context, p *Product) error { - return r.db.WithContext(ctx).Save(p).Error + return r.db.WithContext(ctx).Session(&gorm.Session{FullSaveAssociations: true}).Save(p).Error } -// GetByID retrieves a product by its ID. +// GetByID retrieves a product by its ID, preloading all options, variants, options per variant, and images. func (r *Repository) GetByID(ctx context.Context, id string) (*Product, error) { var p Product - err := r.db.WithContext(ctx).First(&p, "id = ?", id).Error + err := r.db.WithContext(ctx). + Preload("Options"). + Preload("Variants"). + Preload("Variants.Options"). + Preload("Images"). + First(&p, "id = ?", id).Error if err != nil { return nil, err } return &p, nil } -// List returns all products. +// GetByHandle retrieves a product by its unique URL slug/handle, preloading all relationships. +func (r *Repository) GetByHandle(ctx context.Context, handle string) (*Product, error) { + var p Product + err := r.db.WithContext(ctx). + Preload("Options"). + Preload("Variants"). + Preload("Variants.Options"). + Preload("Images"). + First(&p, "handle = ?", handle).Error + if err != nil { + return nil, err + } + return &p, nil +} + +// List retrieves all products with all of their relationships preloaded. func (r *Repository) List(ctx context.Context) ([]*Product, error) { var products []*Product - err := r.db.WithContext(ctx).Find(&products).Error + err := r.db.WithContext(ctx). + Preload("Options"). + Preload("Variants"). + Preload("Variants.Options"). + Preload("Images"). + Find(&products).Error return products, err } -// Delete removes a product from the database. +// Delete removes a product from the database (cascade deletes options, variants, images). func (r *Repository) Delete(ctx context.Context, id string) error { return r.db.WithContext(ctx).Delete(&Product{}, "id = ?", id).Error } diff --git a/search/search_test.go b/search/search_test.go index fe80dbf..7454d7b 100644 --- a/search/search_test.go +++ b/search/search_test.go @@ -2,7 +2,6 @@ package search import ( "context" - "os" "testing" "github.com/GoHyperrr/commerce/product" @@ -12,20 +11,17 @@ import ( ) func TestSearchModule(t *testing.T) { - dbFile := "search_test.db" - defer os.Remove(dbFile) - - database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) + database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) rt := mdk.NewTestRuntime(database) // Mock Product module prodMod := product.NewModule() _ = prodMod.Init(context.Background(), rt) - + mod := NewModule() _ = mod.Init(context.Background(), rt) mod.SetProductModule(prodMod) - + var models []any models = append(models, prodMod.Models()...) models = append(models, mod.Models()...) @@ -33,8 +29,22 @@ func TestSearchModule(t *testing.T) { runner := rt.Workflows().(*mdk.TestWorkflowEngine) // Seed products - prodMod.Repo().Save(context.Background(), &product.Product{ID: "p1", Name: "Go Gopher", Price: 10.0}) - prodMod.Repo().Save(context.Background(), &product.Product{ID: "p2", Name: "Rust Crab", Price: 15.0}) + prodMod.Repo().Save(context.Background(), &product.Product{ + ID: "p1", + Name: "Go Gopher", + Handle: "go-gopher", + Variants: []product.ProductVariant{ + {ID: "v1", Title: "Default", Price: 10.0}, + }, + }) + prodMod.Repo().Save(context.Background(), &product.Product{ + ID: "p2", + Name: "Rust Crab", + Handle: "rust-crab", + Variants: []product.ProductVariant{ + {ID: "v2", Title: "Default", Price: 15.0}, + }, + }) t.Run("Search Success", func(t *testing.T) { wf := mdk.Workflow{ @@ -50,12 +60,6 @@ func TestSearchModule(t *testing.T) { t.Fatalf("workflow failed: %v", err) } - // The workflow output is registered under the step ID "search" or "search_step" - // Let's verify which key is used: look at search step result. - // Wait, the original code had: results := res["search"].([]*product.Product) - // Let's check if the step ID in the original was "search_step" but it read "search". - // Oh! Let's check how search handler sets results. If it puts it in map, let's verify. - // Let's check if we need to modify this or keep it. Let's keep it first, or let's verify. results, ok := res["search"].([]*product.Product) if !ok { results = res["search_step"].([]*product.Product) @@ -67,16 +71,22 @@ func TestSearchModule(t *testing.T) { t.Run("Handler Error Cases", func(t *testing.T) { _, err := mod.SearchProducts(context.Background(), "string") - if err == nil { t.Error("expected error for invalid input type") } - + if err == nil { + t.Error("expected error for invalid input type") + } + _, err = mod.SearchProducts(context.Background(), map[string]any{"wrong": 1}) - if err == nil { t.Error("expected error for missing workflow input") } + if err == nil { + t.Error("expected error for missing workflow input") + } mNoProd := NewModule() mNoProdDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) mNoProdRt := mdk.NewTestRuntime(mNoProdDB) _ = mNoProd.Init(context.Background(), mNoProdRt) _, err = mNoProd.SearchProducts(context.Background(), map[string]any{"input": map[string]any{"query": "x"}}) - if err == nil { t.Error("expected error for missing product module") } + if err == nil { + t.Error("expected error for missing product module") + } }) } diff --git a/seo/seo.go b/seo/seo.go new file mode 100644 index 0000000..09840ce --- /dev/null +++ b/seo/seo.go @@ -0,0 +1,10 @@ +package seo + +// SEO represents generic search engine optimization metadata that can be +// embedded in other models (such as products, taxonomy terms, or pages). +type SEO struct { + MetaTitle string `gorm:"column:meta_title" json:"metaTitle"` + MetaDescription string `gorm:"column:meta_description" json:"metaDescription"` + MetaKeywords string `gorm:"column:meta_keywords" json:"metaKeywords"` + MetaImage string `gorm:"column:meta_image" json:"metaImage"` +} diff --git a/taxonomy/model.go b/taxonomy/model.go new file mode 100644 index 0000000..a803ee1 --- /dev/null +++ b/taxonomy/model.go @@ -0,0 +1,61 @@ +package taxonomy + +import ( + "time" + + "github.com/GoHyperrr/commerce/seo" + "github.com/GoHyperrr/mdk" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Taxonomy represents a decoupled categorization system (e.g., categories, tags, collections, brands). +type Taxonomy struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `gorm:"uniqueIndex;not null" json:"code"` + Type string `json:"type"` // e.g. "category", "tag", "collection", "brand" + Metadata mdk.Metadata `gorm:"type:text" json:"metadata"` + Terms []TaxonomyTerm `gorm:"foreignKey:TaxonomyID;constraint:OnDelete:CASCADE" json:"terms"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (t *Taxonomy) BeforeCreate(tx *gorm.DB) error { + if t.ID == "" { + t.ID = uuid.New().String() + } + return nil +} + +// TaxonomyTerm represents an individual hierarchical term inside a Taxonomy. +type TaxonomyTerm struct { + ID string `gorm:"primaryKey" json:"id"` + TaxonomyID string `gorm:"index;not null" json:"taxonomy_id"` + ParentID *string `gorm:"index" json:"parent_id"` + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"uniqueIndex;not null" json:"slug"` + Description string `json:"description"` + SEO seo.SEO `gorm:"embedded;embeddedPrefix:seo_"` + Metadata mdk.Metadata `gorm:"type:text" json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Children []TaxonomyTerm `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +func (tt *TaxonomyTerm) BeforeCreate(tx *gorm.DB) error { + if tt.ID == "" { + tt.ID = uuid.New().String() + } + return nil +} + +// TaxonomyRelation creates a polymorphic link between a term and any other entity (e.g. product). +type TaxonomyRelation struct { + TermID string `gorm:"primaryKey" json:"term_id"` + ResourceID string `gorm:"primaryKey" json:"resource_id"` + ResourceType string `gorm:"primaryKey" json:"resource_type"` // e.g. "product", "page", "post" +} diff --git a/taxonomy/module.go b/taxonomy/module.go new file mode 100644 index 0000000..7aeb95c --- /dev/null +++ b/taxonomy/module.go @@ -0,0 +1,66 @@ +package taxonomy + +import ( + "context" + + "github.com/GoHyperrr/mdk" +) + +// Module implements the mdk.Module interface for Taxonomy. +type Module struct { + repo *Repository + rt mdk.Runtime +} + +// NewModule creates a new instance of the taxonomy module. +func NewModule() *Module { + return &Module{} +} + +// ID returns the unique identifier for the taxonomy module. +func (m *Module) ID() string { + return "commerce.taxonomy" +} + +// Init initializes the repository and database connections. +func (m *Module) Init(ctx context.Context, rt mdk.Runtime) error { + m.rt = rt + m.repo = NewRepository(rt.DB()) + return nil +} + +// Shutdown performs cleanups when shutting down hyperrr. +func (m *Module) Shutdown(ctx context.Context) error { + return nil +} + +// Models returns instances of the taxonomy database schemas to migrate. +func (m *Module) Models() []any { + return []any{&Taxonomy{}, &TaxonomyTerm{}, &TaxonomyRelation{}} +} + +// Routes returns HTTP endpoints (none for this module). +func (m *Module) Routes() []mdk.Route { + return nil +} + +// Repo returns the taxonomy repository. +func (m *Module) Repo() *Repository { + return m.repo +} + +// ListResources implements MCP list resources (empty). +func (m *Module) ListResources(ctx context.Context) ([]mdk.MCPResource, error) { + return nil, nil +} + +// ReadResource implements MCP read resource (empty). +func (m *Module) ReadResource(ctx context.Context, uri string) (string, error) { + return "", nil +} + +func init() { + mdk.Register(func() mdk.Module { + return NewModule() + }) +} diff --git a/taxonomy/repository.go b/taxonomy/repository.go new file mode 100644 index 0000000..785fe8a --- /dev/null +++ b/taxonomy/repository.go @@ -0,0 +1,148 @@ +package taxonomy + +import ( + "context" + + "gorm.io/gorm" +) + +// Repository handles database queries for Taxonomy, TaxonomyTerm, and TaxonomyRelation. +type Repository struct { + db *gorm.DB +} + +// NewRepository creates a new taxonomy repository. +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// CreateTaxonomy inserts a new taxonomy record. +func (r *Repository) CreateTaxonomy(ctx context.Context, t *Taxonomy) error { + return r.db.WithContext(ctx).Create(t).Error +} + +// GetTaxonomyByCode retrieves a taxonomy by its unique code. +func (r *Repository) GetTaxonomyByCode(ctx context.Context, code string) (*Taxonomy, error) { + var t Taxonomy + err := r.db.WithContext(ctx).Preload("Terms").Where("code = ?", code).First(&t).Error + if err != nil { + return nil, err + } + return &t, nil +} + +// ListTaxonomies returns all taxonomies in the database. +func (r *Repository) ListTaxonomies(ctx context.Context) ([]Taxonomy, error) { + var list []Taxonomy + err := r.db.WithContext(ctx).Find(&list).Error + return list, err +} + +// CreateTerm inserts a new taxonomy term. +func (r *Repository) CreateTerm(ctx context.Context, term *TaxonomyTerm) error { + return r.db.WithContext(ctx).Create(term).Error +} + +// GetTermBySlug retrieves a taxonomy term by its slug. +func (r *Repository) GetTermBySlug(ctx context.Context, slug string) (*TaxonomyTerm, error) { + var term TaxonomyTerm + err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&term).Error + if err != nil { + return nil, err + } + return &term, nil +} + +// GetTermTree builds and retrieves the nested terms hierarchy for a taxonomy. +func (r *Repository) GetTermTree(ctx context.Context, taxonomyID string) ([]TaxonomyTerm, error) { + var terms []TaxonomyTerm + err := r.db.WithContext(ctx).Where("taxonomy_id = ?", taxonomyID).Find(&terms).Error + if err != nil { + return nil, err + } + + termMap := make(map[string]*TaxonomyTerm) + var roots []TaxonomyTerm + + for i := range terms { + termMap[terms[i].ID] = &terms[i] + } + + for i := range terms { + t := &terms[i] + if t.ParentID == nil || *t.ParentID == "" { + roots = append(roots, *t) + } else { + if parent, exists := termMap[*t.ParentID]; exists { + parent.Children = append(parent.Children, *t) + } + } + } + + // Re-map children updates to root elements + for i := range roots { + if updated, exists := termMap[roots[i].ID]; exists { + roots[i] = *updated + } + } + + return roots, nil +} + +// LinkResource associates a resource with a taxonomy term. +func (r *Repository) LinkResource(ctx context.Context, termID, resourceID, resourceType string) error { + relation := TaxonomyRelation{ + TermID: termID, + ResourceID: resourceID, + ResourceType: resourceType, + } + return r.db.WithContext(ctx).FirstOrCreate(&relation).Error +} + +// UnlinkResource removes the association between a resource and a taxonomy term. +func (r *Repository) UnlinkResource(ctx context.Context, termID, resourceID, resourceType string) error { + return r.db.WithContext(ctx). + Where("term_id = ? AND resource_id = ? AND resource_type = ?", termID, resourceID, resourceType). + Delete(&TaxonomyRelation{}).Error +} + +// GetTermsForResource retrieves all terms linked to a resource. +func (r *Repository) GetTermsForResource(ctx context.Context, resourceID, resourceType string) ([]TaxonomyTerm, error) { + var relations []TaxonomyRelation + err := r.db.WithContext(ctx). + Where("resource_id = ? AND resource_type = ?", resourceID, resourceType). + Find(&relations).Error + if err != nil { + return nil, err + } + + if len(relations) == 0 { + return nil, nil + } + + var termIDs []string + for _, rel := range relations { + termIDs = append(termIDs, rel.TermID) + } + + var terms []TaxonomyTerm + err = r.db.WithContext(ctx).Where("id IN ?", termIDs).Find(&terms).Error + return terms, err +} + +// GetResourceIDsForTerm retrieves all resource IDs of a specific type linked to a term. +func (r *Repository) GetResourceIDsForTerm(ctx context.Context, termID, resourceType string) ([]string, error) { + var relations []TaxonomyRelation + err := r.db.WithContext(ctx). + Where("term_id = ? AND resource_type = ?", termID, resourceType). + Find(&relations).Error + if err != nil { + return nil, err + } + + var ids []string + for _, rel := range relations { + ids = append(ids, rel.ResourceID) + } + return ids, nil +} From 87b07ed48881dfe58b57f00a0768011b808ae87c Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Fri, 5 Jun 2026 20:03:44 +0530 Subject: [PATCH 2/7] Refactor: Update commerce tests to use mdktest and refactor cart module to use string-based step resolution --- analytics/analytics_test.go | 9 +++++---- cart/cart_test.go | 16 ++++++++-------- cart/module.go | 19 ++++++++++--------- cart/repo_test.go | 4 ++-- customer/customer_test.go | 13 +++++++------ finance/finance_test.go | 4 ++-- fulfillment/fulfillment_test.go | 5 +++-- marketing/marketing_test.go | 4 ++-- notification/notification_test.go | 5 +++-- order/order_test.go | 7 ++++--- product/product_test.go | 7 ++++--- search/search_test.go | 7 ++++--- support/support_test.go | 5 +++-- 13 files changed, 57 insertions(+), 48 deletions(-) diff --git a/analytics/analytics_test.go b/analytics/analytics_test.go index ff530a6..01922e0 100644 --- a/analytics/analytics_test.go +++ b/analytics/analytics_test.go @@ -6,16 +6,17 @@ import ( "time" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestAnalyticsModule(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) - testProj := &mdk.TestProjector{} - ctxMod := &mdk.TestContextModule{Proj: testProj} + testProj := &mdktest.TestProjector{} + ctxMod := &mdktest.ProjectorModule{Proj: testProj} rt.SetModule("core.context", ctxMod) mod := NewModule() @@ -26,7 +27,7 @@ func TestAnalyticsModule(t *testing.T) { now := time.Now() ended := now.Add(time.Second) testProj.Lineages = []mdk.LineageData{ - mdk.TestLineageData{ + mdktest.TestLineageData{ ID: "wf1", Name: "test", State: "COMPLETED", diff --git a/cart/cart_test.go b/cart/cart_test.go index 29feccb..11b9227 100644 --- a/cart/cart_test.go +++ b/cart/cart_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -12,12 +12,12 @@ import ( func TestCartWorkflow(t *testing.T) { t.Run("Add Item Workflow", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) c := &Cart{ID: "cart1", CustomerID: "cust1", Status: CartActive} mod.Repo().Save(context.Background(), c) @@ -34,12 +34,12 @@ func TestCartWorkflow(t *testing.T) { t.Run("Remove Item Workflow", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) c := &Cart{ID: "cart1", Items: []CartItem{{ID: "i1", CartID: "cart1", ProductID: "p1", Quantity: 1}}, Status: CartActive} mod.Repo().Save(context.Background(), c) @@ -56,12 +56,12 @@ func TestCartWorkflow(t *testing.T) { t.Run("Checkout Workflow", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) c := &Cart{ID: "cart1", Items: []CartItem{{ID: "i1", CartID: "cart1", ProductID: "p1", Quantity: 1}}, Status: CartActive} mod.Repo().Save(context.Background(), c) @@ -75,7 +75,7 @@ func TestCartWorkflow(t *testing.T) { t.Run("Handler Error Cases", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/cart/module.go b/cart/module.go index cd33a78..0bca0c3 100644 --- a/cart/module.go +++ b/cart/module.go @@ -30,9 +30,9 @@ func (m *Module) Init(ctx context.Context, rt mdk.Runtime) error { Name: "Cart Add Item", Steps: []mdk.Step{ { - ID: "add", - Name: "Add Item", - Handler: m.AddItemStep, + ID: "add", + Name: "Add Item", + Uses: "cart.add_item", }, }, }) @@ -42,9 +42,9 @@ func (m *Module) Init(ctx context.Context, rt mdk.Runtime) error { Name: "Cart Remove Item", Steps: []mdk.Step{ { - ID: "remove", - Name: "Remove Item", - Handler: m.RemoveItemStep, + ID: "remove", + Name: "Remove Item", + Uses: "cart.remove_item", }, }, }) @@ -54,15 +54,16 @@ func (m *Module) Init(ctx context.Context, rt mdk.Runtime) error { Name: "Cart Checkout", Steps: []mdk.Step{ { - ID: "checkout", - Name: "Checkout", - Handler: m.CheckoutStep, + ID: "checkout", + Name: "Checkout", + Uses: "cart.checkout", }, }, }) _ = rt.Workflows().RegisterHandler("cart.add_item", m.AddItemStep) _ = rt.Workflows().RegisterHandler("cart.remove_item", m.RemoveItemStep) + _ = rt.Workflows().RegisterHandler("cart.checkout", m.CheckoutStep) return nil } diff --git a/cart/repo_test.go b/cart/repo_test.go index 5087280..5c2356c 100644 --- a/cart/repo_test.go +++ b/cart/repo_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -15,7 +15,7 @@ func TestCartRepository(t *testing.T) { defer os.Remove(dbFile) database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/customer/customer_test.go b/customer/customer_test.go index 4f4bc54..fac9d1d 100644 --- a/customer/customer_test.go +++ b/customer/customer_test.go @@ -7,16 +7,17 @@ import ( "testing" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestCustomerWorkflow(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) - testProj := &mdk.TestProjector{} - ctxMod := &mdk.TestContextModule{Proj: testProj} + testProj := &mdktest.TestProjector{} + ctxMod := &mdktest.ProjectorModule{Proj: testProj} rt.SetModule("core.context", ctxMod) mod := NewModule() @@ -27,7 +28,7 @@ func TestCustomerWorkflow(t *testing.T) { } _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) t.Run("Segmentation Workflow", func(t *testing.T) { // Create a customer first @@ -37,7 +38,7 @@ func TestCustomerWorkflow(t *testing.T) { // Seed lineages to get WHALE persona (needs > 5 orders) for i := 0; i < 6; i++ { wfID := fmt.Sprintf("wf_%d", i) - testProj.Lineages = append(testProj.Lineages, mdk.TestLineageData{ + testProj.Lineages = append(testProj.Lineages, mdktest.TestLineageData{ ID: wfID, Name: "fulfillment.v1", State: "COMPLETED", @@ -78,7 +79,7 @@ func TestCustomerWorkflow(t *testing.T) { t.Run("Handler Error Cases", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/finance/finance_test.go b/finance/finance_test.go index 0dec0f1..43f6535 100644 --- a/finance/finance_test.go +++ b/finance/finance_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -41,7 +41,7 @@ func (m *flexibleMockOrder) GetCustomerID() string { return "" } func TestFinanceWorkflow(t *testing.T) { database, _ := gorm.Open(sqlite.Open("file:memdb_finance_wf?mode=memory&cache=shared"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/fulfillment/fulfillment_test.go b/fulfillment/fulfillment_test.go index e499c95..0584101 100644 --- a/fulfillment/fulfillment_test.go +++ b/fulfillment/fulfillment_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -24,12 +25,12 @@ func (m *mockOrder) GetCustomerID() string { return m.CustomerID } func TestFulfillmentWorkflow(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) t.Run("Reserve Inventory Success", func(t *testing.T) { productID := "p_res_" + uuid.New().String()[:8] diff --git a/marketing/marketing_test.go b/marketing/marketing_test.go index cd9de72..7a0f6a3 100644 --- a/marketing/marketing_test.go +++ b/marketing/marketing_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/google/uuid" - "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -23,7 +23,7 @@ func (m *mockOrder) GetCustomerID() string { return m.CustomerID } func TestMarketingModule(t *testing.T) { database, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/notification/notification_test.go b/notification/notification_test.go index 862bacc..be712c1 100644 --- a/notification/notification_test.go +++ b/notification/notification_test.go @@ -8,13 +8,14 @@ import ( "github.com/google/uuid" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestNotificationModule(t *testing.T) { database, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) // Create mock provider mockProv := &MockProvider{} @@ -22,7 +23,7 @@ func TestNotificationModule(t *testing.T) { mod := NewModule(mockProv) _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) t.Run("Send Notification Success", func(t *testing.T) { recipient := fmt.Sprintf("test_%s@example.com", uuid.New().String()[:8]) diff --git a/order/order_test.go b/order/order_test.go index a796d6d..7210e3b 100644 --- a/order/order_test.go +++ b/order/order_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -18,11 +19,11 @@ func TestOrderWorkflow(t *testing.T) { defer os.Remove(dbFile) database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) // Mock external handlers _ = runner.RegisterHandler("finance.process_payment", func(sCtx mdk.StepContext) mdk.StepResult { @@ -184,7 +185,7 @@ func TestOrderRepository(t *testing.T) { dbFile := "order_err_test.db" defer os.Remove(dbFile) database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) diff --git a/product/product_test.go b/product/product_test.go index a82f8d7..cfd65ea 100644 --- a/product/product_test.go +++ b/product/product_test.go @@ -5,13 +5,14 @@ import ( "testing" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestProductWorkflow(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() if err := mod.Init(context.Background(), rt); err != nil { @@ -19,7 +20,7 @@ func TestProductWorkflow(t *testing.T) { } _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) t.Run("Create Product Workflow", func(t *testing.T) { input := map[string]any{ @@ -74,7 +75,7 @@ func TestProductWorkflow(t *testing.T) { t.Run("Handler Error Cases", func(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) diff --git a/search/search_test.go b/search/search_test.go index 7454d7b..90b6efb 100644 --- a/search/search_test.go +++ b/search/search_test.go @@ -6,13 +6,14 @@ import ( "github.com/GoHyperrr/commerce/product" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func TestSearchModule(t *testing.T) { database, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) // Mock Product module prodMod := product.NewModule() @@ -26,7 +27,7 @@ func TestSearchModule(t *testing.T) { models = append(models, prodMod.Models()...) models = append(models, mod.Models()...) _ = database.AutoMigrate(models...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) // Seed products prodMod.Repo().Save(context.Background(), &product.Product{ @@ -82,7 +83,7 @@ func TestSearchModule(t *testing.T) { mNoProd := NewModule() mNoProdDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - mNoProdRt := mdk.NewTestRuntime(mNoProdDB) + mNoProdRt := mdktest.NewTestRuntime(mNoProdDB) _ = mNoProd.Init(context.Background(), mNoProdRt) _, err = mNoProd.SearchProducts(context.Background(), map[string]any{"input": map[string]any{"query": "x"}}) if err == nil { diff --git a/support/support_test.go b/support/support_test.go index b8a5005..587f5b3 100644 --- a/support/support_test.go +++ b/support/support_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/GoHyperrr/mdk" + "github.com/GoHyperrr/mdk/mdktest" "github.com/glebarez/sqlite" "gorm.io/gorm" ) @@ -15,12 +16,12 @@ func TestSupportModule(t *testing.T) { defer os.Remove(dbFile) database, _ := gorm.Open(sqlite.Open(dbFile), &gorm.Config{}) - rt := mdk.NewTestRuntime(database) + rt := mdktest.NewTestRuntime(database) mod := NewModule() _ = mod.Init(context.Background(), rt) _ = database.AutoMigrate(mod.Models()...) - runner := rt.Workflows().(*mdk.TestWorkflowEngine) + runner := rt.Workflows().(*mdktest.TestWorkflowEngine) t.Run("Create Ticket Success", func(t *testing.T) { wf := mdk.Workflow{ From 239e8c9b28fd873a4b901a97b8c6d89489a52655 Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Fri, 5 Jun 2026 21:28:32 +0530 Subject: [PATCH 3/7] chore: add CHANGELOG and SECURITY policy files --- CHANGELOG.md | 10 ++++++++++ SECURITY.md | 11 +++++++++++ go.mod | 2 ++ go.sum | 2 -- 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eeb5bd4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-06-05 + +### Added +- Catalog product variant options, pricing normalizations, and image relations. +- Decoupled commerce/seo and commerce/taxonomy packages. +- Refactored test suites to link to mdk/mdktest. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dbdd261 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +We currently support and patch security vulnerabilities on the latest major release branch. + +## Reporting a Vulnerability + +If you discover a security vulnerability within Hyperrr or any of its modules, please do not disclose it publicly. Report it directly by emailing security@hyperrr.org or opening a draft security advisory on GitHub. + +We will acknowledge receipt of your report within 48 hours and work with you to patch the issue promptly. diff --git a/go.mod b/go.mod index 9e07c06..e716510 100644 --- a/go.mod +++ b/go.mod @@ -23,3 +23,5 @@ require ( modernc.org/memory v1.5.0 // indirect modernc.org/sqlite v1.23.1 // indirect ) + +replace github.com/GoHyperrr/mdk => ../mdk diff --git a/go.sum b/go.sum index cf0c5d5..311fc89 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/GoHyperrr/mdk v0.0.0-20260605044506-3d2ab0d97ca9 h1:32yHu/Gnk+ztrDUZ8D+WnjWmkKZd8ebs+yxrjKJFq8Y= -github.com/GoHyperrr/mdk v0.0.0-20260605044506-3d2ab0d97ca9/go.mod h1:eZBBN0St+r0Gu5K5CklIsS6/SlVx6t2WsvOjkR1t4Ak= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= From 96887552843d868dbe15b47600fa89420b560a7d Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Sat, 6 Jun 2026 08:40:04 +0530 Subject: [PATCH 4/7] fix: resolve unrecognized properties in .coderabbit.yaml --- .coderabbit.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 05912f4..7bdf21b 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,17 +1,16 @@ # CodeRabbit Configuration File # Reference: https://docs.coderabbit.ai/spec/configuration +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: "en-US" -tone_instruction: "Review code as a senior Go software engineer with a focus on commerce domain structures, data isolation, event-native patterns, and API contracts." reviews: profile: "assertive" + tone_instructions: "Review code as a senior Go software engineer with a focus on commerce domain structures, data isolation, event-native patterns, and API contracts." auto_review: enabled: true drafts: false high_level_summary: true - reviews_per_file: true - collapse_dependency_reviews: true chat: auto_reply: true From addae86df645cf49bbf3b659a5f9edc3622a85f6 Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Sat, 6 Jun 2026 11:42:10 +0530 Subject: [PATCH 5/7] docs: update security policy in SECURITY.md --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index dbdd261..662d04c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -We currently support and patch security vulnerabilities on the latest major release branch. +For pre-1.0 releases we support the latest release or the latest patch series (e.g., 0.1.x), and for 1.0+ we support the latest major release branch. ## Reporting a Vulnerability From 5f39e92d6401a693fe8472b0fa26384b265d82f1 Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Sat, 6 Jun 2026 11:46:57 +0530 Subject: [PATCH 6/7] ci: checkout mdk dependency repository --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e5d8f3..10f096f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,14 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout commerce + uses: actions/checkout@v4 + - name: Checkout mdk + uses: actions/checkout@v4 + with: + repository: GoHyperrr/mdk + ref: ${{ github.head_ref || github.ref_name }} + path: ../mdk - uses: actions/setup-go@v5 with: go-version: '1.25' From 1b22cad410d09825967132bfeb8e9905fcd42f8d Mon Sep 17 00:00:00 2001 From: Viraj Trivedi Date: Sat, 6 Jun 2026 11:52:47 +0530 Subject: [PATCH 7/7] ci: fix mdk checkout path restriction --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10f096f..e5bd25a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: with: repository: GoHyperrr/mdk ref: ${{ github.head_ref || github.ref_name }} - path: ../mdk + path: mdk + - name: Move mdk to parent directory + run: mv mdk ../mdk - uses: actions/setup-go@v5 with: go-version: '1.25'