Compare commits
14 Commits
develop
...
fix/cleanu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8496be254e | ||
|
|
9335e02172 | ||
|
|
58ff0bc67f | ||
|
|
1ced02e0cb | ||
|
|
989bcdf2e8 | ||
|
|
b12cf9621f | ||
|
|
51bd29b86d | ||
|
|
fb38e0f8fa | ||
|
|
a48c634767 | ||
|
|
4ec66cedd9 | ||
|
|
69f0164bc8 | ||
|
|
d7e49e0be3 | ||
|
|
535e320e2f | ||
|
|
71082da9f9 |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(test:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(docker stats:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker-compose down:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(php --version:*)",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(php -l:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(docker update:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(php artisan:*)",
|
||||
"Bash(php check_blade.php:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,3 +59,6 @@ core.*
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
|
||||
# Git worktrees directory
|
||||
.worktrees/
|
||||
|
||||
48
Makefile
48
Makefile
@@ -9,9 +9,13 @@
|
||||
# --api-port 6443 \
|
||||
# --port "80:80@loadbalancer" \
|
||||
# --port "443:443@loadbalancer" \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
|
||||
# --volume k3d-dev-images:/k3d/images
|
||||
# --volume $(pwd)/.worktrees:/worktrees \
|
||||
# --volume $(pwd):/project-root \
|
||||
# --volume k3d-dev-images:/k3d/images \
|
||||
# --k3s-arg "--disable=traefik@server:0"
|
||||
#
|
||||
# Then install nginx ingress:
|
||||
# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
|
||||
|
||||
# Detect if we're in a worktree or project root
|
||||
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||
@@ -35,6 +39,8 @@ K8S_NS := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\//feat-/' | sed 's/b
|
||||
SANITIZED_BRANCH := $(shell echo "$(CURRENT_BRANCH)" | sed 's/[^a-zA-Z0-9]/_/g')
|
||||
# Generate host from branch
|
||||
K8S_HOST := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\///' | sed 's/bugfix\///' | sed 's/\//-/g').cannabrands.test
|
||||
# Generate unique Vite port from namespace (5173-5200 range)
|
||||
VITE_PORT := $(shell echo "$(K8S_NS)" | cksum | cut -d' ' -f1 | awk '{print 5173 + ($$1 % 28)}')
|
||||
# Read database credentials from .env
|
||||
DB_USERNAME := $(shell grep '^DB_USERNAME=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
DB_PASSWORD := $(shell grep '^DB_PASSWORD=' .env 2>/dev/null | cut -d '=' -f2)
|
||||
@@ -95,6 +101,9 @@ k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||
@# Deploy Reverb (WebSocket server)
|
||||
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
|
||||
envsubst < k8s/local/reverb.yaml | kubectl apply -f -
|
||||
@# Create persistent volumes for Vite cache
|
||||
@export NS=$(K8S_NS) && \
|
||||
envsubst < k8s/local/vite-cache-pvc.yaml | kubectl apply -f -
|
||||
@# Wait for DB
|
||||
@echo "⏳ Waiting for PostgreSQL..."
|
||||
@kubectl -n $(K8S_NS) wait --for=condition=ready pod -l app=postgres --timeout=60s
|
||||
@@ -108,13 +117,18 @@ k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||
@echo ""
|
||||
@echo "✅ Ready! Visit: http://$(K8S_HOST)"
|
||||
@echo ""
|
||||
@echo "💡 Your code is volume-mounted - changes are instant!"
|
||||
@echo " Edit files → refresh browser → see changes"
|
||||
@echo "🎨 Next: Start Vite for hot module replacement:"
|
||||
@echo " make k-dev-vite # In another terminal (port $(VITE_PORT))"
|
||||
@echo ""
|
||||
@echo "💡 Each worktree gets a unique Vite port for parallel development!"
|
||||
@echo " This worktree: Port $(VITE_PORT) (auto-assigned from namespace: $(K8S_NS))"
|
||||
@echo " Persistent cache enabled for faster Vite startup"
|
||||
@echo ""
|
||||
@echo "📝 Useful commands:"
|
||||
@echo " make k-logs # View app logs"
|
||||
@echo " make k-shell # Open shell in pod"
|
||||
@echo " make k-vite # Start Vite dev server"
|
||||
@echo " make k-logs # View app logs"
|
||||
@echo " make k-shell # Open shell in pod"
|
||||
@echo " make k-dev-vite # Start Vite dev server (recommended)"
|
||||
@echo " make k-cache-clear # Clear Vite cache if needed"
|
||||
@echo ""
|
||||
@echo "🔌 WebSocket (Reverb) available at: ws://reverb.$(K8S_HOST):8080"
|
||||
|
||||
@@ -135,10 +149,22 @@ k-artisan: ## Run artisan command (usage: make k-artisan CMD="migrate")
|
||||
k-composer: ## Run composer (usage: make k-composer CMD="install")
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- composer $(CMD)
|
||||
|
||||
k-vite: ## Run Vite dev server in k8s pod
|
||||
@echo "🎨 Starting Vite dev server in pod..."
|
||||
k-dev-vite: ## Run Vite dev server on HOST (recommended for k8s - fast HMR)
|
||||
@echo "🎨 Starting Vite dev server on HOST (fast)..."
|
||||
@echo " Namespace: $(K8S_NS)"
|
||||
@echo " Port: $(VITE_PORT) (auto-assigned per worktree)"
|
||||
@VITE_PORT=$(VITE_PORT) npm run dev
|
||||
|
||||
k-vite: ## Run Vite dev server in k8s pod (slower - use k-dev-vite instead)
|
||||
@echo "🎨 Starting Vite dev server in pod (with optimizations)..."
|
||||
@echo " Consider using 'make k-dev-vite' for better performance"
|
||||
@echo " Access at: http://vite.$(K8S_HOST)"
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- npm run dev
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- sh -c "VITE_HMR_HOST=vite.$(K8S_HOST) VITE_USE_POLLING=true npm run dev"
|
||||
|
||||
k-cache-clear: ## Clear Vite cache in k8s pod
|
||||
@echo "🗑 Clearing Vite cache..."
|
||||
@kubectl -n $(K8S_NS) exec deploy/web -- rm -rf node_modules/.vite
|
||||
@echo "✅ Cache cleared. Restart Vite for changes to take effect."
|
||||
|
||||
k-test: ## Run tests in k8s pod
|
||||
@echo "🧪 Running tests in k8s pod..."
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Traits\FileStorageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StorageTestController extends Controller
|
||||
{
|
||||
use FileStorageHelper;
|
||||
|
||||
/**
|
||||
* Test storage configuration
|
||||
*/
|
||||
public function test(Request $request)
|
||||
{
|
||||
$results = [];
|
||||
$results['storage_info'] = $this->getStorageInfo();
|
||||
|
||||
// Test file upload if provided
|
||||
if ($request->hasFile('test_file')) {
|
||||
try {
|
||||
$file = $request->file('test_file');
|
||||
|
||||
// Store test file
|
||||
$path = $this->storeFile($file, 'tests');
|
||||
$results['upload'] = [
|
||||
'success' => true,
|
||||
'path' => $path,
|
||||
'url' => $this->getFileUrl($path),
|
||||
];
|
||||
|
||||
// Verify file exists
|
||||
$disk = Storage::disk($this->getStorageDisk());
|
||||
$results['verification'] = [
|
||||
'exists' => $disk->exists($path),
|
||||
'size' => $disk->size($path),
|
||||
];
|
||||
|
||||
// Delete test file
|
||||
$deleted = $this->deleteFile($path);
|
||||
$results['cleanup'] = [
|
||||
'deleted' => $deleted,
|
||||
'still_exists' => $disk->exists($path),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results['error'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($results, 200, [], JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show test upload form
|
||||
*/
|
||||
public function form()
|
||||
{
|
||||
return view('storage-test');
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,14 @@ class ProductImage extends Model
|
||||
'path',
|
||||
'type',
|
||||
'order',
|
||||
'sort_order',
|
||||
'is_primary',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function product(): BelongsTo
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const content = fs.readFileSync('resources/views/seller/products/edit11.blade.php', 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let depth = 0;
|
||||
const stack = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// Skip lines that are Alpine.js @error handlers
|
||||
if (line.includes('@error') && line.includes('$event')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for @if (but not in @endif, @error, @enderror)
|
||||
if (/@if\s*\(/.test(line) && !/@endif/.test(line)) {
|
||||
depth++;
|
||||
stack.push({ line: lineNum, type: 'if', content: line.trim().substring(0, 80) });
|
||||
console.log(`${lineNum}: [depth +${depth}] @if`);
|
||||
}
|
||||
|
||||
// Check for @elseif
|
||||
if (/@elseif\s*\(/.test(line)) {
|
||||
console.log(`${lineNum}: [depth =${depth}] @elseif`);
|
||||
}
|
||||
|
||||
// Check for @else (but not @elseif, @endforelse, @enderror)
|
||||
if (/@else\b/.test(line) && !/@elseif/.test(line) && !/@endforelse/.test(line) && !/@enderror/.test(line)) {
|
||||
console.log(`${lineNum}: [depth =${depth}] @else`);
|
||||
}
|
||||
|
||||
// Check for @endif
|
||||
if (/@endif\b/.test(line)) {
|
||||
console.log(`${lineNum}: [depth -${depth}] @endif`);
|
||||
if (depth > 0) {
|
||||
depth--;
|
||||
stack.pop();
|
||||
} else {
|
||||
console.log(`ERROR: Extra @endif at line ${lineNum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nFinal depth: ${depth}`);
|
||||
if (depth > 0) {
|
||||
console.log(`\nUNBALANCED: Missing ${depth} @endif statement(s)`);
|
||||
console.log('\nUnclosed @if statements:');
|
||||
stack.forEach(item => {
|
||||
console.log(` Line ${item.line}: ${item.content}`);
|
||||
});
|
||||
} else {
|
||||
console.log('\nAll @if/@endif pairs are balanced!');
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
$file = 'C:\Users\Boss Man\Documents\GitHub\hub\resources\views\seller\products\edit11.blade.php';
|
||||
$lines = file($file);
|
||||
|
||||
$stack = [];
|
||||
|
||||
foreach ($lines as $lineNum => $line) {
|
||||
$lineNum++; // 1-indexed
|
||||
|
||||
// Check for @if (but not @endif, @elseif, etc.)
|
||||
if (preg_match('/^\s*@if\(/', $line)) {
|
||||
$stack[] = ['type' => 'if', 'line' => $lineNum];
|
||||
echo "Line $lineNum: OPEN @if (stack depth: ".count($stack).")\n";
|
||||
}
|
||||
// Check for @elseif
|
||||
elseif (preg_match('/^\s*@elseif\(/', $line)) {
|
||||
echo "Line $lineNum: @elseif\n";
|
||||
}
|
||||
// Check for @else
|
||||
elseif (preg_match('/^\s*@else\s*$/', $line)) {
|
||||
echo "Line $lineNum: @else\n";
|
||||
}
|
||||
// Check for @endif
|
||||
elseif (preg_match('/^\s*@endif\s*$/', $line)) {
|
||||
if (empty($stack)) {
|
||||
echo "ERROR Line $lineNum: @endif without matching @if!\n";
|
||||
} else {
|
||||
$opened = array_pop($stack);
|
||||
echo "Line $lineNum: CLOSE @endif (opened at line {$opened['line']}, stack depth: ".count($stack).")\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($stack)) {
|
||||
echo "\nERROR: Unclosed @if directives:\n";
|
||||
foreach ($stack as $item) {
|
||||
echo " Line {$item['line']}: @if never closed\n";
|
||||
}
|
||||
} else {
|
||||
echo "\nAll @if/@endif directives are balanced!\n";
|
||||
}
|
||||
@@ -54,6 +54,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: code
|
||||
mountPath: /var/www/html
|
||||
- name: vite-cache
|
||||
mountPath: /var/www/html/node_modules/.vite
|
||||
|
||||
# Startup command
|
||||
command: ["/bin/bash", "-c"]
|
||||
@@ -81,8 +83,10 @@ spec:
|
||||
echo "Installing/updating npm dependencies..."
|
||||
npm install --no-audit --no-fund
|
||||
|
||||
echo "Building frontend assets..."
|
||||
npm run build
|
||||
# Skip npm run build when using host Vite (faster startup)
|
||||
# Uncomment for production or if running make k-vite:
|
||||
# echo "Building frontend assets..."
|
||||
# npm run build
|
||||
|
||||
echo "Running migrations..."
|
||||
php artisan migrate --force
|
||||
@@ -128,3 +132,6 @@ spec:
|
||||
hostPath:
|
||||
path: ${K8S_VOLUME_PATH}
|
||||
type: Directory
|
||||
- name: vite-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: vite-cache-pvc
|
||||
|
||||
12
k8s/local/vite-cache-pvc.yaml
Normal file
12
k8s/local/vite-cache-pvc.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: vite-cache-pvc
|
||||
namespace: ${NS}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
storageClassName: local-path
|
||||
@@ -3,8 +3,8 @@
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
{{-- Top Header Bar --}}
|
||||
<div class="bg-white border border-gray-300 rounded-md shadow-sm mb-6">
|
||||
<div class="px-6 py-4">
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
{{-- Left: Product Image & Info --}}
|
||||
<div class="flex items-start gap-4">
|
||||
@@ -13,10 +13,10 @@
|
||||
@if($product->images->where('is_primary', true)->first())
|
||||
<img src="{{ asset('storage/' . $product->images->where('is_primary', true)->first()->path) }}"
|
||||
alt="{{ $product->name }}"
|
||||
class="w-16 h-16 object-cover rounded-md border border-gray-300">
|
||||
class="w-16 h-16 object-cover rounded-md border border-base-300">
|
||||
@else
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-md border border-gray-300 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-16 h-16 bg-base-200 rounded-md border border-base-300 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
{{-- Product Details --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ $product->name }}</h1>
|
||||
<h1 class="text-xl font-bold text-base-content">{{ $product->name }}</h1>
|
||||
{{-- Status Badges --}}
|
||||
<span id="activeBadge" style="display: {{ $product->is_active ? 'inline-flex' : 'none' }};" class="items-center px-2.5 py-0.5 rounded text-xs font-medium bg-success text-white">
|
||||
Active
|
||||
@@ -35,39 +35,30 @@
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-0.5">
|
||||
<div class="text-sm text-base-content/70 space-y-0.5">
|
||||
<div><span class="font-medium">SKU:</span> <span class="font-mono">{{ $product->sku ?? 'N/A' }}</span> <span class="mx-2">•</span> <span class="font-medium">Brand:</span> {{ $product->brand->name ?? 'N/A' }}</div>
|
||||
<div><span class="font-medium">Last updated:</span> {{ $product->updated_at->format('M j, Y g:i A') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right: Action Buttons & Breadcrumb --}}
|
||||
{{-- Right: Action Buttons --}}
|
||||
<div class="flex flex-col gap-2 flex-shrink-0">
|
||||
{{-- View on Marketplace Button (White with border) --}}
|
||||
<a href="#" target="_blank" class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{{-- View on Marketplace Button --}}
|
||||
<a href="#" target="_blank" class="btn btn-outline btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
View on Marketplace
|
||||
</a>
|
||||
|
||||
{{-- Manage BOM Button (Blue solid) --}}
|
||||
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{{-- Manage BOM Button --}}
|
||||
<a href="{{ route('seller.business.products.bom.index', [$business->slug, $product->id]) }}" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
Manage BOM
|
||||
</a>
|
||||
|
||||
{{-- Breadcrumb Navigation --}}
|
||||
<nav class="flex text-xs text-gray-500 mt-1" aria-label="Breadcrumb">
|
||||
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="hover:text-gray-700">Dashboard</a>
|
||||
<span class="mx-2">></span>
|
||||
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
|
||||
<span class="mx-2">></span>
|
||||
<span class="text-gray-900 font-medium">Edit</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +95,7 @@
|
||||
{{-- LEFT SIDEBAR (1/4 width) --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Product Images Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-sm">Product Images</h2>
|
||||
<div class="space-y-4">
|
||||
@@ -147,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Quick Stats Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-sm">Quick Stats</h2>
|
||||
<div class="space-y-2">
|
||||
@@ -173,7 +164,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Audit Info Card --}}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xs">Audit Info</h2>
|
||||
<div class="space-y-1 text-xs text-base-content/70">
|
||||
@@ -186,7 +177,7 @@
|
||||
|
||||
{{-- MAIN CONTENT WITH TABS (3/4 width) --}}
|
||||
<div class="lg:col-span-3">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
{{-- Tabs Navigation --}}
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Storage Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="p-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">File Storage Test</h2>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<span class="text-sm">Current Disk: <strong>{{ config('filesystems.default') }}</strong></span>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('storage.test') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Upload Test File</span>
|
||||
</label>
|
||||
<input type="file" name="test_file" class="file-input file-input-bordered" required>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary">Test Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider">Storage Info</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-semibold">Disk</td>
|
||||
<td>{{ config('filesystems.default') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">Driver</td>
|
||||
<td>{{ config('filesystems.disks.'.config('filesystems.default').'.driver') }}</td>
|
||||
</tr>
|
||||
@if(config('filesystems.default') === 's3')
|
||||
<tr>
|
||||
<td class="font-semibold">Endpoint</td>
|
||||
<td>{{ config('filesystems.disks.s3.endpoint') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">Bucket</td>
|
||||
<td>{{ config('filesystems.disks.s3.bucket') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-semibold">URL</td>
|
||||
<td>{{ config('filesystems.disks.s3.url') }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\StorageTestController;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -199,9 +198,3 @@ Route::prefix('api')->group(function () {
|
||||
->name('api.check-email')
|
||||
->middleware('throttle:10,1'); // Rate limit: 10 requests per minute
|
||||
});
|
||||
|
||||
// Storage Test Routes (Development/Testing Only)
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/storage-test', [StorageTestController::class, 'form'])->name('storage.test.form');
|
||||
Route::post('/storage-test', [StorageTestController::class, 'test'])->name('storage.test');
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
require("daisyui"),
|
||||
addIconSelectors(['lucide', 'hugeicons', 'ri'])
|
||||
],
|
||||
|
||||
|
||||
daisyui: {
|
||||
themes: ["light", "dark"],
|
||||
},
|
||||
|
||||
537
tests/Feature/ProductImageControllerTest.php
Normal file
537
tests/Feature/ProductImageControllerTest.php
Normal file
@@ -0,0 +1,537 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductImage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductImageControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
protected function withCsrfToken(): static
|
||||
{
|
||||
return $this->withSession(['_token' => 'test-token'])
|
||||
->withHeader('X-CSRF-TOKEN', 'test-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can upload valid product image
|
||||
*/
|
||||
public function test_seller_can_upload_valid_product_image(): void
|
||||
{
|
||||
// Create seller business with brand and product
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create valid test image (750x384 minimum)
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image was created in database
|
||||
$this->assertDatabaseHas('product_images', [
|
||||
'product_id' => $product->id,
|
||||
'is_primary' => true, // First image should be primary
|
||||
]);
|
||||
|
||||
// Verify file was stored
|
||||
$productImage = ProductImage::where('product_id', $product->id)->first();
|
||||
Storage::disk('local')->assertExists($productImage->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test first uploaded image becomes primary
|
||||
*/
|
||||
public function test_first_image_becomes_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$productImage = ProductImage::where('product_id', $product->id)->first();
|
||||
$this->assertTrue($productImage->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test image upload validates minimum dimensions
|
||||
*/
|
||||
public function test_upload_validates_minimum_dimensions(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Image too small (below 750x384)
|
||||
$image = UploadedFile::fake()->image('product.jpg', 500, 300);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test upload validates file type
|
||||
*/
|
||||
public function test_upload_validates_file_type(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Invalid file type
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $file]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('image');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot upload more than 6 images per product
|
||||
*/
|
||||
public function test_cannot_upload_more_than_six_images(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create 6 existing images
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => "products/test-{$i}.jpg",
|
||||
'is_primary' => $i === 0,
|
||||
'sort_order' => $i,
|
||||
]);
|
||||
}
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$business->slug, $product->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'Maximum of 6 images allowed per product',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot upload image to another business's product (business_id isolation)
|
||||
*/
|
||||
public function test_seller_cannot_upload_image_to_other_business_product(): void
|
||||
{
|
||||
// Create two businesses
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image = UploadedFile::fake()->image('product.jpg', 750, 384);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to upload to businessB's product using businessA's slug
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.upload', [$businessA->slug, $productB->id]),
|
||||
['image' => $image]
|
||||
);
|
||||
|
||||
$response->assertNotFound(); // Product not found when scoped to businessA
|
||||
|
||||
// Verify no image was created
|
||||
$this->assertDatabaseMissing('product_images', [
|
||||
'product_id' => $productB->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can delete their product's image
|
||||
*/
|
||||
public function test_seller_can_delete_product_image(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create test file
|
||||
Storage::disk('local')->put('products/test.jpg', 'fake content');
|
||||
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$business->slug, $product->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image was deleted from database
|
||||
$this->assertDatabaseMissing('product_images', ['id' => $image->id]);
|
||||
|
||||
// Verify file was deleted from storage
|
||||
Storage::disk('local')->assertMissing('products/test.jpg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test deleting primary image sets next image as primary
|
||||
*/
|
||||
public function test_deleting_primary_image_sets_next_as_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create two images
|
||||
Storage::disk('local')->put('products/test1.jpg', 'fake content 1');
|
||||
Storage::disk('local')->put('products/test2.jpg', 'fake content 2');
|
||||
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Delete primary image
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$business->slug, $product->id, $image1->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Verify image2 is now primary
|
||||
$this->assertTrue($image2->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot delete another business's product image
|
||||
*/
|
||||
public function test_seller_cannot_delete_other_business_product_image(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
Storage::disk('local')->put('products/test.jpg', 'fake content');
|
||||
$imageB = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to delete businessB's product image
|
||||
$response = $this->deleteJson(
|
||||
route('seller.business.products.images.delete', [$businessA->slug, $productB->id, $imageB->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify image was NOT deleted
|
||||
$this->assertDatabaseHas('product_images', ['id' => $imageB->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can reorder product images
|
||||
*/
|
||||
public function test_seller_can_reorder_product_images(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create three images
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$image3 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test3.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Reorder: image3, image1, image2
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.reorder', [$business->slug, $product->id]),
|
||||
['order' => [$image3->id, $image1->id, $image2->id]]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify new sort order
|
||||
$this->assertEquals(0, $image3->fresh()->sort_order);
|
||||
$this->assertEquals(1, $image1->fresh()->sort_order);
|
||||
$this->assertEquals(2, $image2->fresh()->sort_order);
|
||||
|
||||
// Verify first image (image3) is now primary
|
||||
$this->assertTrue($image3->fresh()->is_primary);
|
||||
$this->assertFalse($image1->fresh()->is_primary);
|
||||
$this->assertFalse($image2->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot reorder another business's product images
|
||||
*/
|
||||
public function test_seller_cannot_reorder_other_business_product_images(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to reorder businessB's product images
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.reorder', [$businessA->slug, $productB->id]),
|
||||
['order' => [$image2->id, $image1->id]]
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify order was NOT changed
|
||||
$this->assertEquals(0, $image1->fresh()->sort_order);
|
||||
$this->assertEquals(1, $image2->fresh()->sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can set image as primary
|
||||
*/
|
||||
public function test_seller_can_set_image_as_primary(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create two images
|
||||
$image1 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test1.jpg',
|
||||
'is_primary' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$image2 = ProductImage::create([
|
||||
'product_id' => $product->id,
|
||||
'path' => 'products/test2.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Set image2 as primary
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$business->slug, $product->id, $image2->id])
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Verify image2 is now primary and image1 is not
|
||||
$this->assertTrue($image2->fresh()->is_primary);
|
||||
$this->assertFalse($image1->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot set primary on another business's product image
|
||||
*/
|
||||
public function test_seller_cannot_set_primary_on_other_business_product_image(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$brandB = Brand::factory()->create(['business_id' => $businessB->id]);
|
||||
$productB = Product::factory()->create(['brand_id' => $brandB->id]);
|
||||
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $productB->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to set primary on businessB's product image
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$businessA->slug, $productB->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify is_primary was NOT changed
|
||||
$this->assertFalse($image->fresh()->is_primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test cannot set primary on image that doesn't belong to product
|
||||
*/
|
||||
public function test_cannot_set_primary_on_image_from_different_product(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$brand = Brand::factory()->create(['business_id' => $business->id]);
|
||||
$product1 = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
$product2 = Product::factory()->create(['brand_id' => $brand->id]);
|
||||
|
||||
// Create image for product2
|
||||
$image = ProductImage::create([
|
||||
'product_id' => $product2->id,
|
||||
'path' => 'products/test.jpg',
|
||||
'is_primary' => false,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
|
||||
// Try to set it as primary for product1 (wrong product)
|
||||
$response = $this->postJson(
|
||||
route('seller.business.products.images.set-primary', [$business->slug, $product1->id, $image->id])
|
||||
);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJson([
|
||||
'success' => false,
|
||||
'message' => 'Image not found',
|
||||
]);
|
||||
}
|
||||
}
|
||||
324
tests/Feature/ProductLineControllerTest.php
Normal file
324
tests/Feature/ProductLineControllerTest.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductLine;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProductLineControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Test seller can create product line for their business
|
||||
*/
|
||||
public function test_seller_can_create_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line created successfully.');
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name is required
|
||||
*/
|
||||
public function test_product_line_name_is_required(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => '']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'business_id' => $business->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name must be unique per business
|
||||
*/
|
||||
public function test_product_line_name_must_be_unique_per_business(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
// Create existing product line
|
||||
ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $business->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test product line name can be duplicated across different businesses
|
||||
*/
|
||||
public function test_product_line_name_can_be_duplicated_across_businesses(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$sellerB = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerB->businesses()->attach($businessB->id);
|
||||
|
||||
// Create product line in business A
|
||||
ProductLine::create([
|
||||
'business_id' => $businessA->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
// Create product line with same name in business B (should work)
|
||||
$this->actingAs($sellerB);
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $businessB->slug),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
// Verify both exist
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $businessA->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can update their product line
|
||||
*/
|
||||
public function test_seller_can_update_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
|
||||
['name' => 'Ultra Premium Line']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line updated successfully.');
|
||||
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
'name' => 'Ultra Premium Line',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test update validates name is required
|
||||
*/
|
||||
public function test_update_validates_name_is_required(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine->id]),
|
||||
['name' => '']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Premium Line', $productLine->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test update validates uniqueness per business
|
||||
*/
|
||||
public function test_update_validates_uniqueness_per_business(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine1 = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$productLine2 = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Budget Line',
|
||||
]);
|
||||
|
||||
// Try to rename productLine2 to match productLine1
|
||||
$this->actingAs($seller);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$business->slug, $productLine2->id]),
|
||||
['name' => 'Premium Line']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Budget Line', $productLine2->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot update another business's product line
|
||||
*/
|
||||
public function test_seller_cannot_update_other_business_product_line(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$productLineB = ProductLine::create([
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
$response = $this->put(
|
||||
route('seller.business.product-lines.update', [$businessA->slug, $productLineB->id]),
|
||||
['name' => 'Hacked Name']
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify name wasn't changed
|
||||
$this->assertEquals('Premium Line', $productLineB->fresh()->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller can delete their product line
|
||||
*/
|
||||
public function test_seller_can_delete_product_line(): void
|
||||
{
|
||||
$business = Business::factory()->create(['business_type' => 'brand']);
|
||||
$seller = User::factory()->create(['user_type' => 'seller']);
|
||||
$seller->businesses()->attach($business->id);
|
||||
|
||||
$productLine = ProductLine::create([
|
||||
'business_id' => $business->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($seller);
|
||||
$response = $this->delete(
|
||||
route('seller.business.product-lines.destroy', [$business->slug, $productLine->id])
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('success', 'Product line deleted successfully.');
|
||||
|
||||
$this->assertDatabaseMissing('product_lines', [
|
||||
'id' => $productLine->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller cannot delete another business's product line
|
||||
*/
|
||||
public function test_seller_cannot_delete_other_business_product_line(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$productLineB = ProductLine::create([
|
||||
'business_id' => $businessB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
$response = $this->delete(
|
||||
route('seller.business.product-lines.destroy', [$businessA->slug, $productLineB->id])
|
||||
);
|
||||
|
||||
$response->assertNotFound();
|
||||
|
||||
// Verify product line wasn't deleted
|
||||
$this->assertDatabaseHas('product_lines', [
|
||||
'id' => $productLineB->id,
|
||||
'name' => 'Premium Line',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seller from unauthorized business cannot access another business routes
|
||||
*/
|
||||
public function test_seller_cannot_access_unauthorized_business_routes(): void
|
||||
{
|
||||
$businessA = Business::factory()->create(['business_type' => 'brand']);
|
||||
$businessB = Business::factory()->create(['business_type' => 'brand']);
|
||||
|
||||
$sellerA = User::factory()->create(['user_type' => 'seller']);
|
||||
$sellerA->businesses()->attach($businessA->id);
|
||||
|
||||
$this->actingAs($sellerA);
|
||||
|
||||
// Try to access businessB routes
|
||||
$response = $this->post(
|
||||
route('seller.business.product-lines.store', $businessB->slug),
|
||||
['name' => 'Test Line']
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,58 @@ export default defineConfig({
|
||||
refresh: true,
|
||||
}),
|
||||
],
|
||||
// Dependency pre-bundling optimization
|
||||
optimizeDeps: {
|
||||
// Force pre-bundle these dependencies for faster startup
|
||||
include: [
|
||||
'@alpinejs/intersect',
|
||||
'@alpinejs/morph',
|
||||
'@alpinejs/persist',
|
||||
'animejs',
|
||||
'apexcharts',
|
||||
'cal-heatmap',
|
||||
'cally',
|
||||
'choices.js',
|
||||
'filepond',
|
||||
'filepond-plugin-file-validate-type',
|
||||
'filepond-plugin-image-preview',
|
||||
'laravel-echo',
|
||||
'laravel-precognition-alpine',
|
||||
'pusher-js',
|
||||
'sortablejs',
|
||||
],
|
||||
// Hold crawling until all imports are discovered
|
||||
holdUntilCrawled: true,
|
||||
},
|
||||
// Cache configuration
|
||||
cacheDir: 'node_modules/.vite',
|
||||
server: {
|
||||
host: "0.0.0.0", // Listen on all interfaces
|
||||
port: process.env.VITE_PORT ? parseInt(process.env.VITE_PORT) : 5173,
|
||||
strictPort: false, // Allow fallback port if assigned port is taken
|
||||
allowedHosts: [".cannabrands.test"], // Allow all subdomains
|
||||
// File watching optimization for containers
|
||||
watch: {
|
||||
// Use polling for container environments (increases CPU but more reliable)
|
||||
usePolling: process.env.VITE_USE_POLLING === 'true',
|
||||
interval: 1000, // Check every 1 second (vs default 100ms)
|
||||
binaryInterval: 3000, // For binary files
|
||||
},
|
||||
// Warmup frequently used files
|
||||
warmup: {
|
||||
clientFiles: [
|
||||
'./resources/js/app.js',
|
||||
'./resources/css/app.css',
|
||||
],
|
||||
},
|
||||
hmr: {
|
||||
// Host mode (npm run dev on macOS): use localhost with assigned port
|
||||
// K8s mode (make k-vite): use vite subdomain through ingress
|
||||
host: process.env.VITE_HMR_HOST || "localhost",
|
||||
clientPort: process.env.VITE_HMR_HOST
|
||||
? 80
|
||||
: (process.env.VITE_PORT ? parseInt(process.env.VITE_PORT) : 5173),
|
||||
protocol: "http",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user