Files
knowledge_base/docs/superpowers/plans/2026-05-09-media-manager.md
T
Yutaka Kurosaki ba25b544f5 Add implementation plan for admin media manager
Eleven TDD tasks from migration through editor integration: model,
service (store/rename/delete + auto-suffix), admin CRUD controller,
real Blade UI, auth-gated download, listener Phase-1 logical-path
rewrite for Image+Link nodes, and ImageUploadController rewiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:54:54 +09:00

58 KiB

Media Manager Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an admin media manager (upload / rename / download / delete) where files are stored under UUID physical names but referenced from Markdown by stable user-facing logical paths.

Architecture: A media_files table maps logical_path (UNIQUE) → physical_path (media/{uuid}.{ext}). A MediaService owns logical-path normalization, collision detection, and physical I/O. An admin controller drives the CRUD UI. The existing MediaEmbedListener gains a phase that rewrites schema-less Image/Link URLs from logical paths to physical public URLs before the existing video/audio/YouTube/Vimeo detection runs. The editor's drag-and-drop endpoint is rewired through the same service with server-side auto-suffix on collision.

Tech Stack: Laravel 13, Eloquent, league/commonmark, Blade views (no Livewire), PHPUnit, MySQL (production) / SQLite in-memory (tests), Storage::fake('public') for file I/O tests. All commands run inside Docker via docker compose exec php ….

Spec: docs/superpowers/specs/2026-05-09-media-manager-design.md


File Map

Create

  • src/database/migrations/2026_05_09_000001_create_media_files_table.php
  • src/app/Models/MediaFile.php
  • src/app/Services/MediaService.php
  • src/app/Http/Controllers/Admin/MediaController.php
  • src/resources/views/admin/media/index.blade.php
  • src/resources/views/admin/media/edit.blade.php
  • src/tests/Unit/Services/MediaServiceTest.php
  • src/tests/Feature/Admin/MediaControllerTest.php
  • src/tests/Feature/MediaDownloadTest.php

Modify

  • src/app/Markdown/MediaEmbedListener.php — add Phase-1 logical-path URL rewrite for Image and Link nodes
  • src/app/Http/Controllers/ImageUploadController.php — delegate to MediaService::store(..., autoSuffixOnConflict: true)
  • src/routes/web.php — admin resource routes for media; auth-only download route
  • src/resources/views/layouts/navigation.blade.php — admin dropdown entry "メディア"
  • src/lang/en/messages.php and src/lang/ja/messages.phpadmin.media.* and nav.media_management
  • src/tests/Unit/Markdown/MediaEmbedExtensionTest.php — extend with logical-path scenarios

Task 1: Migration and MediaFile model

Files:

  • Create: src/database/migrations/2026_05_09_000001_create_media_files_table.php

  • Create: src/app/Models/MediaFile.php

  • Test: src/tests/Unit/Services/MediaServiceTest.php (created here, expanded later)

  • Step 1: Write the failing test

Create src/tests/Unit/Services/MediaServiceTest.php:

<?php

namespace Tests\Unit\Services;

use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class MediaServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_media_file_publicUrl_returns_storage_url(): void
    {
        Storage::fake('public');

        $media = MediaFile::create([
            'logical_path'  => 'report.png',
            'physical_path' => 'media/abc.png',
            'original_name' => 'report.png',
            'mime_type'     => 'image/png',
            'size'          => 100,
            'uploaded_by'   => User::factory()->create()->id,
        ]);

        $this->assertStringEndsWith('/storage/media/abc.png', $media->publicUrl());
    }

    public function test_deleting_media_file_removes_physical_file(): void
    {
        Storage::fake('public');
        Storage::disk('public')->put('media/abc.png', 'fake-bytes');

        $media = MediaFile::create([
            'logical_path'  => 'report.png',
            'physical_path' => 'media/abc.png',
            'original_name' => 'report.png',
            'mime_type'     => 'image/png',
            'size'          => 10,
            'uploaded_by'   => User::factory()->create()->id,
        ]);

        $media->delete();

        Storage::disk('public')->assertMissing('media/abc.png');
        $this->assertDatabaseMissing('media_files', ['id' => $media->id]);
    }

    public function test_delete_keeps_db_row_when_physical_delete_fails(): void
    {
        Storage::fake('public');

        $media = MediaFile::create([
            'logical_path'  => 'never.png',
            'physical_path' => 'media/does-not-exist.png',
            'original_name' => 'never.png',
            'mime_type'     => 'image/png',
            'size'          => 0,
            'uploaded_by'   => User::factory()->create()->id,
        ]);

        // Force the disk delete to throw.
        Storage::shouldReceive('disk')
            ->with('public')
            ->andReturnSelf();
        Storage::shouldReceive('delete')
            ->with('media/does-not-exist.png')
            ->andThrow(new \RuntimeException('disk full'));

        try {
            $media->delete();
            $this->fail('Expected exception was not thrown.');
        } catch (\RuntimeException) {
            // expected
        }

        $this->assertDatabaseHas('media_files', ['id' => $media->id]);
    }
}
  • Step 2: Run test to verify it fails
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: FAIL — Class "App\Models\MediaFile" not found (or "Base table or view not found: media_files").

  • Step 3: Create the migration

src/database/migrations/2026_05_09_000001_create_media_files_table.php:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('media_files', function (Blueprint $table) {
            $table->id();
            $table->string('logical_path', 512)->unique();
            $table->string('physical_path', 255);
            $table->string('original_name', 255);
            $table->string('mime_type', 127);
            $table->unsignedBigInteger('size');
            $table->foreignId('uploaded_by')
                ->nullable()
                ->constrained('users')
                ->nullOnDelete();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('media_files');
    }
};
  • Step 4: Create the model

src/app/Models/MediaFile.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;

class MediaFile extends Model
{
    protected $fillable = [
        'logical_path',
        'physical_path',
        'original_name',
        'mime_type',
        'size',
        'uploaded_by',
    ];

    protected $casts = [
        'size' => 'integer',
    ];

    public function uploader(): BelongsTo
    {
        return $this->belongsTo(User::class, 'uploaded_by');
    }

    public function publicUrl(): string
    {
        return Storage::disk('public')->url($this->physical_path);
    }

    public function delete(): bool
    {
        Storage::disk('public')->delete($this->physical_path);

        return parent::delete();
    }
}
  • Step 5: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: PASS (3 tests).

  • Step 6: Commit
git add src/database/migrations/2026_05_09_000001_create_media_files_table.php \
        src/app/Models/MediaFile.php \
        src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Add media_files table and MediaFile model"

Task 2: MediaService::store for admin uploads (no auto-suffix)

Files:

  • Create: src/app/Services/MediaService.php

  • Modify: src/tests/Unit/Services/MediaServiceTest.php

  • Step 1: Add failing tests

Append these methods to MediaServiceTest:

public function test_store_with_explicit_logical_path(): void
{
    Storage::fake('public');
    $user = User::factory()->create();
    $this->actingAs($user);

    $service = app(\App\Services\MediaService::class);
    $file = \Illuminate\Http\UploadedFile::fake()->image('photo.png');

    $media = $service->store($file, '2026/photo.png');

    $this->assertSame('2026/photo.png', $media->logical_path);
    $this->assertStringStartsWith('media/', $media->physical_path);
    $this->assertStringEndsWith('.png', $media->physical_path);
    $this->assertSame('photo.png', $media->original_name);
    $this->assertSame($user->id, $media->uploaded_by);
    Storage::disk('public')->assertExists($media->physical_path);
}

public function test_store_defaults_logical_path_to_original_name(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $file = \Illuminate\Http\UploadedFile::fake()->image('hello.png');

    $media = $service->store($file, null);

    $this->assertSame('hello.png', $media->logical_path);
}

public function test_store_rejects_duplicate_logical_path(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $first = \Illuminate\Http\UploadedFile::fake()->image('a.png');
    $second = \Illuminate\Http\UploadedFile::fake()->image('a.png');

    $service->store($first, 'a.png');

    $this->expectException(\App\Services\MediaPathConflictException::class);
    $service->store($second, 'a.png');
}

public function test_store_normalizes_logical_path(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $file = \Illuminate\Http\UploadedFile::fake()->image('a.png');

    $media = $service->store($file, '  /docs//report.png  ');

    $this->assertSame('docs/report.png', $media->logical_path);
}

public function test_store_rejects_path_with_dotdot(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $file = \Illuminate\Http\UploadedFile::fake()->image('a.png');

    $this->expectException(\App\Services\InvalidMediaPathException::class);
    $service->store($file, '../etc/passwd');
}

public function test_store_rejects_empty_path_after_trim(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $file = \Illuminate\Http\UploadedFile::fake()->image('a.png');

    $this->expectException(\App\Services\InvalidMediaPathException::class);
    $service->store($file, '   ');
    // (note: empty after trim is invalid; null defaults to original name in a separate test)
}
  • Step 2: Run tests to verify they fail
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: FAIL — class App\Services\MediaService not found.

  • Step 3: Implement the service and exceptions

Create src/app/Services/MediaService.php:

<?php

namespace App\Services;

use App\Models\MediaFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class MediaService
{
    private const MAX_AUTO_SUFFIX_ATTEMPTS = 100;

    public function store(
        UploadedFile $file,
        ?string $logicalPath,
        bool $autoSuffixOnConflict = false,
    ): MediaFile {
        $extension = strtolower($file->getClientOriginalExtension());
        $originalName = $file->getClientOriginalName();

        $logicalPath = $this->resolveLogicalPath($logicalPath, $originalName, $autoSuffixOnConflict);

        $physicalPath = 'media/' . Str::uuid() . ($extension !== '' ? '.' . $extension : '');

        Storage::disk('public')->putFileAs(
            'media',
            $file,
            basename($physicalPath),
        );

        return MediaFile::create([
            'logical_path'  => $logicalPath,
            'physical_path' => $physicalPath,
            'original_name' => $originalName,
            'mime_type'     => $file->getMimeType() ?? 'application/octet-stream',
            'size'          => $file->getSize() ?? 0,
            'uploaded_by'   => auth()->id(),
        ]);
    }

    private function resolveLogicalPath(
        ?string $logicalPath,
        string $originalName,
        bool $autoSuffixOnConflict,
    ): string {
        $candidate = $logicalPath !== null
            ? $this->normalize($logicalPath)
            : $this->normalize($originalName);

        if (!MediaFile::where('logical_path', $candidate)->exists()) {
            return $candidate;
        }

        if (!$autoSuffixOnConflict) {
            throw new MediaPathConflictException("Logical path already exists: {$candidate}");
        }

        return $this->findFreeSuffixed($candidate);
    }

    private function findFreeSuffixed(string $candidate): string
    {
        $dot = strrpos($candidate, '.');
        $stem = $dot === false ? $candidate : substr($candidate, 0, $dot);
        $ext = $dot === false ? '' : substr($candidate, $dot);

        for ($i = 2; $i <= self::MAX_AUTO_SUFFIX_ATTEMPTS; $i++) {
            $try = "{$stem}-{$i}{$ext}";
            if (!MediaFile::where('logical_path', $try)->exists()) {
                return $try;
            }
        }
        throw new MediaPathConflictException("Could not find a free logical path for {$candidate}");
    }

    private function normalize(string $path): string
    {
        $trimmed = trim($path);
        if ($trimmed === '') {
            throw new InvalidMediaPathException('Logical path cannot be empty.');
        }
        $trimmed = trim($trimmed, '/');
        if ($trimmed === '') {
            throw new InvalidMediaPathException('Logical path cannot consist only of slashes.');
        }
        if (strlen($trimmed) > 512) {
            throw new InvalidMediaPathException('Logical path exceeds 512 characters.');
        }
        $segments = explode('/', $trimmed);
        $clean = [];
        foreach ($segments as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                throw new InvalidMediaPathException('Logical path contains an invalid segment.');
            }
            $clean[] = $segment;
        }
        return implode('/', $clean);
    }
}

Create src/app/Services/MediaPathConflictException.php:

<?php

namespace App\Services;

class MediaPathConflictException extends \RuntimeException {}

Create src/app/Services/InvalidMediaPathException.php:

<?php

namespace App\Services;

class InvalidMediaPathException extends \RuntimeException {}
  • Step 4: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: PASS (all store_* and the original 3 tests).

  • Step 5: Commit
git add src/app/Services/ src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Add MediaService::store with logical-path normalization"

Task 3: MediaService::rename

Files:

  • Modify: src/app/Services/MediaService.php

  • Modify: src/tests/Unit/Services/MediaServiceTest.php

  • Step 1: Add failing tests

Append:

public function test_rename_updates_logical_path_and_keeps_physical(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $media = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png');
    $physicalBefore = $media->physical_path;

    $service->rename($media, 'b/c.png');

    $media->refresh();
    $this->assertSame('b/c.png', $media->logical_path);
    $this->assertSame($physicalBefore, $media->physical_path);
    Storage::disk('public')->assertExists($physicalBefore);
}

public function test_rename_rejects_conflict(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $a = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png');
    $service->store(\Illuminate\Http\UploadedFile::fake()->image('b.png'), 'b.png');

    $this->expectException(\App\Services\MediaPathConflictException::class);
    $service->rename($a, 'b.png');
}

public function test_rename_to_same_path_is_noop(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $a = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png');

    $service->rename($a, 'a.png'); // should not throw

    $a->refresh();
    $this->assertSame('a.png', $a->logical_path);
}
  • Step 2: Run tests to verify they fail
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: FAIL — Method rename does not exist.

  • Step 3: Implement rename

Add to MediaService (above private function resolveLogicalPath):

public function rename(MediaFile $media, string $newLogicalPath): MediaFile
{
    $normalized = $this->normalize($newLogicalPath);

    if ($normalized === $media->logical_path) {
        return $media;
    }

    if (MediaFile::where('logical_path', $normalized)->where('id', '!=', $media->id)->exists()) {
        throw new MediaPathConflictException("Logical path already exists: {$normalized}");
    }

    $media->logical_path = $normalized;
    $media->save();

    return $media;
}
  • Step 4: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: PASS.

  • Step 5: Commit
git add src/app/Services/MediaService.php src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Add MediaService::rename"

Task 4: MediaService::delete wrapper

Files:

  • Modify: src/app/Services/MediaService.php
  • Modify: src/tests/Unit/Services/MediaServiceTest.php

The model already wires the physical-delete-then-DB-delete flow in Task 1. This task adds a service-level wrapper so the controller depends on the service for symmetry with store and rename, and surfaces a clear error when the physical delete fails.

  • Step 1: Add failing test

Append:

public function test_service_delete_removes_db_and_physical(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);
    $media = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png');
    $physical = $media->physical_path;

    $service->delete($media);

    $this->assertDatabaseMissing('media_files', ['id' => $media->id]);
    Storage::disk('public')->assertMissing($physical);
}
  • Step 2: Run test to verify it fails
docker compose exec php php artisan test --filter=MediaServiceTest::test_service_delete

Expected: FAIL — Method delete does not exist.

  • Step 3: Implement delete

Add to MediaService:

public function delete(MediaFile $media): void
{
    $media->delete();
}
  • Step 4: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: PASS (full file).

  • Step 5: Commit
git add src/app/Services/MediaService.php src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Add MediaService::delete wrapper"

Task 5: Auto-suffix variant for editor uploads

Files:

  • Modify: src/tests/Unit/Services/MediaServiceTest.php

store(..., autoSuffixOnConflict: true) is already implemented in Task 2. This task locks the contract behind a test.

  • Step 1: Add failing test

Append:

public function test_store_with_auto_suffix_resolves_collisions(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);

    $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png');
    $second = $service->store(
        \Illuminate\Http\UploadedFile::fake()->image('a.png'),
        null,
        autoSuffixOnConflict: true,
    );
    $third = $service->store(
        \Illuminate\Http\UploadedFile::fake()->image('a.png'),
        null,
        autoSuffixOnConflict: true,
    );

    $this->assertSame('a-2.png', $second->logical_path);
    $this->assertSame('a-3.png', $third->logical_path);
}

public function test_store_with_auto_suffix_handles_pathless_extension(): void
{
    Storage::fake('public');
    $this->actingAs(User::factory()->create());
    $service = app(\App\Services\MediaService::class);

    // simulate a file named "README" (no extension)
    $file = \Illuminate\Http\UploadedFile::fake()->create('README', 1);
    $service->store($file, 'README');
    $second = $service->store(
        \Illuminate\Http\UploadedFile::fake()->create('README', 1),
        'README',
        autoSuffixOnConflict: true,
    );

    $this->assertSame('README-2', $second->logical_path);
}
  • Step 2: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest

Expected: PASS (the implementation from Task 2 already supports this).

  • Step 3: Commit
git add src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Lock MediaService auto-suffix behavior with tests"

Task 6: Routes and Admin\MediaController (CRUD scaffolding)

Files:

  • Create: src/app/Http/Controllers/Admin/MediaController.php

  • Modify: src/routes/web.php

  • Create: src/tests/Feature/Admin/MediaControllerTest.php

  • Step 1: Write failing tests

Create src/tests/Feature/Admin/MediaControllerTest.php:

<?php

namespace Tests\Feature\Admin;

use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class MediaControllerTest extends TestCase
{
    use RefreshDatabase;

    private function admin(): User
    {
        return User::factory()->create(['is_admin' => true]);
    }

    private function nonAdmin(): User
    {
        return User::factory()->create(['is_admin' => false]);
    }

    public function test_index_requires_admin(): void
    {
        $this->actingAs($this->nonAdmin())
            ->get('/admin/media')
            ->assertForbidden();
    }

    public function test_index_lists_media(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());
        MediaFile::create([
            'logical_path' => 'a.png',
            'physical_path' => 'media/a.png',
            'original_name' => 'a.png',
            'mime_type' => 'image/png',
            'size' => 1,
        ]);

        $this->get('/admin/media')
            ->assertOk()
            ->assertSee('a.png');
    }

    public function test_index_search_filters_by_logical_path(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());
        MediaFile::create([
            'logical_path' => 'reports/q1.pdf',
            'physical_path' => 'media/x.pdf',
            'original_name' => 'q1.pdf',
            'mime_type' => 'application/pdf',
            'size' => 1,
        ]);
        MediaFile::create([
            'logical_path' => 'images/cat.png',
            'physical_path' => 'media/y.png',
            'original_name' => 'cat.png',
            'mime_type' => 'image/png',
            'size' => 1,
        ]);

        $this->get('/admin/media?q=report')
            ->assertOk()
            ->assertSee('q1.pdf')
            ->assertDontSee('cat.png');
    }

    public function test_store_uploads_file(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());

        $response = $this->post('/admin/media', [
            'file' => UploadedFile::fake()->image('hello.png'),
            'logical_path' => 'docs/hello.png',
        ]);

        $response->assertRedirect(route('admin.media.index'));
        $this->assertDatabaseHas('media_files', ['logical_path' => 'docs/hello.png']);
    }

    public function test_store_rejects_duplicate_logical_path(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());
        MediaFile::create([
            'logical_path' => 'a.png',
            'physical_path' => 'media/old.png',
            'original_name' => 'a.png',
            'mime_type' => 'image/png',
            'size' => 1,
        ]);

        $response = $this->post('/admin/media', [
            'file' => UploadedFile::fake()->image('a.png'),
            'logical_path' => 'a.png',
        ]);

        $response->assertSessionHasErrors('logical_path');
    }

    public function test_store_rejects_disallowed_mime(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());

        $response = $this->post('/admin/media', [
            'file' => UploadedFile::fake()->create('evil.exe', 10, 'application/x-msdownload'),
        ]);

        $response->assertSessionHasErrors('file');
    }

    public function test_update_renames_logical_path(): void
    {
        Storage::fake('public');
        $this->actingAs($this->admin());
        $media = MediaFile::create([
            'logical_path' => 'old.png',
            'physical_path' => 'media/x.png',
            'original_name' => 'old.png',
            'mime_type' => 'image/png',
            'size' => 1,
        ]);

        $this->put("/admin/media/{$media->id}", [
            'logical_path' => 'new/name.png',
        ])->assertRedirect(route('admin.media.index'));

        $this->assertDatabaseHas('media_files', [
            'id' => $media->id,
            'logical_path' => 'new/name.png',
        ]);
    }

    public function test_destroy_removes_media(): void
    {
        Storage::fake('public');
        Storage::disk('public')->put('media/x.png', 'bytes');
        $this->actingAs($this->admin());
        $media = MediaFile::create([
            'logical_path' => 'x.png',
            'physical_path' => 'media/x.png',
            'original_name' => 'x.png',
            'mime_type' => 'image/png',
            'size' => 5,
        ]);

        $this->delete("/admin/media/{$media->id}")
            ->assertRedirect(route('admin.media.index'));

        $this->assertDatabaseMissing('media_files', ['id' => $media->id]);
        Storage::disk('public')->assertMissing('media/x.png');
    }
}
  • Step 2: Run tests to verify they fail
docker compose exec php php artisan test --filter=MediaControllerTest

Expected: FAIL — Route [admin.media.index] not defined / 404.

  • Step 3: Implement the controller

Create src/app/Http/Controllers/Admin/MediaController.php:

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\MediaFile;
use App\Services\InvalidMediaPathException;
use App\Services\MediaPathConflictException;
use App\Services\MediaService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class MediaController extends Controller
{
    public function __construct(private readonly MediaService $service) {}

    public function index(Request $request)
    {
        $q = trim((string) $request->query('q', ''));
        $query = MediaFile::query()->orderByDesc('updated_at');

        if ($q !== '') {
            $query->where('logical_path', 'like', '%' . $q . '%');
        }

        $media = $query->paginate(20)->withQueryString();

        return view('admin.media.index', [
            'media' => $media,
            'q'     => $q,
        ]);
    }

    public function store(Request $request)
    {
        $request->validate([
            'file' => [
                'required',
                'file',
                'mimes:jpg,jpeg,png,gif,webp,svg,mp4,webm,mp3,m4a,ogg,wav,pdf,zip',
                'max:102400', // 100 MB
            ],
            'logical_path' => ['nullable', 'string', 'max:512'],
        ]);

        try {
            $this->service->store(
                $request->file('file'),
                $request->input('logical_path') ?: null,
            );
        } catch (MediaPathConflictException $e) {
            throw ValidationException::withMessages([
                'logical_path' => __('messages.admin.media.conflict'),
            ]);
        } catch (InvalidMediaPathException $e) {
            throw ValidationException::withMessages([
                'logical_path' => __('messages.admin.media.invalid_path'),
            ]);
        }

        return redirect()->route('admin.media.index')
            ->with('success', __('messages.admin.media.create_success'));
    }

    public function edit(MediaFile $media)
    {
        return view('admin.media.edit', ['media' => $media]);
    }

    public function update(Request $request, MediaFile $media)
    {
        $request->validate([
            'logical_path' => ['required', 'string', 'max:512'],
        ]);

        try {
            $this->service->rename($media, (string) $request->input('logical_path'));
        } catch (MediaPathConflictException) {
            throw ValidationException::withMessages([
                'logical_path' => __('messages.admin.media.conflict'),
            ]);
        } catch (InvalidMediaPathException) {
            throw ValidationException::withMessages([
                'logical_path' => __('messages.admin.media.invalid_path'),
            ]);
        }

        return redirect()->route('admin.media.index')
            ->with('success', __('messages.admin.media.update_success'));
    }

    public function destroy(MediaFile $media)
    {
        try {
            $this->service->delete($media);
        } catch (\Throwable $e) {
            report($e);
            return redirect()->route('admin.media.index')
                ->with('error', __('messages.admin.media.delete_failed'));
        }

        return redirect()->route('admin.media.index')
            ->with('success', __('messages.admin.media.delete_success'));
    }

    public function download(MediaFile $media)
    {
        return \Illuminate\Support\Facades\Storage::disk('public')
            ->download($media->physical_path, $media->original_name);
    }
}
  • Step 4: Wire the routes

Edit src/routes/web.php. Inside the existing Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(...), add:

Route::resource('media', \App\Http\Controllers\Admin\MediaController::class)
    ->except(['show', 'create']);

Inside the existing Route::middleware('auth')->group(...), add:

Route::get('/media/{media}/download', [\App\Http\Controllers\Admin\MediaController::class, 'download'])
    ->name('media.download');
  • Step 5: Add minimal placeholder views so the controller boots

Create src/resources/views/admin/media/index.blade.php:

<x-app-layout>
    <x-slot name="header">{{ __('messages.admin.media.title') }}</x-slot>
    <div class="p-6">
        @foreach($media as $m)
            <div data-testid="media-row">{{ $m->logical_path }}</div>
        @endforeach
    </div>
</x-app-layout>

Create src/resources/views/admin/media/edit.blade.php:

<x-app-layout>
    <x-slot name="header">{{ __('messages.admin.media.edit_title') }}</x-slot>
    <div class="p-6">
        <form method="POST" action="{{ route('admin.media.update', $media) }}">
            @csrf @method('PUT')
            <input type="text" name="logical_path" value="{{ old('logical_path', $media->logical_path) }}">
            <button type="submit">{{ __('messages.admin.media.save') }}</button>
        </form>
    </div>
</x-app-layout>

(These are intentionally minimal — Task 7 fleshes out the real UI.)

  • Step 6: Add the new translation keys (en + ja)

Edit src/lang/en/messages.php. Inside 'admin' => [ … ], add:

'media' => [
    'title' => 'Media',
    'edit_title' => 'Edit Media',
    'save' => 'Save',
    'create_success' => 'Media uploaded successfully.',
    'update_success' => 'Media updated successfully.',
    'delete_success' => 'Media deleted successfully.',
    'delete_failed' => 'Failed to delete the file. The record was kept; please retry.',
    'conflict' => 'A file with this path already exists.',
    'invalid_path' => 'The path is invalid.',
],

Edit src/lang/ja/messages.php. Inside 'admin' => [ … ], add:

'media' => [
    'title' => 'メディア',
    'edit_title' => 'メディア編集',
    'save' => '保存',
    'create_success' => 'メディアをアップロードしました。',
    'update_success' => 'メディア情報を更新しました。',
    'delete_success' => 'メディアを削除しました。',
    'delete_failed' => 'ファイルの削除に失敗しました。レコードは保持されています。再試行してください。',
    'conflict' => '同じパスのファイルが既に存在します。',
    'invalid_path' => 'パスが不正です。',
],
  • Step 7: Run tests
docker compose exec php php artisan test --filter=MediaControllerTest

Expected: PASS.

  • Step 8: Commit
git add src/app/Http/Controllers/Admin/MediaController.php \
        src/routes/web.php \
        src/resources/views/admin/media/ \
        src/lang/en/messages.php src/lang/ja/messages.php \
        src/tests/Feature/Admin/MediaControllerTest.php
git commit -m "Add Admin MediaController CRUD"

Task 7: Real admin views (index + edit) and navigation entry

Files:

  • Modify: src/resources/views/admin/media/index.blade.php (full UI)
  • Modify: src/resources/views/admin/media/edit.blade.php (full UI)
  • Modify: src/resources/views/layouts/navigation.blade.php
  • Modify: src/lang/en/messages.php, src/lang/ja/messages.php (extra labels)

This is a UI task with no new logic. Tests from Task 6 already exercise the routes; we keep them green.

  • Step 1: Add UI labels to translations

src/lang/en/messages.php, inside admin.media:

'logical_path' => 'Path',
'logical_path_hint' => 'Optional. Leave blank to use the uploaded filename. Use slashes (/) to organize into folders.',
'original_name' => 'Original',
'mime_type' => 'Type',
'size' => 'Size',
'updated_at' => 'Updated',
'upload' => 'Upload',
'rename' => 'Rename',
'download' => 'Download',
'delete_confirm' => 'Delete this media file? Existing references will break.',
'search_placeholder' => 'Search by path…',
'no_media' => 'No media files yet.',
'new' => 'Upload',

src/lang/ja/messages.php, inside admin.media:

'logical_path' => 'パス',
'logical_path_hint' => '任意。空欄ならアップロードしたファイル名を使用。スラッシュ(/)でフォルダ階層を表現できます。',
'original_name' => 'オリジナル名',
'mime_type' => '種別',
'size' => 'サイズ',
'updated_at' => '更新日',
'upload' => 'アップロード',
'rename' => 'リネーム',
'download' => 'ダウンロード',
'delete_confirm' => 'このメディアを削除しますか? 既存の参照は壊れます。',
'search_placeholder' => 'パスで検索…',
'no_media' => 'メディアファイルはまだありません。',
'new' => 'アップロード',

Also add to nav (both files):

en:

'media_management' => 'Media',

ja:

'media_management' => 'メディア管理',
  • Step 2: Replace index.blade.php with the real UI

src/resources/views/admin/media/index.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('messages.admin.media.title') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
            @if(session('success'))
                <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">{{ session('success') }}</div>
            @endif
            @if(session('error'))
                <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">{{ session('error') }}</div>
            @endif

            <div class="bg-white shadow-sm sm:rounded-lg p-6">
                <h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('messages.admin.media.new') }}</h3>
                <form method="POST" action="{{ route('admin.media.store') }}" enctype="multipart/form-data" class="space-y-3">
                    @csrf
                    <div>
                        <input type="file" name="file" required class="block">
                        @error('file')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
                    </div>
                    <div>
                        <label class="block text-sm text-gray-600">{{ __('messages.admin.media.logical_path') }}</label>
                        <input type="text" name="logical_path" value="{{ old('logical_path') }}"
                               placeholder="report.png / 2026/spec.pdf"
                               class="border-gray-300 rounded-md shadow-sm w-full">
                        <p class="text-xs text-gray-500 mt-1">{{ __('messages.admin.media.logical_path_hint') }}</p>
                        @error('logical_path')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
                    </div>
                    <button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-semibold uppercase">
                        {{ __('messages.admin.media.upload') }}
                    </button>
                </form>
            </div>

            <div class="bg-white shadow-sm sm:rounded-lg p-6">
                <form method="GET" action="{{ route('admin.media.index') }}" class="mb-4">
                    <input type="text" name="q" value="{{ $q }}"
                           placeholder="{{ __('messages.admin.media.search_placeholder') }}"
                           class="border-gray-300 rounded-md shadow-sm w-64">
                </form>

                <table class="min-w-full divide-y divide-gray-200">
                    <thead class="bg-gray-50">
                        <tr>
                            <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.logical_path') }}</th>
                            <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.mime_type') }}</th>
                            <th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.size') }}</th>
                            <th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.updated_at') }}</th>
                            <th class="px-4 py-2"></th>
                        </tr>
                    </thead>
                    <tbody class="bg-white divide-y divide-gray-200">
                        @forelse($media as $m)
                            <tr>
                                <td class="px-4 py-2 text-sm">
                                    @if(str_starts_with($m->mime_type, 'image/'))
                                        <img src="{{ $m->publicUrl() }}" alt="" class="inline-block h-8 w-8 object-cover rounded mr-2">
                                    @endif
                                    <span class="font-mono">{{ $m->logical_path }}</span>
                                </td>
                                <td class="px-4 py-2 text-sm text-gray-500">{{ $m->mime_type }}</td>
                                <td class="px-4 py-2 text-sm text-gray-500 text-right">{{ number_format($m->size) }}</td>
                                <td class="px-4 py-2 text-sm text-gray-500">{{ $m->updated_at->format('Y/m/d H:i') }}</td>
                                <td class="px-4 py-2 text-sm text-right space-x-3 whitespace-nowrap">
                                    <a href="{{ route('admin.media.edit', $m) }}" class="text-indigo-600">{{ __('messages.admin.media.rename') }}</a>
                                    <a href="{{ route('media.download', $m) }}" class="text-gray-700">{{ __('messages.admin.media.download') }}</a>
                                    <form method="POST" action="{{ route('admin.media.destroy', $m) }}" class="inline"
                                          onsubmit="return confirm('{{ __('messages.admin.media.delete_confirm') }}')">
                                        @csrf @method('DELETE')
                                        <button type="submit" class="text-red-600">{{ __('messages.admin.delete') }}</button>
                                    </form>
                                </td>
                            </tr>
                        @empty
                            <tr><td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">{{ __('messages.admin.media.no_media') }}</td></tr>
                        @endforelse
                    </tbody>
                </table>

                <div class="mt-4">{{ $media->links() }}</div>
            </div>
        </div>
    </div>
</x-app-layout>
  • Step 3: Replace edit.blade.php with the real UI

src/resources/views/admin/media/edit.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('messages.admin.media.edit_title') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white shadow-sm sm:rounded-lg p-6">
                <form method="POST" action="{{ route('admin.media.update', $media) }}" class="space-y-4">
                    @csrf @method('PUT')

                    <div>
                        <label class="block text-sm text-gray-600">{{ __('messages.admin.media.original_name') }}</label>
                        <p class="text-sm text-gray-800 font-mono">{{ $media->original_name }}</p>
                    </div>

                    <div>
                        <label class="block text-sm text-gray-600">{{ __('messages.admin.media.logical_path') }}</label>
                        <input type="text" name="logical_path" value="{{ old('logical_path', $media->logical_path) }}"
                               class="border-gray-300 rounded-md shadow-sm w-full">
                        @error('logical_path')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
                    </div>

                    <div class="flex space-x-2">
                        <button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-semibold uppercase">
                            {{ __('messages.admin.media.save') }}
                        </button>
                        <a href="{{ route('admin.media.index') }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm">
                            {{ __('messages.documents.cancel') }}
                        </a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</x-app-layout>
  • Step 4: Add nav entry

In src/resources/views/layouts/navigation.blade.php, inside the admin block (right after the User Management dropdown link), insert:

<x-dropdown-link :href="route('admin.media.index')">
    {{ __('messages.nav.media_management') }}
</x-dropdown-link>

Also add to the responsive nav section if the file mirrors links there (search the file for admin.users.index and add a sibling entry next to each occurrence).

  • Step 5: Run the existing controller tests
docker compose exec php php artisan test --filter=MediaControllerTest

Expected: PASS.

  • Step 6: Smoke-test in browser

Bring up the stack and click through:

docker compose up -d

Visit http://localhost:9700/admin/media, log in as admin@example.com / password, upload a file, rename it, click Download, then delete it. Confirm flashes appear. Watch the browser console for errors.

  • Step 7: Commit
git add src/resources/views/admin/media/ \
        src/resources/views/layouts/navigation.blade.php \
        src/lang/en/messages.php src/lang/ja/messages.php
git commit -m "Add admin media manager UI and nav entry"

Task 8: Authenticated download route

Files:

  • Create: src/tests/Feature/MediaDownloadTest.php

The download route and controller method exist from Task 6. This task locks the auth behavior.

  • Step 1: Write failing tests

Create src/tests/Feature/MediaDownloadTest.php:

<?php

namespace Tests\Feature;

use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class MediaDownloadTest extends TestCase
{
    use RefreshDatabase;

    private function media(): MediaFile
    {
        Storage::fake('public');
        Storage::disk('public')->put('media/abc.png', 'bytes');
        return MediaFile::create([
            'logical_path' => 'a.png',
            'physical_path' => 'media/abc.png',
            'original_name' => 'original.png',
            'mime_type' => 'image/png',
            'size' => 5,
        ]);
    }

    public function test_unauthenticated_is_redirected(): void
    {
        $media = $this->media();

        $this->get("/media/{$media->id}/download")
            ->assertRedirect('/login');
    }

    public function test_authenticated_non_admin_can_download(): void
    {
        $media = $this->media();
        $user = User::factory()->create(['is_admin' => false]);

        $response = $this->actingAs($user)->get("/media/{$media->id}/download");

        $response->assertOk();
        $this->assertSame(
            'attachment; filename=original.png',
            $response->headers->get('content-disposition'),
        );
    }
}
  • Step 2: Run tests
docker compose exec php php artisan test --filter=MediaDownloadTest

Expected: PASS (download route is wired in Task 6).

  • Step 3: Commit
git add src/tests/Feature/MediaDownloadTest.php
git commit -m "Lock media download auth contract with tests"

Task 9: MediaEmbedListener Phase-1 logical-path rewrite

Files:

  • Modify: src/app/Markdown/MediaEmbedListener.php

  • Modify: src/tests/Unit/Markdown/MediaEmbedExtensionTest.php

  • Step 1: Add failing tests

Append to src/tests/Unit/Markdown/MediaEmbedExtensionTest.php:

public function test_image_with_logical_path_rewrites_to_physical_url(): void
{
    \Illuminate\Support\Facades\Storage::fake('public');
    \App\Models\MediaFile::create([
        'logical_path' => 'report.png',
        'physical_path' => 'media/abc.png',
        'original_name' => 'report.png',
        'mime_type' => 'image/png',
        'size' => 1,
    ]);

    $html = \App\Models\Document::renderMarkdown('![alt](report.png)');

    $this->assertStringContainsString('<img', $html);
    $this->assertStringContainsString('/storage/media/abc.png', $html);
    $this->assertStringContainsString('alt="alt"', $html);
}

public function test_logical_path_video_extension_rewrites_then_renders_video(): void
{
    \Illuminate\Support\Facades\Storage::fake('public');
    \App\Models\MediaFile::create([
        'logical_path' => 'demo.mp4',
        'physical_path' => 'media/xyz.mp4',
        'original_name' => 'demo.mp4',
        'mime_type' => 'video/mp4',
        'size' => 1,
    ]);

    $html = \App\Models\Document::renderMarkdown('![](demo.mp4)');

    $this->assertStringContainsString('<video', $html);
    $this->assertStringContainsString('/storage/media/xyz.mp4', $html);
}

public function test_link_with_logical_path_rewrites_href(): void
{
    \Illuminate\Support\Facades\Storage::fake('public');
    \App\Models\MediaFile::create([
        'logical_path' => 'manual.pdf',
        'physical_path' => 'media/abc.pdf',
        'original_name' => 'manual.pdf',
        'mime_type' => 'application/pdf',
        'size' => 1,
    ]);

    $html = \App\Models\Document::renderMarkdown('[manual]( manual.pdf )');
    // CommonMark trims internal whitespace; just assert the rewrite happened.
    $this->assertStringContainsString('href="', $html);
    $this->assertStringContainsString('/storage/media/abc.pdf', $html);
    $this->assertStringContainsString('>manual</a>', $html);
}

public function test_absolute_url_is_left_alone(): void
{
    \Illuminate\Support\Facades\Storage::fake('public');
    \App\Models\MediaFile::create([
        'logical_path' => 'foo.png',
        'physical_path' => 'media/abc.png',
        'original_name' => 'foo.png',
        'mime_type' => 'image/png',
        'size' => 1,
    ]);

    $html = \App\Models\Document::renderMarkdown('![](https://example.com/foo.png)');

    $this->assertStringContainsString('src="https://example.com/foo.png"', $html);
    $this->assertStringNotContainsString('/storage/media/', $html);
}

public function test_leading_slash_url_is_left_alone(): void
{
    \Illuminate\Support\Facades\Storage::fake('public');
    \App\Models\MediaFile::create([
        'logical_path' => 'foo.png',
        'physical_path' => 'media/abc.png',
        'original_name' => 'foo.png',
        'mime_type' => 'image/png',
        'size' => 1,
    ]);

    $html = \App\Models\Document::renderMarkdown('![](/foo.png)');

    $this->assertStringContainsString('src="/foo.png"', $html);
    $this->assertStringNotContainsString('/storage/media/', $html);
}

public function test_unknown_logical_path_is_left_alone(): void
{
    $html = \App\Models\Document::renderMarkdown('![](nope.png)');
    $this->assertStringContainsString('src="nope.png"', $html);
}

These need RefreshDatabase plus a base test setup. Check MediaEmbedExtensionTest's parent — it currently extends Tests\TestCase but does not use RefreshDatabase. Add the trait and use Illuminate\Foundation\Testing\RefreshDatabase; import at the top of the file:

use Illuminate\Foundation\Testing\RefreshDatabase;

class MediaEmbedExtensionTest extends TestCase
{
    use RefreshDatabase;
    // ...existing tests stay as-is
}
  • Step 2: Run tests to verify failures
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest

Expected: FAIL — the new logical-path tests render with src="report.png" rather than the physical URL.

  • Step 3: Implement the listener change

Replace the contents of src/app/Markdown/MediaEmbedListener.php with:

<?php

namespace App\Markdown;

use App\Models\MediaFile;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;

class MediaEmbedListener
{
    public function __construct(private readonly MediaUrlResolver $resolver)
    {
    }

    public function handle(DocumentParsedEvent $event): void
    {
        $imageNodes = [];
        $linkNodes = [];

        foreach ($event->getDocument()->iterator() as $node) {
            if ($node instanceof Image) {
                $imageNodes[] = $node;
            } elseif ($node instanceof Link) {
                $linkNodes[] = $node;
            }
        }

        // Phase 1: rewrite logical paths -> physical URLs (Image and Link).
        foreach ($imageNodes as $image) {
            $rewritten = $this->rewriteLogicalPath($image->getUrl());
            if ($rewritten !== null) {
                $image->setUrl($rewritten);
            }
        }
        foreach ($linkNodes as $link) {
            $rewritten = $this->rewriteLogicalPath($link->getUrl());
            if ($rewritten !== null) {
                $link->setUrl($rewritten);
            }
        }

        // Phase 2: video/audio/YouTube/Vimeo embed detection on Image nodes.
        foreach ($imageNodes as $image) {
            $html = $this->resolver->resolve($image->getUrl());
            if ($html !== null) {
                $image->replaceWith(new MediaEmbedNode($html));
            }
        }
    }

    private function rewriteLogicalPath(string $url): ?string
    {
        if ($url === '') {
            return null;
        }
        // Skip absolute URLs and root-relative paths.
        if (preg_match('~^[a-z][a-z0-9+.\-]*://~i', $url) === 1) {
            return null;
        }
        if (str_starts_with($url, '/')) {
            return null;
        }

        $media = MediaFile::where('logical_path', $url)->first();
        if ($media === null) {
            return null;
        }

        return $media->publicUrl();
    }
}
  • Step 4: Run tests
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest

Expected: PASS (existing tests unchanged + new ones green).

  • Step 5: Commit
git add src/app/Markdown/MediaEmbedListener.php src/tests/Unit/Markdown/MediaEmbedExtensionTest.php
git commit -m "Rewrite logical paths to physical URLs in Markdown renderer"

Task 10: Wire ImageUploadController to MediaService

Files:

  • Modify: src/app/Http/Controllers/ImageUploadController.php

  • Modify: src/tests/Feature/ (add ImageUploadControllerTest.php)

  • Step 1: Write failing tests

Create src/tests/Feature/ImageUploadControllerTest.php:

<?php

namespace Tests\Feature;

use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ImageUploadControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_upload_returns_logical_path(): void
    {
        Storage::fake('public');
        $this->actingAs(User::factory()->create());

        $response = $this->postJson('/images/upload', [
            'image' => UploadedFile::fake()->image('photo.png'),
        ]);

        $response->assertOk()
            ->assertJsonPath('data.filePath', 'photo.png')
            ->assertJsonPath('data.altText', 'photo');

        $this->assertDatabaseHas('media_files', ['logical_path' => 'photo.png']);
    }

    public function test_upload_auto_suffixes_on_collision(): void
    {
        Storage::fake('public');
        $this->actingAs(User::factory()->create());
        MediaFile::create([
            'logical_path' => 'photo.png',
            'physical_path' => 'media/x.png',
            'original_name' => 'photo.png',
            'mime_type' => 'image/png',
            'size' => 1,
        ]);

        $response = $this->postJson('/images/upload', [
            'image' => UploadedFile::fake()->image('photo.png'),
        ]);

        $response->assertOk()->assertJsonPath('data.filePath', 'photo-2.png');
    }
}
  • Step 2: Run tests
docker compose exec php php artisan test --filter=ImageUploadControllerTest

Expected: FAIL — current controller stores under images/YYYY/MM/{uuid}.ext and returns an absolute URL.

  • Step 3: Rewrite the controller

Replace src/app/Http/Controllers/ImageUploadController.php with:

<?php

namespace App\Http\Controllers;

use App\Services\MediaService;
use Illuminate\Http\Request;

class ImageUploadController extends Controller
{
    public function __construct(private readonly MediaService $service) {}

    public function upload(Request $request)
    {
        $request->validate([
            'image' => [
                'required',
                'file',
                'mimes:jpeg,jpg,png,gif,webp,svg',
                'max:102400',
            ],
        ]);

        $file = $request->file('image');
        $media = $this->service->store($file, null, autoSuffixOnConflict: true);

        return response()->json([
            'data' => [
                'filePath' => $media->logical_path,
                'altText'  => pathinfo($media->original_name, PATHINFO_FILENAME),
            ],
        ]);
    }
}
  • Step 4: Run tests
docker compose exec php php artisan test --filter=ImageUploadControllerTest

Expected: PASS.

  • Step 5: Smoke-test the editor

Open the document editor at http://localhost:9700/documents/create, drag an image into the editor, save, then view the rendered document. The Markdown should contain ![photo](photo.png) (or photo-2.png) and the rendered HTML should show the image via /storage/media/{uuid}.png.

  • Step 6: Commit
git add src/app/Http/Controllers/ImageUploadController.php src/tests/Feature/ImageUploadControllerTest.php
git commit -m "Route editor image uploads through MediaService"

Task 11: Final pass — run full suite, browser smoke test

Files: none

  • Step 1: Run the full test suite
docker compose exec php php artisan test

Expected: PASS, no skips that didn't already exist.

  • Step 2: Browser smoke test (full flow)
docker compose up -d
cd src && npm run build

In a browser:

  1. Log in as admin (admin@example.com / password).
  2. Confirm the メディア管理 entry exists in the user dropdown.
  3. Open /admin/media. Upload report.png with explicit logical path 2026/report.png.
  4. Open the document editor at /documents/create. Type ![](2026/report.png) and save. View the document; the image should render via /storage/media/{uuid}.png.
  5. Drag a second image into the editor. The inserted Markdown should be ![<basename>](<basename>.png) (logical path). Save and view; image should render.
  6. In /admin/media, rename 2026/report.png to 2026/report-renamed.png. Refresh the document; the first image should now appear broken (expected — no auto-rewrite).
  7. Click Download on a row. Confirm the file downloads with the original filename.
  8. Delete a media row. Confirm flash success. Refresh document; remaining references should still resolve.
  9. Log out, log back in as a non-admin user. Confirm /admin/media returns 403 but /media/{id}/download still serves the file.
  • Step 3: Commit (if any final tweaks were needed)

If the smoke test surfaced anything fixable, fix and commit. Otherwise, no commit needed for this task.


Summary

After this plan: a media_files table maps logical paths to UUID-named physical files; an admin UI under /admin/media provides upload/rename/download/delete; the document renderer transparently resolves ![](logical/path.ext) to the physical URL (and embeds video/audio appropriately); the editor's drag-and-drop endpoint inserts the logical path into Markdown so renames continue to work as long as the logical path is preserved.