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