more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

39
tests/.auth/admin.json Normal file
View File

@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "XSRF-TOKEN",
"value": "eyJpdiI6IlIyTWVLc1doRWZmL0xhWmpIbFp5akE9PSIsInZhbHVlIjoiY2Q0ZzA1blBXRFpxR2pDdlZ5S2E5YmVYUWdyOHJsc3RYR0ZYZUllYjFZeHRZNmFMUXFuamd3NlZwVnNtaW10Qjhsd0JaRDVRYUg2cGFUTmE4ZjZIMlN0bmdSYzB3NnRveHcwMHI1QVdMSUJaQ3ZZNTZHeUxxUXBkbEFqYTkyZ0QiLCJtYWMiOiIyODg0YzY5NmVhNTVlMWMyMjA0YmMxMjcxYWJkZmJlZWU2ZGI1Mzg5NmY4Y2IzMGJmZWE5NGViN2I5NzRmYTY5IiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1772951912.063718,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "skinbasenova-session",
"value": "eyJpdiI6IklCV1VlRmlWL1owYkJiWTRpZkY4MkE9PSIsInZhbHVlIjoiRXJ2VTNjMm56Z3NNNWdwSE5RUHlSV2JhMHpoY21aeHJEb3JSUnVBRGIvVUpLZmVEbjBSM2ZQeFM0eDJEZDRYNHB0YVlpZUVqNVBYMFNXbXArYmFybVdrTnpBN3pJS0ZBcm9kSGlmQWs5dThVMXdyRUhOUjBRUFloWDJKWGl0L0UiLCJtYWMiOiIyYmZkMThmYjljNzIwMDgyOGE1N2U0NDgyMWVmNDg4YTU4MWFhNTk0MDA0NmM4MDA5YzU4ZGFlN2M5MGJmNjk3IiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1772951912.063846,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://skinbase26.test",
"localStorage": [
{
"name": "phpdebugbar-height",
"value": "301"
},
{
"name": "phpdebugbar-visible",
"value": "0"
}
]
}
]
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\File;
beforeEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
config(['uploads.storage_root' => $root]);
if (File::exists($root)) {
File::deleteDirectory($root);
}
File::makeDirectory($root, 0755, true);
});
afterEach(function () {
$root = storage_path('framework/testing/artwork-downloads');
if (File::exists($root)) {
File::deleteDirectory($root);
}
});
function makeOriginalFile(string $hash, string $ext, string $content = 'test-image-content'): string
{
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$dir = $root . DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
File::makeDirectory($dir, 0755, true, true);
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
File::put($path, $content);
return $path;
}
it('downloads an existing artwork file', function () {
$hash = 'a9f3e6c1b8';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'Sky Sunset',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$response->assertDownload('Sky Sunset.png');
});
it('forces the download filename using file_name and extension', function () {
$hash = 'b7c4d1e2f3';
$ext = 'jpg';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'file_name' => 'My Original Name',
'hash' => $hash,
'file_ext' => $ext,
]);
$response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk();
$response->assertDownload('My Original Name.jpg');
});
it('returns 404 for a missing artwork', function () {
$this->get('/download/artwork/999999')->assertNotFound();
});
it('returns 404 when the original file is missing', function () {
$artwork = Artwork::factory()->create([
'hash' => 'c1d2e3f4a5',
'file_ext' => 'webp',
]);
$this->get("/download/artwork/{$artwork->id}")->assertNotFound();
});
it('logs download metadata with user and request context', function () {
$hash = 'd4e5f6a7b8';
$ext = 'gif';
makeOriginalFile($hash, $ext);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->actingAs($user)
->withHeaders([
'User-Agent' => 'SkinbaseTestAgent/1.0',
'Referer' => 'https://example.test/art/' . $artwork->id,
])
->get("/download/artwork/{$artwork->id}")
->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'SkinbaseTestAgent/1.0',
'referer' => 'https://example.test/art/' . $artwork->id,
]);
});
it('logs guest download with null user_id', function () {
$hash = 'e1f2a3b4c5';
$ext = 'png';
makeOriginalFile($hash, $ext);
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => $ext,
]);
$this->get("/download/artwork/{$artwork->id}")->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => null,
]);
});

View File

@@ -1,5 +1,7 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;

View File

@@ -0,0 +1,76 @@
<?php
use App\Models\Story;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
function createPendingReviewStory(User $creator): Story
{
return Story::query()->create([
'creator_id' => $creator->id,
'title' => 'Pending Story ' . Str::random(6),
'slug' => 'pending-story-' . Str::lower(Str::random(8)),
'content' => '<p>Pending review content</p>',
'story_type' => 'creator_story',
'status' => 'pending_review',
'submitted_for_review_at' => now(),
]);
}
it('non moderator cannot access admin stories review queue', function () {
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get(route('admin.stories.review'))
->assertStatus(403);
});
it('admin can approve a pending story and notify creator', function () {
Notification::fake();
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$story = createPendingReviewStory($creator);
$response = $this->actingAs($admin)
->post(route('admin.stories.approve', ['story' => $story->id]));
$response->assertRedirect();
$story->refresh();
expect($story->status)->toBe('published');
expect($story->reviewed_by_id)->toBe($admin->id);
expect($story->reviewed_at)->not->toBeNull();
expect($story->published_at)->not->toBeNull();
Notification::assertSentTo($creator, StoryStatusNotification::class);
});
it('moderator can reject a pending story with reason and notify creator', function () {
Notification::fake();
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$story = createPendingReviewStory($creator);
$response = $this->actingAs($moderator)
->post(route('admin.stories.reject', ['story' => $story->id]), [
'reason' => 'Please remove promotional external links and resubmit.',
]);
$response->assertRedirect();
$story->refresh();
expect($story->status)->toBe('rejected');
expect($story->reviewed_by_id)->toBe($moderator->id);
expect($story->reviewed_at)->not->toBeNull();
expect($story->rejected_reason)->toContain('promotional external links');
Notification::assertSentTo($creator, StoryStatusNotification::class);
});

View File

@@ -0,0 +1,96 @@
<?php
use App\Models\Story;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
function storyPayload(array $overrides = []): array
{
return array_merge([
'title' => 'Story ' . Str::random(8),
'cover_image' => 'https://example.test/cover.jpg',
'excerpt' => 'A compact excerpt for testing.',
'story_type' => 'creator_story',
'content' => '<p>Hello Story World</p>',
'status' => 'draft',
'submit_action' => 'save_draft',
], $overrides);
}
it('creator can create a draft story from editor form', function () {
$creator = User::factory()->create();
$response = $this->actingAs($creator)
->post(route('creator.stories.store'), storyPayload());
$story = Story::query()->where('creator_id', $creator->id)->latest('id')->first();
expect($story)->not->toBeNull();
expect($story->status)->toBe('draft');
expect($story->slug)->not->toBe('');
$response->assertRedirect(route('creator.stories.edit', ['story' => $story->id]));
});
it('creator autosave updates draft fields and creates tags from csv', function () {
$creator = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $creator->id,
'title' => 'Autosave Draft',
'slug' => 'autosave-draft-' . Str::lower(Str::random(6)),
'content' => '<p>Original</p>',
'story_type' => 'creator_story',
'status' => 'draft',
]);
$response = $this->actingAs($creator)->postJson(
route('creator.stories.autosave', ['story' => $story->id]),
[
'title' => 'Autosaved Title',
'content' => '<p>Autosaved content with enough words for reading time.</p>',
'tags_csv' => 'alpha,beta',
]
);
$response->assertOk()->assertJson(['ok' => true]);
$story->refresh();
expect($story->title)->toBe('Autosaved Title');
expect($story->status)->toBe('draft');
$this->assertDatabaseHas('story_tags', ['slug' => 'alpha']);
$this->assertDatabaseHas('story_tags', ['slug' => 'beta']);
$tagIds = DB::table('story_tags')->whereIn('slug', ['alpha', 'beta'])->pluck('id')->all();
foreach ($tagIds as $tagId) {
$this->assertDatabaseHas('relation_story_tags', [
'story_id' => $story->id,
'tag_id' => $tagId,
]);
}
});
it('creator can submit draft for review', function () {
$creator = User::factory()->create();
$story = Story::query()->create([
'creator_id' => $creator->id,
'title' => 'Review Draft',
'slug' => 'review-draft-' . Str::lower(Str::random(6)),
'content' => '<p>Review content</p>',
'story_type' => 'creator_story',
'status' => 'draft',
]);
$response = $this->actingAs($creator)
->post(route('creator.stories.submit-review', ['story' => $story->id]));
$response->assertRedirect();
$story->refresh();
expect($story->status)->toBe('pending_review');
expect($story->submitted_for_review_at)->not->toBeNull();
});

View File

@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"598fdabf36083b33787e-d0e56fbd27a2103ba5b0"
]
}

View File

@@ -0,0 +1,289 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e10]: Internal Server Error
- button "Copy as Markdown" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- generic [ref=e15]: Copy as Markdown
- generic [ref=e18]:
- generic [ref=e19]:
- heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20]
- generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528
- paragraph [ref=e23]: Route [artwork.show] not defined.
- generic [ref=e24]:
- generic [ref=e25]:
- generic [ref=e26]:
- generic [ref=e27]: LARAVEL
- generic [ref=e28]: 12.53.0
- generic [ref=e29]:
- generic [ref=e30]: PHP
- generic [ref=e31]: 8.4.12
- generic [ref=e32]:
- img [ref=e33]
- text: UNHANDLED
- generic [ref=e36]: CODE 0
- generic [ref=e38]:
- generic [ref=e39]:
- img [ref=e40]
- text: "500"
- generic [ref=e43]:
- img [ref=e44]
- text: GET
- generic [ref=e47]: http://skinbase26.test/explore
- button [ref=e48] [cursor=pointer]:
- img [ref=e49]
- generic [ref=e53]:
- generic [ref=e54]:
- generic [ref=e55]:
- img [ref=e57]
- heading "Exception trace" [level=3] [ref=e60]
- generic [ref=e61]:
- generic [ref=e63] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e68]: 2 vendor frames
- button [ref=e69]:
- img [ref=e70]
- generic [ref=e74]:
- generic [ref=e75] [cursor=pointer]:
- generic [ref=e78]:
- code [ref=e82]:
- generic [ref=e83]: route(string, string)
- generic [ref=e85]: resources\views\web\explore\index.blade.php:18
- button [ref=e87]:
- img [ref=e88]
- code [ref=e96]:
- generic [ref=e97]: 13 <span class="text-xs font-semibold uppercase tracking-widest text-amber-400">✦ Featured Today</span>
- generic [ref=e98]: 14 <span class="flex-1 border-t border-white/10"></span>
- generic [ref=e99]: 15 </div>
- generic [ref=e100]: 16 <div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
- generic [ref=e101]: 17 @foreach($spotlight as $item)
- generic [ref=e102]: "18 <a href=\"{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}\""
- generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
- generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40
- generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
- generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">"
- generic [ref=e107]: "23"
- generic [ref=e108]: "24 {{-- Thumbnail --}}"
- generic [ref=e109]: 25 <div class="aspect-[4/3] overflow-hidden bg-neutral-900">
- generic [ref=e110]: 26 <img
- generic [ref=e111]: "27 src=\"{{ $item->thumb_url ?? '' }}\""
- generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif"
- generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\""
- generic [ref=e114]: "30"
- generic [ref=e116] [cursor=pointer]:
- img [ref=e117]
- generic [ref=e121]: 15 vendor frames
- button [ref=e122]:
- img [ref=e123]
- generic [ref=e128] [cursor=pointer]:
- generic [ref=e131]:
- code [ref=e135]:
- generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))"
- generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27
- button [ref=e140]:
- img [ref=e141]
- generic [ref=e146] [cursor=pointer]:
- img [ref=e147]
- generic [ref=e151]: 45 vendor frames
- button [ref=e152]:
- img [ref=e153]
- generic [ref=e158] [cursor=pointer]:
- generic [ref=e161]:
- code [ref=e165]:
- generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request))
- generic [ref=e168]: public\index.php:20
- button [ref=e170]:
- img [ref=e171]
- generic [ref=e175]:
- generic [ref=e176]:
- generic [ref=e177]:
- img [ref=e179]
- heading "Queries" [level=3] [ref=e181]
- generic [ref=e183]: 1-10 of 90
- generic [ref=e184]:
- generic [ref=e185]:
- generic [ref=e186]:
- generic [ref=e187]:
- img [ref=e188]
- generic [ref=e190]: mysql
- code [ref=e194]:
- generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e196]: 13.96ms
- generic [ref=e197]:
- generic [ref=e198]:
- generic [ref=e199]:
- img [ref=e200]
- generic [ref=e202]: mysql
- code [ref=e206]:
- generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e208]: 1.1ms
- generic [ref=e209]:
- generic [ref=e210]:
- generic [ref=e211]:
- img [ref=e212]
- generic [ref=e214]: mysql
- code [ref=e218]:
- generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')"
- generic [ref=e220]: 0.53ms
- generic [ref=e221]:
- generic [ref=e222]:
- generic [ref=e223]:
- img [ref=e224]
- generic [ref=e226]: mysql
- code [ref=e230]:
- generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1"
- generic [ref=e232]: 0.69ms
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]:
- img [ref=e236]
- generic [ref=e238]: mysql
- code [ref=e242]:
- generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')"
- generic [ref=e244]: 0.51ms
- generic [ref=e245]:
- generic [ref=e246]:
- generic [ref=e247]:
- img [ref=e248]
- generic [ref=e250]: mysql
- code [ref=e254]:
- generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)"
- generic [ref=e256]: 3.31ms
- generic [ref=e257]:
- generic [ref=e258]:
- generic [ref=e259]:
- img [ref=e260]
- generic [ref=e262]: mysql
- code [ref=e266]:
- generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')"
- generic [ref=e268]: 2.8ms
- generic [ref=e269]:
- generic [ref=e270]:
- generic [ref=e271]:
- img [ref=e272]
- generic [ref=e274]: mysql
- code [ref=e278]:
- generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1"
- generic [ref=e280]: 1.28ms
- generic [ref=e281]:
- generic [ref=e282]:
- generic [ref=e283]:
- img [ref=e284]
- generic [ref=e286]: mysql
- code [ref=e290]:
- generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')"
- generic [ref=e292]: 0.58ms
- generic [ref=e293]:
- generic [ref=e294]:
- generic [ref=e295]:
- img [ref=e296]
- generic [ref=e298]: mysql
- code [ref=e302]:
- generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)"
- generic [ref=e304]: 3.13ms
- generic [ref=e305]:
- button [disabled] [ref=e306]:
- img [ref=e307]
- button [disabled] [ref=e310]:
- img [ref=e311]
- button "1" [ref=e314] [cursor=pointer]
- button "2" [ref=e316] [cursor=pointer]
- button "3" [ref=e318] [cursor=pointer]
- button "4" [ref=e320] [cursor=pointer]
- button "5" [ref=e322] [cursor=pointer]
- generic [ref=e324]: ...
- button "9" [ref=e326] [cursor=pointer]
- button [ref=e327] [cursor=pointer]:
- img [ref=e328]
- button [ref=e330] [cursor=pointer]:
- img [ref=e331]
- generic [ref=e335]:
- generic [ref=e336]:
- heading "Headers" [level=2] [ref=e337]
- generic [ref=e338]:
- generic [ref=e339]:
- generic [ref=e340]: cookie
- generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D
- generic [ref=e343]:
- generic [ref=e344]: accept-encoding
- generic [ref=e346]: gzip, deflate
- generic [ref=e347]:
- generic [ref=e348]: accept
- generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
- generic [ref=e351]:
- generic [ref=e352]: accept-language
- generic [ref=e354]: en-US
- generic [ref=e355]:
- generic [ref=e356]: user-agent
- generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36
- generic [ref=e359]:
- generic [ref=e360]: upgrade-insecure-requests
- generic [ref=e362]: "1"
- generic [ref=e363]:
- generic [ref=e364]: connection
- generic [ref=e366]: close
- generic [ref=e367]:
- generic [ref=e368]: host
- generic [ref=e370]: skinbase26.test
- generic [ref=e371]:
- heading "Body" [level=2] [ref=e372]
- generic [ref=e373]: // No request body
- generic [ref=e374]:
- heading "Routing" [level=2] [ref=e375]
- generic [ref=e376]:
- generic [ref=e377]:
- generic [ref=e378]: controller
- generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index
- generic [ref=e381]:
- generic [ref=e382]: route name
- generic [ref=e384]: explore.index
- generic [ref=e385]:
- generic [ref=e386]: middleware
- generic [ref=e388]: web
- generic [ref=e389]:
- heading "Routing parameters" [level=2] [ref=e390]
- generic [ref=e391]: // No routing parameters
- generic [ref=e394]:
- img [ref=e396]
- img [ref=e3434]
- generic [ref=e6474]:
- generic [ref=e6476]:
- generic [ref=e6477] [cursor=pointer]:
- generic: Request
- generic [ref=e6478]: "500"
- generic [ref=e6479] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e6480]: "2"
- generic [ref=e6481] [cursor=pointer]:
- generic: Messages
- generic [ref=e6482]: "5"
- generic [ref=e6483] [cursor=pointer]:
- generic: Timeline
- generic [ref=e6484] [cursor=pointer]:
- generic: Views
- generic [ref=e6485]: "513"
- generic [ref=e6486] [cursor=pointer]:
- generic: Queries
- generic [ref=e6487]: "91"
- generic [ref=e6488] [cursor=pointer]:
- generic: Models
- generic [ref=e6489]: "156"
- generic [ref=e6490] [cursor=pointer]:
- generic: Cache
- generic [ref=e6491]: "8"
- generic [ref=e6492]:
- generic [ref=e6499] [cursor=pointer]:
- generic [ref=e6500]: "2"
- generic [ref=e6501]: GET /explore
- generic [ref=e6502] [cursor=pointer]:
- generic: 4.91s
- generic [ref=e6504] [cursor=pointer]:
- generic: 51MB
- generic [ref=e6506] [cursor=pointer]:
- generic: 12.x
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

28
tests/cpad/auth.setup.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* cPad Authentication Setup
*
* This file is matched by the `cpad-setup` Playwright project (see playwright.config.ts).
* It runs ONCE before all cpad tests, logs in as admin, and saves the browser
* storage state to tests/.auth/admin.json so subsequent tests skip the login step.
*
* Run only this setup step:
* npx playwright test --project=cpad-setup
*/
import { test as setup } from '@playwright/test';
import { loginAsAdmin, AUTH_FILE } from '../helpers/auth';
import fs from 'fs';
import path from 'path';
setup('authenticate as cPad admin', async ({ page }) => {
// Ensure the .auth directory exists
const authDir = path.dirname(AUTH_FILE);
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
await loginAsAdmin(page);
// Persist cookies + localStorage for the cpad project
await page.context().storageState({ path: AUTH_FILE });
});

149
tests/cpad/auth.spec.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* Authentication tests for the cPad Control Panel.
*
* Coverage:
* • Login page renders correctly
* • Login with valid credentials → redirected to /cp/dashboard (or /cp/)
* • Login with invalid credentials → error message shown, no redirect
* • Logout → session is destroyed, login page is shown
*
* These tests do NOT use the pre-saved storageState; they exercise the actual
* login/logout flow from scratch.
*
* Run:
* npx playwright test tests/cpad/auth.spec.ts --project=chromium
*/
import { test, expect } from '@playwright/test';
import {
ADMIN_EMAIL,
ADMIN_PASSWORD,
CP_PATH,
DASHBOARD_PATH,
attachErrorListeners,
} from '../helpers/auth';
// ─────────────────────────────────────────────────────────────────────────────
// Login page
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Login Page', () => {
test('login page loads and shows email + password fields', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
// Basic page health
await expect(page.locator('body')).toBeVisible();
const title = await page.title();
expect(title.length, 'Page title should not be empty').toBeGreaterThan(0);
// Form fields
const emailField = page.locator('input[name="email"], input[type="email"]').first();
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
await expect(emailField).toBeVisible();
await expect(passwordField).toBeVisible();
await expect(submitButton).toBeVisible();
// No JS crashes on page load
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Successful login
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Successful Login', () => {
test('admin can log in and is redirected to dashboard', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"], input[type="submit"]').first().click();
// After successful login the URL should be within /cp and not be /login
await page.waitForURL(
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
{ timeout: 20_000 },
);
await expect(page.locator('body')).toBeVisible();
// No server errors
const body = await page.locator('body').textContent() ?? '';
expect(/Whoops|Server Error|500/.test(body), 'Server error page after login').toBe(false);
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Failed login
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Failed Login', () => {
test('wrong password shows error and stays on login page', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill('WrongPassword999!');
await page.locator('button[type="submit"], input[type="submit"]').first().click();
// Should stay on the login page
await page.waitForLoadState('networkidle');
expect(page.url()).toContain('/login');
// No 5xx errors from a bad credentials attempt
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
});
test('empty credentials show validation errors', async ({ page }) => {
await page.goto(CP_PATH + '/login');
// Submit without filling in anything
await page.locator('button[type="submit"], input[type="submit"]').first().click();
await page.waitForLoadState('networkidle');
// Still on login page
expect(page.url()).toContain(CP_PATH);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Logout', () => {
test('logout destroys session and shows login page', async ({ page }) => {
// Log in first
await page.goto(CP_PATH + '/login');
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"], input[type="submit"]').first().click();
await page.waitForURL(
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
{ timeout: 20_000 },
);
// Perform logout via the /cp/logout route
await page.goto(CP_PATH + '/logout');
await page.waitForLoadState('networkidle');
// Should land on the login page again
expect(page.url()).toContain('/login');
// Attempting to access dashboard now should redirect back to login
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
expect(page.url()).toContain('/login');
});
});

View File

@@ -0,0 +1,122 @@
/**
* Dashboard tests for the cPad Control Panel.
*
* Uses the pre-saved authenticated session (tests/.auth/admin.json) so no
* explicit login is required in each test.
*
* Coverage:
* • Dashboard page loads (HTTP 200, not a Laravel error page)
* • Page title is not empty
* • No JavaScript console errors
* • No HTTP 5xx responses
* • Key dashboard elements are visible (sidebar, main content area)
* • Dashboard setup page loads
*
* Run:
* npx playwright test tests/cpad/dashboard.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { DASHBOARD_PATH, CP_PATH, attachErrorListeners } from '../helpers/auth';
test.describe('cPad Dashboard', () => {
test('dashboard page loads without errors', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Must not be redirected to login
expect(page.url(), 'Should not redirect to login').not.toContain('/login');
// Title check
const title = await page.title();
expect(title.length, 'Page must have a non-empty title').toBeGreaterThan(0);
// Body must be visible
await expect(page.locator('body')).toBeVisible();
// No server error page
const bodyText = await page.locator('body').textContent() ?? '';
expect(/Whoops|Server Error|5[0-9]{2}/.test(bodyText), 'Server error detected in body').toBe(false);
// No JS exceptions
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test('dashboard root redirect works', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
// /cp redirects to /cp/dashboard
await page.goto(CP_PATH);
await page.waitForLoadState('networkidle');
expect(page.url(), 'Should not be on login page').not.toContain('/login');
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test('dashboard has visible sidebar or navigation', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Check for a nav/sidebar element — any of these common AdminLTE/Bootstrap selectors
const navSelectors = [
'nav',
'.sidebar',
'#sidebar',
'.main-sidebar',
'#main-sidebar',
'[data-testid="sidebar"]',
'.navbar',
];
let foundNav = false;
for (const sel of navSelectors) {
const count = await page.locator(sel).count();
if (count > 0) {
foundNav = true;
break;
}
}
expect(foundNav, 'Dashboard should contain a sidebar or navigation element').toBe(true);
});
test('dashboard main content area is visible', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Any of these indicate a main content wrapper is rendered
const contentSelectors = [
'main',
'#content',
'.content-wrapper',
'.main-content',
'[data-testid="main-content"]',
'.wrapper',
];
let foundContent = false;
for (const sel of contentSelectors) {
const count = await page.locator(sel).count();
if (count > 0) {
foundContent = true;
break;
}
}
expect(foundContent, 'Dashboard main content area should be present').toBe(true);
});
test('dashboard setup page loads', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(CP_PATH + '/dashboard/setup');
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Configuration module tests for cPad.
*
* Routes:
* /cp/configuration — main configuration overview (alias)
* /cp/config — canonical config route
*
* Coverage:
* • Both config URLs load without errors
* • No console/server errors
* • Configuration form is present and contains input elements
* • Smart form filler can fill the form without crashing
* • Tab-based navigation within config (if applicable) works
*
* Run:
* npx playwright test tests/cpad/modules/configuration.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
import { fillForm, hasForm } from '../../helpers/formFiller';
const CONFIG_URLS = [
`${CP_PATH}/configuration`,
`${CP_PATH}/config`,
] as const;
test.describe('cPad Configuration Module', () => {
for (const configUrl of CONFIG_URLS) {
test(`config page (${configUrl}) loads without errors`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(configUrl);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error at ${configUrl}`).toBe(false);
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
}
test('configuration page (/cp/config) contains a form or settings inputs', async ({ page }) => {
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
const pageHasForm = await hasForm(page);
expect(pageHasForm, '/cp/config should contain a form or input fields').toBe(true);
});
test('smart form filler can fill configuration form without errors', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// Fill the form — should not throw
await fillForm(page);
// No 5xx errors triggered by the fill operations
expect(networkErrors.length, `HTTP 5xx during form fill: ${networkErrors.join(' | ')}`).toBe(0);
});
test('configuration tab navigation works (if tabs present)', async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/config`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// Look for tab buttons (Bootstrap / AdminLTE tabs)
const tabSelectors = [
'[role="tab"]',
'.nav-tabs .nav-link',
'.nav-pills .nav-link',
'a[data-toggle="tab"]',
'a[data-bs-toggle="tab"]',
];
for (const sel of tabSelectors) {
const tabs = page.locator(sel);
const count = await tabs.count();
if (count > 1) {
// Click each tab and verify no server errors
for (let i = 0; i < Math.min(count, 8); i++) {
const tab = tabs.nth(i);
if (await tab.isVisible()) {
await tab.click();
await page.waitForLoadState('domcontentloaded').catch(() => null);
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE/.test(bodyText);
expect(hasError, `Error after clicking tab ${i} at ${sel}`).toBe(false);
}
}
break; // found tabs, done
}
}
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,94 @@
/**
* Languages module tests for cPad.
*
* Routes under /cp/language/{type} — type can be: app, system
*
* Coverage:
* • Language list pages load without errors
* • No console/server errors
* • Add-language page loads
* • (Optional) CRUD flow when list data is available
*
* Run:
* npx playwright test tests/cpad/modules/languages.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
import { hasListData, tryCrudCreate } from '../../helpers/crudHelper';
const LANGUAGE_TYPES = ['app', 'system'] as const;
test.describe('cPad Languages Module', () => {
for (const type of LANGUAGE_TYPES) {
const basePath = `${CP_PATH}/language/${type}`;
test(`language list (${type}) loads without errors`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(basePath);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error on ${basePath}`).toBe(false);
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test(`language add page (${type}) loads`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(`${basePath}/add`);
await page.waitForLoadState('networkidle');
expect(page.url()).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
});
test(`language list (${type}) shows table or empty state`, async ({ page }) => {
await page.goto(basePath);
await page.waitForLoadState('networkidle');
// Either a data table OR a "no data" message must be present
const tableCount = await page.locator('table').count();
const emptyMsgCount = await page.locator(
':has-text("No languages"), :has-text("No records"), :has-text("Empty")',
).count();
expect(tableCount + emptyMsgCount, 'Should show a table or an empty-state message').toBeGreaterThan(0);
});
}
test('language list (app) — CRUD: add language form submission', async ({ page }) => {
const { networkErrors } = attachErrorListeners(page);
await page.goto(`${CP_PATH}/language/app/add`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Skipped: not authenticated');
return;
}
// Try submitting the add form
const didCreate = await tryCrudCreate(page);
if (!didCreate) {
// The add page itself is the form; submit directly
const submit = page.locator('button[type=submit], input[type=submit]').first();
if (await submit.count() > 0 && await submit.isVisible()) {
await submit.click();
await page.waitForLoadState('networkidle');
}
}
expect(networkErrors.length, `HTTP 5xx after form submit: ${networkErrors.join(' | ')}`).toBe(0);
});
});

View File

@@ -0,0 +1,112 @@
/**
* Translations module tests for cPad.
*
* Routes under /cp/translation/{file}
* The most common translation file is "app".
*
* Coverage:
* • Main translation index page loads
* • Translation list page for "app" file loads
* • Add translation entry page loads
* • Translation grid page loads
* • No console/server errors on any page
* • (Optional) inline edit via CRUD helper
*
* Run:
* npx playwright test tests/cpad/modules/translations.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
/** Translation file slugs to probe — add more as needed */
const TRANSLATION_FILES = ['app'] as const;
/** Reusable page-health assertion */
async function assertPageHealthy(page: import('@playwright/test').Page, path: string) {
const consoleErrors: string[] = [];
const networkErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
page.on('pageerror', (err) => consoleErrors.push(err.message));
page.on('response', (res) => {
if (res.status() >= 500) networkErrors.push(`HTTP ${res.status()}: ${res.url()}`);
});
await page.goto(path);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
expect(page.url(), `${path} must not redirect to login`).not.toContain('/login');
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
expect(hasError, `Server error at ${path}`).toBe(false);
expect(consoleErrors.length, `Console errors at ${path}: ${consoleErrors.join(' | ')}`).toBe(0);
expect(networkErrors.length, `HTTP 5xx at ${path}: ${networkErrors.join(' | ')}`).toBe(0);
}
test.describe('cPad Translations Module', () => {
for (const file of TRANSLATION_FILES) {
const base = `${CP_PATH}/translation/${file}`;
test(`translation main page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, base);
});
test(`translation list page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/list`);
});
test(`translation add page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/add`);
});
test(`translation grid page (${file}) loads`, async ({ page }) => {
await assertPageHealthy(page, `${base}/grid`);
});
test(`translation list (${file}) renders content`, async ({ page }) => {
await page.goto(`${base}/list`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
// A table, a grid element, or an empty-state message should be present
const tableCount = await page.locator('table, .grid, [class*="grid"]').count();
const emptyMsgCount = await page.locator(
':has-text("No translations"), :has-text("No records"), :has-text("Empty")',
).count();
const inputCount = await page.locator('input[type=text], textarea').count();
expect(
tableCount + emptyMsgCount + inputCount,
'Translation list should contain table, grid, empty state, or editable fields',
).toBeGreaterThan(0);
});
test(`translation add page (${file}) contains a form`, async ({ page }) => {
await page.goto(`${base}/add`);
await page.waitForLoadState('networkidle');
if (page.url().includes('/login')) {
test.skip(true, 'Not authenticated');
return;
}
const formCount = await page.locator('form').count();
const inputCount = await page.locator('input:not([type=hidden]), textarea').count();
expect(
formCount + inputCount,
'Add translation page should contain a form or input fields',
).toBeGreaterThan(0);
});
}
});

View File

@@ -0,0 +1,95 @@
/**
* Navigation Discovery — scans the cPad sidebar and collects all /cp links.
*
* This test acts as both:
* 1. A standalone spec that validates the nav scan returns ≥ 1 link.
* 2. A data producer: it writes the discovered URLs to
* tests/.discovered/cpad-links.json so that navigation.spec.ts can
* consume them dynamically.
*
* Ignored links:
* • /cp/logout
* • javascript:void / # anchors
* • External URLs (not starting with /cp)
*
* Run:
* npx playwright test tests/cpad/navigation-discovery.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { DASHBOARD_PATH, CP_PATH } from '../helpers/auth';
import fs from 'fs';
import path from 'path';
const DISCOVERED_DIR = path.join('tests', '.discovered');
const DISCOVERED_FILE = path.join(DISCOVERED_DIR, 'cpad-links.json');
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Returns true if the href should be included in navigation tests */
function isNavigableLink(href: string | null): boolean {
if (!href) return false;
if (!href.startsWith(CP_PATH)) return false;
if (href.includes('/logout')) return false;
if (href.startsWith('javascript:')) return false;
if (href === CP_PATH || href === CP_PATH + '/') return false; // exclude root (same as dashboard)
return true;
}
/** Remove query strings and anchors for clean deduplication */
function normalise(href: string): string {
try {
const u = new URL(href, 'http://placeholder');
return u.pathname;
} catch {
return href.split('?')[0].split('#')[0];
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Discovery test
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Navigation Discovery', () => {
test('scan sidebar and collect all /cp navigation links', async ({ page }) => {
await page.goto(DASHBOARD_PATH);
await page.waitForLoadState('networkidle');
// Ensure we're not on the login page
expect(page.url()).not.toContain('/login');
// ── Widen the scan to cover lazy-loaded submenu items ──────────────────
// Hover over sidebar nav items to expand hidden submenus
const menuItems = page.locator('.nav-item, .sidebar-item, .menu-item, li.nav-item');
const menuCount = await menuItems.count();
for (let i = 0; i < Math.min(menuCount, 30); i++) {
await menuItems.nth(i).hover().catch(() => null);
}
// ── Collect all anchor hrefs ───────────────────────────────────────────
const rawHrefs: string[] = await page.$$eval('a[href]', (anchors) =>
anchors.map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? ''),
);
const discovered = [...new Set(
rawHrefs
.map((h) => normalise(h))
.filter(isNavigableLink),
)].sort();
console.log(`[discovery] Found ${discovered.length} navigable /cp links`);
discovered.forEach((l) => console.log(' •', l));
// Must find at least a few links — if not, something is wrong with auth
expect(discovered.length, 'Expected to find /cp navigation links').toBeGreaterThanOrEqual(1);
// ── Persist for navigation.spec.ts ────────────────────────────────────
if (!fs.existsSync(DISCOVERED_DIR)) {
fs.mkdirSync(DISCOVERED_DIR, { recursive: true });
}
fs.writeFileSync(DISCOVERED_FILE, JSON.stringify(discovered, null, 2), 'utf8');
console.log(`[discovery] Links saved to ${DISCOVERED_FILE}`);
});
});

View File

@@ -0,0 +1,107 @@
/**
* Navigation health test for the cPad Control Panel.
*
* Two operating modes:
*
* 1. Dynamic — reads tests/.discovered/cpad-links.json (written by
* navigation-discovery.spec.ts) and visits every link.
* Run the discovery spec first to populate this file.
*
* 2. Fallback — when the discovery file is absent, falls back to a static
* list of known /cp routes so the suite never fails silently.
*
* For every visited page the test asserts:
* • No redirect to /cp/login (i.e. session is still valid)
* • HTTP status not 5xx (detected via response interception)
* • No uncaught JavaScript exceptions
* • No browser console errors
* • Page body is visible and non-empty
* • Page does not contain Laravel error text
*
* Run:
* npx playwright test tests/cpad/navigation.spec.ts --project=cpad
*/
import { test, expect } from '@playwright/test';
import { CP_PATH, attachErrorListeners } from '../helpers/auth';
import { hasForm } from '../helpers/formFiller';
import fs from 'fs';
import path from 'path';
// ─────────────────────────────────────────────────────────────────────────────
// Link source
// ─────────────────────────────────────────────────────────────────────────────
const DISCOVERED_FILE = path.join('tests', '.discovered', 'cpad-links.json');
/** Well-known /cp pages used when the discovery file is missing */
const STATIC_FALLBACK_LINKS: string[] = [
'/cp/dashboard',
'/cp/configuration',
'/cp/config',
'/cp/language/app',
'/cp/language/system',
'/cp/translation/app',
'/cp/security/access',
'/cp/security/roles',
'/cp/security/permissions',
'/cp/security/login',
'/cp/plugins',
'/cp/user/profile',
'/cp/messages',
'/cp/api/keys',
'/cp/friendly-url',
];
function loadLinks(): string[] {
if (fs.existsSync(DISCOVERED_FILE)) {
try {
const links: string[] = JSON.parse(fs.readFileSync(DISCOVERED_FILE, 'utf8'));
if (links.length > 0) return links;
} catch { /* fall through to static list */ }
}
console.warn('[navigation] discovery file not found — using static fallback list');
return STATIC_FALLBACK_LINKS;
}
const CP_LINKS = loadLinks();
// ─────────────────────────────────────────────────────────────────────────────
// Main navigation suite
// ─────────────────────────────────────────────────────────────────────────────
test.describe('cPad Navigation Health', () => {
for (const linkPath of CP_LINKS) {
test(`page loads: ${linkPath}`, async ({ page }) => {
const { consoleErrors, networkErrors } = attachErrorListeners(page);
await page.goto(linkPath);
await page.waitForLoadState('networkidle', { timeout: 20_000 });
// ── Auth check ──────────────────────────────────────────────────────
expect(page.url(), `${linkPath} should not redirect to login`).not.toContain('/login');
// ── HTTP errors ─────────────────────────────────────────────────────
expect(networkErrors.length, `HTTP 5xx on ${linkPath}: ${networkErrors.join(' | ')}`).toBe(0);
// ── Body visibility ─────────────────────────────────────────────────
await expect(page.locator('body')).toBeVisible();
const bodyText = await page.locator('body').textContent() ?? '';
expect(bodyText.trim().length, `Body should not be empty on ${linkPath}`).toBeGreaterThan(0);
// ── Laravel / server error page ──────────────────────────────────────
const hasServerError = /Whoops[,!]|Server Error|Call to undefined function|SQLSTATE/.test(bodyText);
expect(hasServerError, `Server/Laravel error page at ${linkPath}: ${bodyText.slice(0, 200)}`).toBe(false);
// ── JS exceptions ────────────────────────────────────────────────────
expect(consoleErrors.length, `JS console errors on ${linkPath}: ${consoleErrors.join(' | ')}`).toBe(0);
// ── Form detection (informational — logged, not asserted) ─────────────
const pageHasForm = await hasForm(page);
if (pageHasForm) {
console.log(` [form] form detected on ${linkPath}`);
}
});
}
});

93
tests/helpers/auth.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Authentication helper for cPad Control Panel tests.
*
* Usage in tests that do NOT use the pre-saved storageState:
* import { loginAsAdmin } from '../helpers/auth';
* await loginAsAdmin(page);
*
* The cpad-setup project (auth.setup.ts) calls loginAsAdmin once and persists
* the session to tests/.auth/admin.json so all other cpad tests reuse it.
*/
import { type Page, expect } from '@playwright/test';
import path from 'path';
export const ADMIN_EMAIL = 'gregor@klevze.si';
export const ADMIN_PASSWORD = 'Gre15#10gor!1976$';
export const CP_PATH = '/cp';
export const DASHBOARD_PATH = '/cp/dashboard';
export const AUTH_FILE = path.join('tests', '.auth', 'admin.json');
/**
* Perform a full cPad login and wait for the dashboard to load.
* Verifies the redirect ends up on a /cp/dashboard URL.
*/
export async function loginAsAdmin(page: Page): Promise<void> {
// Attach console-error listener so callers can detect JS exceptions
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.warn(`[CONSOLE ERROR] ${msg.text()}`);
}
});
await page.goto(CP_PATH);
// Wait for the login form to be ready
const emailField = page.locator('input[name="email"], input[type="email"]').first();
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
await emailField.waitFor({ state: 'visible', timeout: 15_000 });
await emailField.fill(ADMIN_EMAIL);
await passwordField.fill(ADMIN_PASSWORD);
await submitButton.click();
// After login, the panel may show a 2FA screen or land on dashboard
// Wait up to 20 s for a URL that contains /cp (but isn't the login page)
await page.waitForURL((url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'), {
timeout: 20_000,
});
}
/**
* Verify the current page is the cPad dashboard.
* Call this *after* loginAsAdmin to assert a successful login.
*/
export async function assertDashboard(page: Page): Promise<void> {
await expect(page).toHaveURL(new RegExp(`${CP_PATH}`));
}
/**
* Attach console-error and network-failure listeners to the page.
* Returns arrays that accumulate errors so the calling test can assert them.
*/
export function attachErrorListeners(page: Page): {
consoleErrors: string[];
networkErrors: string[];
} {
const consoleErrors: string[] = [];
const networkErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
consoleErrors.push(`[pageerror] ${err.message}`);
});
page.on('response', (response) => {
const status = response.status();
if (status >= 500) {
networkErrors.push(`HTTP ${status}: ${response.url()}`);
}
});
page.on('requestfailed', (request) => {
networkErrors.push(`[requestfailed] ${request.url()}${request.failure()?.errorText}`);
});
return { consoleErrors, networkErrors };
}

147
tests/helpers/crudHelper.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* CRUD Helper for cPad module tests.
*
* Provides reusable functions that detect and execute common CRUD operations
* (Create, Edit, Delete) through the cPad UI without hard-coding selectors
* for every individual module.
*
* Strategy:
* • "Create" — look for button text: Create / Add / New
* • "Edit" — look for button/link text: Edit, or table action icons
* • "Delete" — look for button/link text: Delete / Remove (with confirmation handling)
*
* Each function returns false if the appropriate trigger could not be found,
* allowing callers to skip gracefully when a module lacks a given operation.
*/
import { type Page, expect } from '@playwright/test';
import { fillForm } from './formFiller';
// Selectors used to locate CRUD triggers (order = priority)
const CREATE_SELECTORS = [
'a:has-text("Create")',
'a:has-text("Add")',
'a:has-text("New")',
'button:has-text("Create")',
'button:has-text("Add")',
'button:has-text("New")',
'[data-testid="btn-create"]',
'[data-testid="btn-add"]',
];
const EDIT_SELECTORS = [
'a:has-text("Edit")',
'button:has-text("Edit")',
'td a[href*="/edit/"]',
'[data-testid="btn-edit"]',
];
const DELETE_SELECTORS = [
'a:has-text("Delete")',
'button:has-text("Delete")',
'a:has-text("Remove")',
'button:has-text("Remove")',
'[data-testid="btn-delete"]',
];
/**
* Try to find and click a Create/Add/New button, fill the resulting form,
* submit it, and verify the page does not show a 500 error.
*
* @returns true if a create button was found and the flow completed without crashing.
*/
export async function tryCrudCreate(page: Page): Promise<boolean> {
for (const selector of CREATE_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
await btn.click();
// Wait for either a form or a new page section to appear
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
// Fill any visible form
await fillForm(page);
// Look for a submit button
const submit = page.locator('button[type=submit], input[type=submit]').first();
if (await submit.count() > 0 && await submit.isVisible()) {
await submit.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
// Fail if a Laravel/server error page appeared
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after create on ${page.url()}`).toBe(false);
}
return true;
}
}
return false;
}
/**
* Try to find and click the first Edit button/link on the page, fill the form,
* and submit.
*
* @returns true if an edit trigger was found.
*/
export async function tryCrudEdit(page: Page): Promise<boolean> {
for (const selector of EDIT_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
await btn.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
await fillForm(page);
const submit = page.locator('button[type=submit], input[type=submit]').first();
if (await submit.count() > 0 && await submit.isVisible()) {
await submit.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after edit on ${page.url()}`).toBe(false);
}
return true;
}
}
return false;
}
/**
* Try to click the first delete trigger, handle a confirmation dialog if one
* appears, and assert no server error is displayed.
*
* @returns true if a delete trigger was found.
*/
export async function tryCrudDelete(page: Page): Promise<boolean> {
for (const selector of DELETE_SELECTORS) {
const btn = page.locator(selector).first();
if (await btn.count() > 0 && await btn.isVisible()) {
// Some delete links pop a browser confirm() dialog
page.once('dialog', (dialog) => dialog.accept());
await btn.click();
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
const body = await page.locator('body').textContent() ?? '';
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
expect(hasServerError, `Server error after delete on ${page.url()}`).toBe(false);
return true;
}
}
return false;
}
/**
* Quick check whether the page contains any list/table data (i.e. the module
* has records to operate on).
*/
export async function hasListData(page: Page): Promise<boolean> {
const tableRows = await page.locator('table tbody tr').count();
return tableRows > 0;
}

127
tests/helpers/formFiller.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Smart Form Filler helper for cPad tests.
*
* Detects all interactive form controls on a page (or within a locator scope)
* and fills them with sensible test values so form-validation tests do not
* need to enumerate every field manually.
*
* Supported field types:
* text, email, number, url, tel, search, password, date, time, textarea,
* checkbox, radio, select, file (skipped)
*
* Hidden fields and readonly fields are ignored.
*/
import { type Locator, type Page } from '@playwright/test';
/** Seed text used for text-like inputs */
const RANDOM_TEXT = `Test_${Date.now()}`;
const RANDOM_EMAIL = 'playwright-test@test.com';
const RANDOM_NUM = '42';
const RANDOM_URL = 'https://skinbase.test';
const RANDOM_DATE = '2025-01-01';
const RANDOM_TIME = '09:00';
const RANDOM_TEXTAREA = `Automated test content generated at ${new Date().toISOString()}.`;
/**
* Fill all detectable form controls inside `scope` (defaults to the whole page).
*
* @param page Playwright Page object (used for evaluate calls)
* @param scope Optional Locator to restrict filling to a specific container
*/
export async function fillForm(page: Page, scope?: Locator): Promise<void> {
const root: Page | Locator = scope ?? page;
// ── text / email / url / tel / search / password / date / time ──────────
const textInputs = root.locator(
'input:not([type=hidden]):not([type=submit]):not([type=button])' +
':not([type=reset]):not([type=file]):not([type=checkbox]):not([type=radio])' +
':not([readonly]):not([disabled])',
);
const inputCount = await textInputs.count();
for (let i = 0; i < inputCount; i++) {
const input = textInputs.nth(i);
const type = (await input.getAttribute('type'))?.toLowerCase() ?? 'text';
const name = (await input.getAttribute('name')) ?? '';
// Skip honeypot / internal fields whose names suggest they must stay empty
if (/honeypot|_token|csrf/i.test(name)) continue;
try {
await input.scrollIntoViewIfNeeded();
switch (type) {
case 'email':
await input.fill(RANDOM_EMAIL);
break;
case 'number':
case 'range':
await input.fill(RANDOM_NUM);
break;
case 'url':
await input.fill(RANDOM_URL);
break;
case 'date':
await input.fill(RANDOM_DATE);
break;
case 'time':
await input.fill(RANDOM_TIME);
break;
case 'password':
await input.fill(`Pw@${Date.now()}`);
break;
default:
await input.fill(RANDOM_TEXT);
}
} catch {
// Field might have become detached or invisible — skip silently
}
}
// ── textarea ─────────────────────────────────────────────────────────────
const textareas = root.locator('textarea:not([readonly]):not([disabled])');
const taCount = await textareas.count();
for (let i = 0; i < taCount; i++) {
try {
await textareas.nth(i).scrollIntoViewIfNeeded();
await textareas.nth(i).fill(RANDOM_TEXTAREA);
} catch { /* skip */ }
}
// ── select ────────────────────────────────────────────────────────────────
const selects = root.locator('select:not([disabled])');
const selCount = await selects.count();
for (let i = 0; i < selCount; i++) {
try {
// Pick the first non-empty option
const firstOption = await selects.nth(i).locator('option:not([value=""])').first().getAttribute('value');
if (firstOption !== null) {
await selects.nth(i).selectOption(firstOption);
}
} catch { /* skip */ }
}
// ── checkboxes (toggle unchecked → checked) ───────────────────────────────
const checkboxes = root.locator('input[type=checkbox]:not([disabled])');
const cbCount = await checkboxes.count();
for (let i = 0; i < cbCount; i++) {
try {
const isChecked = await checkboxes.nth(i).isChecked();
if (!isChecked) {
await checkboxes.nth(i).check();
}
} catch { /* skip */ }
}
}
/**
* Detect whether the given scope contains a visible, submittable form.
* Returns true if any <form>, submit button, or text input is found.
*/
export async function hasForm(scope: Page | Locator): Promise<boolean> {
const formCount = await scope.locator('form').count();
const submitCount = await scope.locator('button[type=submit], input[type=submit]').count();
const inputCount = await scope.locator('input:not([type=hidden])').count();
return formCount > 0 || submitCount > 0 || inputCount > 0;
}