fix: team permissions, transactions, form sentinels and review polish
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c4e6173e46
commit
231123f641
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PricingPlan;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductScreenshot;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function integrityPayload(Product $product, array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'title' => 'Integrity Product',
|
||||
'description' => 'A product description',
|
||||
'website_url' => 'https://example.com',
|
||||
'category_id' => $product->category_id,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function productWithTeam(User $owner, string $status = 'approved'): array
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$product = Product::factory()->{$status}()->create(['user_id' => $owner->id]);
|
||||
$product->members()->attach($member->id, ['role' => 'Designer']);
|
||||
|
||||
return [$product, $member];
|
||||
}
|
||||
|
||||
test('moderator update leaves members untouched', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product, $member] = productWithTeam($owner);
|
||||
$other = User::factory()->create();
|
||||
|
||||
$moderator = userWithRole('moderator');
|
||||
$this->actingAs($moderator)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $other->id, 'role' => 'Hacker']],
|
||||
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
expect($product->members()->pluck('users.id')->all())->toBe([$member->id]);
|
||||
|
||||
$this->actingAs($moderator)->put(route('products.update', $product->fresh()), integrityPayload($product, [
|
||||
'members_present' => 1,
|
||||
]))->assertRedirect();
|
||||
expect($product->members()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('admin can change members on another users product', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product] = productWithTeam($owner);
|
||||
$other = User::factory()->create();
|
||||
|
||||
$this->actingAs(userWithRole('admin'))->put(route('products.update', $product), integrityPayload($product, [
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $other->id, 'role' => 'Lead']],
|
||||
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
expect($product->members()->pluck('users.id')->all())->toBe([$other->id]);
|
||||
});
|
||||
|
||||
test('owner can change members', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product] = productWithTeam($owner, 'pending');
|
||||
$other = User::factory()->create();
|
||||
|
||||
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $other->id, 'role' => 'Lead']],
|
||||
]))->assertRedirect();
|
||||
|
||||
expect($product->members()->pluck('users.id')->all())->toBe([$other->id]);
|
||||
});
|
||||
|
||||
test('moderator sees a read-only team on the edit form, owner sees inputs', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product, $member] = productWithTeam($owner);
|
||||
|
||||
$this->actingAs(userWithRole('moderator'))->get(route('products.edit', $product))
|
||||
->assertOk()->assertSee($member->name)
|
||||
->assertDontSee('members_present')
|
||||
->assertDontSee('Search users by name');
|
||||
|
||||
$this->actingAs($owner)->get(route('products.edit', $product))
|
||||
->assertOk()->assertSee('members_present')->assertSee('Search users by name');
|
||||
});
|
||||
|
||||
test('update without sentinels keeps existing plans and members', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product, $member] = productWithTeam($owner, 'pending');
|
||||
$product->pricingPlans()->create(['name' => 'Keep', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||
|
||||
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product))->assertRedirect();
|
||||
|
||||
expect($product->pricingPlans()->pluck('name')->all())->toBe(['Keep']);
|
||||
expect($product->members()->pluck('users.id')->all())->toBe([$member->id]);
|
||||
});
|
||||
|
||||
test('update with sentinels and no rows clears plans and members', function () {
|
||||
$owner = userWithRole('user');
|
||||
[$product] = productWithTeam($owner, 'pending');
|
||||
$product->pricingPlans()->create(['name' => 'Gone', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||
|
||||
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'plans_present' => 1,
|
||||
'members_present' => 1,
|
||||
]))->assertRedirect();
|
||||
|
||||
expect($product->pricingPlans()->count())->toBe(0);
|
||||
expect($product->members()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('forms post the sentinels and honour them after a validation error', function () {
|
||||
$owner = userWithRole('user');
|
||||
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||
$product->pricingPlans()->create(['name' => 'Stored', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||
|
||||
$this->actingAs($owner)->get(route('products.edit', $product))
|
||||
->assertOk()->assertSee('plans_present')->assertSee('Stored');
|
||||
|
||||
// User removed every plan, then validation failed: the plan must not come back.
|
||||
$this->actingAs($owner)->from(route('products.edit', $product))
|
||||
->put(route('products.update', $product), integrityPayload($product, ['title' => '', 'plans_present' => 1]));
|
||||
$this->actingAs($owner)->get(route('products.edit', $product))->assertOk();
|
||||
$html = $this->actingAs($owner)->followingRedirects()->from(route('products.edit', $product))
|
||||
->put(route('products.update', $product), integrityPayload($product, ['title' => '', 'plans_present' => 1]))
|
||||
->getContent();
|
||||
expect($html)->not->toContain('Stored');
|
||||
});
|
||||
|
||||
test('restored members show their names after a validation error', function () {
|
||||
$owner = userWithRole('user');
|
||||
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||
$m = User::factory()->create(['name' => 'Zed Zephyr']);
|
||||
|
||||
$html = $this->actingAs($owner)->followingRedirects()->from(route('products.edit', $product))
|
||||
->put(route('products.update', $product), integrityPayload($product, [
|
||||
'title' => '',
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $m->id, 'role' => 'Dev', 'name' => 'Zed Zephyr']],
|
||||
]))->getContent();
|
||||
|
||||
expect($html)->toContain('Zed Zephyr')->not->toContain('User #')->toContain('members[${i}][name]');
|
||||
});
|
||||
|
||||
test('failure part-way through update rolls back and removes new files', function () {
|
||||
Storage::fake('public');
|
||||
$owner = userWithRole('user');
|
||||
$product = Product::factory()->pending()->create(['user_id' => $owner->id, 'thumbnail' => 'thumbnails/old.jpg']);
|
||||
Storage::disk('public')->put('thumbnails/old.jpg', 'x');
|
||||
$product->pricingPlans()->create(['name' => 'Old', 'price' => 1, 'period' => 'monthly', 'position' => 0]);
|
||||
$shot = $product->screenshots()->create(['path' => 'screenshots/old.jpg']);
|
||||
Storage::disk('public')->put('screenshots/old.jpg', 'x');
|
||||
|
||||
ProductScreenshot::creating(fn () => throw new RuntimeException('boom'));
|
||||
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'title' => 'Changed',
|
||||
'plans_present' => 1,
|
||||
'plans' => [['name' => 'New', 'price' => '3', 'period' => 'yearly']],
|
||||
'remove_screenshots' => [$shot->id],
|
||||
'thumbnail' => UploadedFile::fake()->image('t.jpg'),
|
||||
'screenshots' => [UploadedFile::fake()->image('s.jpg')],
|
||||
])))->toThrow(RuntimeException::class);
|
||||
|
||||
expect($product->fresh()->title)->not->toBe('Changed');
|
||||
expect($product->pricingPlans()->pluck('name')->all())->toBe(['Old']);
|
||||
expect($product->screenshots()->count())->toBe(1);
|
||||
expect(Storage::disk('public')->allFiles())->toEqualCanonicalizing(['thumbnails/old.jpg', 'screenshots/old.jpg']);
|
||||
});
|
||||
|
||||
test('failure during store leaves no product and no files', function () {
|
||||
Storage::fake('public');
|
||||
$owner = userWithRole('user');
|
||||
ProductScreenshot::creating(fn () => throw new RuntimeException('boom'));
|
||||
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->actingAs($owner)->post(route('products.store'), [
|
||||
'title' => 'X', 'description' => 'D', 'website_url' => 'https://example.com',
|
||||
'category_id' => makeCategory()->id,
|
||||
'thumbnail' => UploadedFile::fake()->image('t.jpg'),
|
||||
'screenshots' => [UploadedFile::fake()->image('s.jpg')],
|
||||
]))->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Product::count())->toBe(0);
|
||||
expect(Storage::disk('public')->allFiles())->toBe([]);
|
||||
});
|
||||
|
||||
test('price allows at most two decimals', function () {
|
||||
$owner = userWithRole('user');
|
||||
$product = Product::factory()->pending()->create(['user_id' => $owner->id]);
|
||||
|
||||
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'plans_present' => 1,
|
||||
'plans' => [['name' => 'P', 'price' => '9.999', 'period' => 'monthly']],
|
||||
]))->assertSessionHasErrors('plans.0.price');
|
||||
});
|
||||
|
||||
test('admin can change badges on an approved product', function () {
|
||||
$product = Product::factory()->approved()->create(['eu_hosted' => false]);
|
||||
|
||||
$this->actingAs(userWithRole('admin'))->put(route('products.update', $product), integrityPayload($product, [
|
||||
'eu_hosted' => 1,
|
||||
]))->assertRedirect();
|
||||
|
||||
expect($product->fresh()->eu_hosted)->toBeTrue();
|
||||
});
|
||||
|
||||
test('submitter of a rejected product can change badges', function () {
|
||||
$owner = userWithRole('user');
|
||||
$product = Product::factory()->rejected()->create(['user_id' => $owner->id, 'eu_made' => false]);
|
||||
|
||||
$this->actingAs($owner)->put(route('products.update', $product), integrityPayload($product, [
|
||||
'eu_made' => 1,
|
||||
]))->assertRedirect();
|
||||
|
||||
expect($product->fresh()->eu_made)->toBeTrue();
|
||||
});
|
||||
|
||||
test('single screenshot gallery tile spans the full width', function () {
|
||||
$product = Product::factory()->approved()->create();
|
||||
$product->screenshots()->create(['path' => 'screenshots/a.jpg']);
|
||||
|
||||
$html = view('components.product.gallery', ['product' => $product->load('screenshots')])->render();
|
||||
expect($html)->toContain('sm:col-span-3')->not->toContain('sm:col-span-2');
|
||||
});
|
||||
@@ -56,6 +56,7 @@ test('update replaces the plan list', function () {
|
||||
|
||||
$this->actingAs($user)->put(route('products.update', $product), pricingPayload([
|
||||
'category_id' => $product->category_id,
|
||||
'plans_present' => 1,
|
||||
'plans' => [['name' => 'New', 'price' => '3', 'period' => 'one-time']],
|
||||
]))->assertRedirect();
|
||||
|
||||
|
||||
@@ -63,12 +63,14 @@ test('update replaces members and uses the product owner for the owner rule', fu
|
||||
|
||||
$this->actingAs($admin)->put(route('products.update', $product), teamPayload([
|
||||
'category_id' => $product->category_id,
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $owner->id, 'role' => 'Founder']],
|
||||
]))->assertSessionHasErrors('members.0.user_id');
|
||||
expect($product->members()->pluck('users.id')->all())->toBe([$a->id]);
|
||||
|
||||
$this->actingAs($admin)->put(route('products.update', $product), teamPayload([
|
||||
'category_id' => $product->category_id,
|
||||
'members_present' => 1,
|
||||
'members' => [['user_id' => $admin->id, 'role' => 'Helper'], ['user_id' => $b->id, 'role' => 'New']],
|
||||
]))->assertRedirect()->assertSessionHasNoErrors();
|
||||
expect($product->members()->pluck('users.id')->all())->toEqualCanonicalizing([$admin->id, $b->id]);
|
||||
|
||||
Reference in New Issue
Block a user