Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Build and Push Docker Image to GHCR
on:
release:
types: [published]
workflow_dispatch: # Allow manual testing

env:
REGISTRY: ghcr.io
Expand All @@ -27,7 +28,7 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
password: ${{ secrets.GHCR_TOKEN }}

- name: Convert repository name to lowercase
run: |
Expand All @@ -52,6 +53,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
53 changes: 52 additions & 1 deletion docs/RESTAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,15 @@ DELETE /api/admin/categories/{id}

Delete category (admin only).

**Status Codes:**

- `200 OK`: Category deleted successfully
- `400 Bad Request`: Cannot delete category with child categories or products
- `401 Unauthorized`: Not authenticated
- `403 Forbidden`: Not authorized (not an admin)
- `404 Not Found`: Category not found
- `500 Internal Server Error`: Server error

### Product Management

#### List Products
Expand Down Expand Up @@ -1145,7 +1154,49 @@ Get webhook information for payment provider (admin only).
POST /api/admin/test/email
```

Send test email (admin only).
Send test order confirmation and notification emails to a specified email address (admin only).

**Request Body:**

```json
{
"email": "test@example.com"
}
```

**Query Parameters:**

- `email` (optional): Email address to send test emails to. Can be provided as query parameter instead of request body.

**Response Body (Success):**

```json
{
"success": true,
"message": "Both order confirmation and notification emails sent successfully",
"details": {
"target_email": "test@example.com",
"order_id": "12345"
}
}
```

**Response Body (Error):**

```json
{
"success": false,
"errors": ["Invalid email format"]
}
```

**Status Codes:**

- `200 OK`: Test emails sent successfully
- `400 Bad Request`: Invalid email format
- `401 Unauthorized`: Not authenticated
- `403 Forbidden`: Not authorized (not an admin)
- `500 Internal Server Error`: Failed to send one or more emails

## Webhook Endpoints

Expand Down
83 changes: 83 additions & 0 deletions docs/email_test_api_examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Email Test API Examples

## Test Email Endpoint

### POST /api/admin/test/email

Test endpoint to send order confirmation and notification emails to a specified email address.

#### Example Request - JSON Body

```bash
curl -X POST http://localhost:8080/api/admin/test/email \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
-d '{
"email": "test@example.com"
}'
```

#### Example Request - Query Parameter

```bash
curl -X POST "http://localhost:8080/api/admin/test/email?email=test@example.com" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```

#### Example Response - Success

```json
{
"success": true,
"message": "Both order confirmation and notification emails sent successfully",
"details": {
"target_email": "test@example.com",
"order_id": "12345"
}
}
```

#### Example Response - Validation Error

```json
{
"success": false,
"errors": ["Invalid email format"]
}
```

#### Example Response - Email Send Error

```json
{
"success": false,
"errors": [
"Order confirmation: failed to send email: SMTP connection failed",
"Order notification: failed to send email: SMTP connection failed"
]
}
```

## Email Content

The test emails use mock order data with the following characteristics:

- Order ID: 12345
- Order Number: ORD-12345
- Customer: John Doe
- Total Amount: $83.00 (after $15.00 discount)
- Shipping Cost: $8.50
- Items:
- Test Product 1 (SKU: TEST-001) - Quantity: 2 - $25.00 each
- Test Product 2 (SKU: TEST-002) - Quantity: 1 - $49.50 each
- Discount: TEST15 - $15.00 off
- Shipping Address: 123 Test Street, Apt 4B, Test City, Test State, 12345, Test Country
- Billing Address: 456 Billing Ave, Billing City, Billing State, 67890, Billing Country

## Notes

- This endpoint is only accessible to admin users
- If no email is provided in the request, it falls back to the admin email from configuration
- Both order confirmation and notification emails are sent to the specified address
- The endpoint validates email format before attempting to send emails
- Individual email failures are reported in the error array
14 changes: 13 additions & 1 deletion internal/application/usecase/category_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import (
// CategoryUseCase implements category-related use cases
type CategoryUseCase struct {
categoryRepo repository.CategoryRepository
productRepo repository.ProductRepository
}

// NewCategoryUseCase creates a new CategoryUseCase
func NewCategoryUseCase(categoryRepo repository.CategoryRepository) *CategoryUseCase {
func NewCategoryUseCase(categoryRepo repository.CategoryRepository, productRepo repository.ProductRepository) *CategoryUseCase {
return &CategoryUseCase{
categoryRepo: categoryRepo,
productRepo: productRepo,
}
}

Expand Down Expand Up @@ -153,6 +155,16 @@ func (uc *CategoryUseCase) DeleteCategory(categoryID uint) error {
return errors.New("cannot delete category with child categories")
}

// Check if category has products
hasProducts, err := uc.productRepo.HasProductsWithCategory(categoryID)
if err != nil {
return fmt.Errorf("failed to check for products in category: %w", err)
}

if hasProducts {
return errors.New("cannot delete category with products")
}

// Delete the category
if err := uc.categoryRepo.Delete(categoryID); err != nil {
return fmt.Errorf("failed to delete category: %w", err)
Expand Down
131 changes: 131 additions & 0 deletions internal/application/usecase/category_usecase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package usecase

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zenfulcode/commercify/internal/domain/entity"
"github.com/zenfulcode/commercify/internal/infrastructure/repository/gorm"
"github.com/zenfulcode/commercify/testutil"
)

func TestCategoryUseCase_DeleteCategory_WithProducts(t *testing.T) {
t.Run("should prevent deletion of category with products", func(t *testing.T) {
// Setup
db := testutil.SetupTestDB(t)
defer testutil.CleanupTestDB(t, db)

categoryRepo := gorm.NewCategoryRepository(db)
productRepo := gorm.NewProductRepository(db)
categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo)

// Create test category
category, err := categoryUseCase.CreateCategory(CreateCategory{
Name: "Test Category",
Description: "Test category for deletion test",
ParentID: nil,
})
require.NoError(t, err)

// Create a product in this category
variant, err := entity.NewProductVariant(
"TEST-SKU-001",
10,
9999, // 99.99 in cents
1.0,
map[string]string{"size": "M"},
[]string{"test-image.jpg"},
true,
)
require.NoError(t, err)

product, err := entity.NewProduct(
"Test Product",
"Test product description",
"USD",
category.ID,
[]string{"product-image.jpg"},
[]*entity.ProductVariant{variant},
true,
)
require.NoError(t, err)

err = productRepo.Create(product)
require.NoError(t, err)

// Act: Try to delete category with products
err = categoryUseCase.DeleteCategory(category.ID)

// Assert: Should fail with specific error message
assert.Error(t, err)
assert.Equal(t, "cannot delete category with products", err.Error())

// Verify category still exists
existingCategory, err := categoryRepo.GetByID(category.ID)
assert.NoError(t, err)
assert.NotNil(t, existingCategory)
})

t.Run("should allow deletion of category without products", func(t *testing.T) {
// Setup
db := testutil.SetupTestDB(t)
defer testutil.CleanupTestDB(t, db)

categoryRepo := gorm.NewCategoryRepository(db)
productRepo := gorm.NewProductRepository(db)
categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo)

// Create test category
category, err := categoryUseCase.CreateCategory(CreateCategory{
Name: "Empty Category",
Description: "Category without products",
ParentID: nil,
})
require.NoError(t, err)

// Act: Delete category without products
err = categoryUseCase.DeleteCategory(category.ID)

// Assert: Should succeed
assert.NoError(t, err)

// Verify category was deleted
_, err = categoryRepo.GetByID(category.ID)
assert.Error(t, err)
})

t.Run("should still prevent deletion of category with child categories", func(t *testing.T) {
// Setup
db := testutil.SetupTestDB(t)
defer testutil.CleanupTestDB(t, db)

categoryRepo := gorm.NewCategoryRepository(db)
productRepo := gorm.NewProductRepository(db)
categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo)

// Create parent category
parentCategory, err := categoryUseCase.CreateCategory(CreateCategory{
Name: "Parent Category",
Description: "Parent category",
ParentID: nil,
})
require.NoError(t, err)

// Create child category
_, err = categoryUseCase.CreateCategory(CreateCategory{
Name: "Child Category",
Description: "Child category",
ParentID: &parentCategory.ID,
})
require.NoError(t, err)

// Act: Try to delete parent category with children
err = categoryUseCase.DeleteCategory(parentCategory.ID)

// Assert: Should fail with specific error message
assert.Error(t, err)
assert.Equal(t, "cannot delete category with child categories", err.Error())
})
}
12 changes: 10 additions & 2 deletions internal/application/usecase/product_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ func (uc *ProductUseCase) GetProductByID(id uint) (*entity.Product, error) {
type UpdateProductInput struct {
Name *string
Description *string
Currency *string
CategoryID *uint
Images *[]string
Active *bool
Expand All @@ -193,11 +194,18 @@ func (uc *ProductUseCase) UpdateProduct(id uint, input UpdateProductInput) (*ent
if err != nil {
return nil, errors.New("category not found")
}
product.CategoryID = *input.CategoryID
}

// Validate currency exists if changing
if input.Currency != nil && *input.Currency != product.Currency {
_, err := uc.currencyRepo.GetByCode(*input.Currency)
if err != nil {
return nil, errors.New("invalid currency code: " + *input.Currency)
}
}

// Update basic product fields
updated := product.Update(input.Name, input.Description, input.Images, input.Active)
updated := product.Update(input.Name, input.Description, input.Currency, input.Images, input.Active, input.CategoryID)

// Handle variant updates if provided
if input.Variants != nil {
Expand Down
7 changes: 7 additions & 0 deletions internal/domain/dto/email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package dto

// EmailTestDetails represents additional details in the email test response
type EmailTestDetails struct {
TargetEmail string `json:"target_email"`
OrderID string `json:"order_id"`
}
Loading