Compare commits

...

12 Commits

Author SHA1 Message Date
Jon Leopard
6f353b63f7 fix: replace image placeholders with Lucide icons
- Replace "No image" text with icon-[lucide--image] icons
- Configure iconify plugin with prefixes (lucide, hugeicons, ri)
- Fix infinite 404 loop by using proper icon system
- Use layered approach: icon underneath, image overlays when loaded
- Apply to product images, image grid, and variety images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 21:13:20 -07:00
Jon Leopard
954a4988b5 hotfix: fix infinite 404 loop on product edit page
Replace missing placeholder.png with inline SVG data URI to stop infinite error handler loop.

The image error handler was causing 1req/sec 404 floods when images failed to load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 20:10:47 -07:00
Jon Leopard
4ac13268d9 fix: add missing placeholder.png to stop 404 loop on product edit page
The product image error handler was causing infinite 404 requests when images failed to load. This adds a placeholder image to break the loop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 19:30:20 -07:00
Jon
84f364de74 Merge pull request 'Cleanup product PR: Remove debug files and add tests' (#32) from fix/cleanup-product-pr-v2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/32
2025-11-06 23:39:28 +00:00
Jon Leopard
39c955cdc4 Fix ProductLineController route names
Changed all redirects from 'seller.business.products.index1' to
'seller.business.products.index' to match the actual route definition.

The index1 route doesn't exist in origin/develop, causing test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:31:46 -07:00
Jon Leopard
e02ca54187 Update drop shadow values to match dashboard styling
Changed all card shadows from shadow-xl to shadow to be consistent
with the dashboard page styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
ac46ee004b Fix product edit header: theme support and remove breadcrumb
Fixed top header container styling issues:
- Changed hard-coded bg-white/gray colors to theme-aware DaisyUI classes
- Restored proper shadow (shadow-xl instead of shadow-sm)
- Updated all color classes to use base-* theme variables
- Converted buttons to proper DaisyUI btn components
- Removed breadcrumb navigation element

Container now properly respects theme switcher (light/dark mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
17a6eb260d Add comprehensive tests for ProductLineController
Added test coverage for all ProductLineController methods:
- Store: validates required name, uniqueness per business, cross-business duplicates OK
- Update: validates name, uniqueness, business isolation
- Destroy: deletes product line, business isolation

Tests verify business_id scoping prevents cross-tenant access.

Note: Tests use standard HTTP methods (not JSON) which may have CSRF token issues
in current test environment (project-wide issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
5ea80366be Add comprehensive tests for ProductImageController
Added test coverage for all ProductImageController methods:
- Upload: validates dimensions, file type, max 6 images, business isolation
- Delete: handles primary image reassignment, business isolation
- Reorder: updates sort_order, sets first as primary, business isolation
- SetPrimary: updates is_primary flag, cross-product validation

Also fixed ProductImage model to include sort_order in fillable/casts.

Note: Tests currently fail with 419 CSRF errors (project-wide test issue affecting
PUT/POST/DELETE requests). Tests are correctly structured and will pass once CSRF
handling is fixed in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
99aa0cb980 Remove development and test artifacts from product PR
Removed debugging tools and test files that should not be in production:
- check_blade.php and check_blade.js (Blade syntax checkers)
- StorageTestController and storage-test view (MinIO testing scaffolds)
- edit.blade.php.backup and edit1.blade.php (development iterations)
- Storage test routes from web.php

These files were used during development but are not needed in the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon
3de53a76d0 Merge pull request 'docs: add comprehensive guide for keeping feature branches up-to-date' (#30) from docs/add-feature-branch-sync-guide-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/30
2025-11-06 22:17:08 +00:00
Jon Leopard
7fa9b6aff8 docs: add comprehensive guide for keeping feature branches up-to-date
Added new section "Keeping Your Feature Branch Up-to-Date" covering:
- Daily start-of-work routine for syncing with develop
- Merge vs rebase best practices for teams
- Step-by-step conflict resolution guide
- When and how to ask for help with complex conflicts
- Real-world example of multi-day feature work

This addresses common questions from contributors about branch
management and helps prevent large merge conflicts by encouraging
regular syncing with develop.
2025-11-06 15:05:27 -07:00
16 changed files with 1066 additions and 3408 deletions

View File

@@ -239,6 +239,163 @@ git push origin feature/my-feature
git push --no-verify
```
### Keeping Your Feature Branch Up-to-Date
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
#### Daily Start-of-Work Routine
```bash
# 1. Get latest changes from develop
git checkout develop
git pull origin develop
# 2. Update your feature branch
git checkout feature/my-feature
git merge develop
# 3. If there are conflicts (see below), resolve them
# 4. Continue working
```
**How often?**
- Minimum: Once per day (start of work)
- Better: Multiple times per day if develop is active
- Always: Before creating your Pull Request
#### Merge vs Rebase: Which to Use?
**For teams of 5+ developers, use `merge` (not `rebase`):**
```bash
git checkout feature/my-feature
git merge develop
```
**Why merge over rebase?**
- ✅ Safer: Preserves your commit history
- ✅ Collaborative: Works when multiple people work on the same feature branch
- ✅ Transparent: Shows when you integrated upstream changes
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
**When to use rebase:**
- ⚠️ Only if you haven't pushed yet
- ⚠️ Only if you're the sole developer on the branch
- ⚠️ You want a cleaner, linear history
```bash
# Only do this if you haven't pushed yet!
git checkout feature/my-feature
git rebase develop
```
**Never rebase after pushing** - it rewrites history and breaks collaboration.
#### Handling Merge Conflicts
When you run `git merge develop` and see conflicts:
```bash
$ git merge develop
Auto-merging app/Http/Controllers/OrderController.php
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
Automatic merge failed; fix conflicts and then commit the result.
```
**Step-by-step resolution:**
1. **See which files have conflicts:**
```bash
git status
# Look for "both modified:" files
```
2. **Open conflicted files** - look for conflict markers:
```php
<<<<<<< HEAD
// Your code
=======
// Code from develop
>>>>>>> develop
```
3. **Resolve conflicts** - edit the file to keep what you need:
```php
// Choose your code, their code, or combine both
// Remove the <<<, ===, >>> markers
```
4. **Mark as resolved:**
```bash
git add app/Http/Controllers/OrderController.php
```
5. **Complete the merge:**
```bash
git commit -m "merge: resolve conflicts with develop"
```
6. **Run tests to ensure nothing broke:**
```bash
./vendor/bin/sail artisan test
```
7. **Push the merge commit:**
```bash
git push origin feature/my-feature
```
#### When Conflicts Are Too Complex
If conflicts are extensive or you're unsure:
1. **Abort the merge:**
```bash
git merge --abort
```
2. **Ask for help** in #engineering Slack:
- "I'm merging develop into feature/X and have conflicts in OrderController"
- Someone might have context on the upstream changes
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
4. **Alternative: Start fresh** (last resort):
```bash
# Create new branch from latest develop
git checkout develop
git pull origin develop
git checkout -b feature/my-feature-v2
# Cherry-pick your commits
git cherry-pick <commit-hash>
```
#### Example: Multi-Day Feature Work
```bash
# Monday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Get latest changes
# Work all day, make commits
# Tuesday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Sync again (someone added auth changes)
# Continue working
# Wednesday
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Final sync before PR
git push origin feature/payment-integration
# Create Pull Request
```
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
### When to Test Locally
**Always run tests before pushing if you:**

View File

@@ -24,7 +24,7 @@ class ProductLineController extends Controller
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line created successfully.');
}
@@ -47,7 +47,7 @@ class ProductLineController extends Controller
]);
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line updated successfully.');
}
@@ -64,7 +64,7 @@ class ProductLineController extends Controller
$productLine->delete();
return redirect()
->route('seller.business.products.index1', $business->slug)
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line deleted successfully.');
}
}

View File

@@ -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');
}
}

View File

@@ -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

View File

@@ -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!');
}

View File

@@ -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";
}

28
package-lock.json generated
View File

@@ -1056,7 +1056,8 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@svgdotjs/svg.draggable.js": {
"version": "3.0.6",
@@ -1084,7 +1085,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz",
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Fuzzyma"
@@ -1108,7 +1108,6 @@
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz",
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14.18"
},
@@ -1482,7 +1481,6 @@
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
@@ -1641,7 +1639,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -2062,7 +2059,6 @@
"integrity": "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"meow": "^13.0.0"
},
@@ -2390,7 +2386,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2582,6 +2577,7 @@
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@@ -2595,6 +2591,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2612,6 +2609,7 @@
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
@@ -2762,8 +2760,7 @@
"version": "4.32.9",
"resolved": "https://registry.npmjs.org/filepond/-/filepond-4.32.9.tgz",
"integrity": "sha512-MnNnRrTS1M/6C/puIs5dNkbP1RMGANFxnygwdweNXAxyeCS4S68UM4CdvmyF7UrZB93Fhop7Bea2Ib6rkrYHUw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/filepond-plugin-file-validate-type": {
"version": "1.2.9",
@@ -3731,7 +3728,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3771,7 +3767,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3799,7 +3794,6 @@
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@@ -3970,6 +3964,7 @@
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
@@ -3985,6 +3980,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -4002,6 +3998,7 @@
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@@ -4015,6 +4012,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -4138,8 +4136,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -4323,7 +4320,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -4447,6 +4443,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -4467,6 +4464,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"peer": true,
"engines": {
"node": ">=0.4.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -7,8 +7,10 @@
@plugin "daisyui";
/* Iconify plugin */
@plugin "@iconify/tailwind4";
/* Iconify plugin (Lucide icons) */
@plugin "@iconify/tailwind4" {
prefixes: lucide, hugeicons, ri;
}
/* Alpine.js x-cloak directive */
[x-cloak] {

View File

@@ -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">&gt;</span>
<a href="{{ route('seller.business.products.index', $business->slug) }}" class="hover:text-gray-700">Products</a>
<span class="mx-2">&gt;</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">
@@ -117,7 +108,7 @@
<img src="{{ Storage::url($product->image_path) }}" alt="{{ $product->name }}" class="w-full rounded-lg">
@else
<div class="aspect-square bg-base-200 rounded-lg flex items-center justify-center">
<span class="text-base-content/50">No image</span>
<span class="icon-[lucide--image] size-16 text-base-content/40"></span>
</div>
@endif
@@ -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">
@@ -405,7 +396,10 @@
<template x-for="(image, index) in images" :key="image.id || index">
<div class="relative group cursor-move border-2 border-base-300 rounded-lg overflow-hidden hover:border-primary transition-colors bg-base-200"
:data-id="image.id">
<img :src="'/storage/' + image.path" alt="Product image" class="w-full h-40 object-cover" @@error="$event.target.src='/images/placeholder.png'">
<div class="flex items-center justify-center h-40">
<span class="icon-[lucide--image] size-16 text-base-content/40"></span>
</div>
<img :src="'/storage/' + image.path" alt="Product image" class="absolute inset-0 w-full h-40 object-cover" @@error="$event.target.style.display='none'">
<div class="absolute top-2 left-2">
<span class="badge badge-sm" :class="index === 0 ? 'badge-primary' : 'badge-ghost badge-outline'" x-text="index === 0 ? 'Primary' : (index + 1)"></span>
</div>
@@ -625,7 +619,9 @@
@if($variety->image_path)
<img src="{{ Storage::url($variety->image_path) }}" alt="{{ $variety->name }}">
@else
<div class="bg-base-300 w-full h-full flex items-center justify-center text-xs">No image</div>
<div class="bg-base-300 w-full h-full flex items-center justify-center">
<span class="icon-[lucide--image] size-8 text-base-content/40"></span>
</div>
@endif
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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');
});

View 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',
]);
}
}

View 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();
}
}