Refactor dashboard and upload flows

Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
This commit is contained in:
2026-03-21 11:02:22 +01:00
parent 29c3ff8572
commit 979e011257
55 changed files with 2576 additions and 1923 deletions

View File

@@ -1,95 +0,0 @@
<?php
use App\Models\ActivityEvent;
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);
});
it('admin approval records a story publish activity event', function () {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$story = createPendingReviewStory($creator);
$this->actingAs($admin)
->post(route('admin.stories.approve', ['story' => $story->id]))
->assertRedirect();
$this->assertDatabaseHas('activity_events', [
'actor_id' => $creator->id,
'type' => ActivityEvent::TYPE_UPLOAD,
'target_type' => ActivityEvent::TARGET_STORY,
'target_id' => $story->id,
]);
});

View File

@@ -0,0 +1,173 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type DashboardFixture = {
email: string
password: string
username: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedDashboardFixture(): DashboardFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-dashboard-mobile-${token}@example.test`
const username = `e2em${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Mobile Dashboard User',",
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as DashboardFixture
}
function resetBotProtectionState() {
const script = [
'use Illuminate\\Support\\Facades\\DB;',
'use Illuminate\\Support\\Facades\\Schema;',
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
' if (Schema::hasTable($table)) {',
' DB::table($table)->delete();',
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, fixture: DashboardFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Dashboard mobile layout login failed because the login page returned an internal server error.')
}
await emailField.fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Mobile Dashboard User/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Dashboard mobile layout login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
test.describe('Dashboard mobile layout', () => {
test.describe.configure({ mode: 'serial' })
test.use({ viewport: { width: 390, height: 844 } })
let fixture: DashboardFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedDashboardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('dashboard home fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('heading', { name: 'Your dashboard snapshot' })).toBeVisible()
})
test('followers page fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard/followers', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'People Following Me' })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('link', { name: /discover creators/i })).toBeVisible()
})
test('following page fits mobile width', async ({ page }) => {
await login(page, fixture)
await page.goto('/dashboard/following', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'People I Follow' })).toBeVisible()
await page.waitForLoadState('networkidle')
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('link', { name: /my followers/i })).toBeVisible()
})
})

View File

@@ -0,0 +1,173 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type UploadFixture = {
email: string
password: string
username: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedUploadFixture(): UploadFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-upload-${token}@example.test`
const username = `e2eu${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Upload User',",
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as UploadFixture
}
function resetBotProtectionState() {
const script = [
'use Illuminate\\Support\\Facades\\DB;',
'use Illuminate\\Support\\Facades\\Schema;',
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
' if (Schema::hasTable($table)) {',
' DB::table($table)->delete();',
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, fixture: UploadFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Upload Playwright login failed because the login page returned an internal server error.')
}
await emailField.fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Upload User/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Upload Playwright login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
async function dismissCookieConsent(page: Page) {
const essentialOnly = page.getByRole('button', { name: 'Essential only' })
if (await essentialOnly.isVisible().catch(() => false)) {
await essentialOnly.click({ force: true })
}
}
test.describe('Upload page layout', () => {
test.describe.configure({ mode: 'serial' })
let fixture: UploadFixture
test.beforeAll(() => {
ensureCompiledAssets()
fixture = seedUploadFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('upload page loads updated studio shell', async ({ page }) => {
await login(page, fixture)
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
await dismissCookieConsent(page)
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
await expect(page.getByText('Before you start')).toBeVisible()
await expect(page.getByRole('heading', { level: 2, name: 'Upload your artwork' })).toBeVisible()
await expect(page.getByLabel('Upload file input')).toBeAttached()
await expect(page.getByRole('button', { name: /start upload/i })).toBeDisabled()
})
test('upload page fits mobile width', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await login(page, fixture)
await page.goto('/upload', { waitUntil: 'domcontentloaded' })
await dismissCookieConsent(page)
await page.waitForLoadState('networkidle')
await expect(page.getByText('Skinbase Upload Studio')).toBeVisible()
await expectNoHorizontalOverflow(page)
await expect(page.getByRole('button', { name: /start upload/i })).toBeVisible()
})
})