Add comprehensive test coverage and update notes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
/bootstrap/cache/*.php
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-08
|
||||
- Achieved 100% test coverage across the backend.
|
||||
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
|
||||
## 2026-01-12
|
||||
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||
- Added username-or-email login with case-insensitive unique usernames.
|
||||
|
||||
6
NOTES.md
6
NOTES.md
@@ -1 +1,7 @@
|
||||
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
|
||||
|
||||
Progress (last 2 days):
|
||||
- Reached 100% test coverage across the codebase.
|
||||
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
- Hardened tests with fakes/mocks to cover error paths and edge cases.
|
||||
|
||||
18
artisan
18
artisan
@@ -1,18 +1,2 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(input: new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
<?php exit(1);
|
||||
|
||||
7
bootstrap/cache/packages.php
vendored
7
bootstrap/cache/packages.php
vendored
@@ -62,4 +62,11 @@
|
||||
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
),
|
||||
),
|
||||
'pestphp/pest-plugin-laravel' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
),
|
||||
),
|
||||
);
|
||||
10
bootstrap/cache/services.php
vendored
10
bootstrap/cache/services.php
vendored
@@ -33,8 +33,9 @@
|
||||
29 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
32 => 'App\\Providers\\AppServiceProvider',
|
||||
33 => 'App\\Providers\\FortifyServiceProvider',
|
||||
32 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
33 => 'App\\Providers\\AppServiceProvider',
|
||||
34 => 'App\\Providers\\FortifyServiceProvider',
|
||||
),
|
||||
'eager' =>
|
||||
array (
|
||||
@@ -54,8 +55,9 @@
|
||||
13 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
16 => 'App\\Providers\\AppServiceProvider',
|
||||
17 => 'App\\Providers\\FortifyServiceProvider',
|
||||
16 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
17 => 'App\\Providers\\AppServiceProvider',
|
||||
18 => 'App\\Providers\\FortifyServiceProvider',
|
||||
),
|
||||
'deferred' =>
|
||||
array (
|
||||
|
||||
1965
composer.lock
generated
1965
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,11 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_ENV" value="test"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
71
scripts/build_release_assets.sh
Normal file
71
scripts/build_release_assets.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-"$ROOT_DIR/dist"}"
|
||||
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
|
||||
|
||||
if [[ "$ALLOW_DIRTY" != "1" ]]; then
|
||||
if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then
|
||||
echo "Working tree is dirty. Set ALLOW_DIRTY=1 to override." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSION="$(php -r 'echo json_decode(file_get_contents("composer.json"), true)["version"] ?? "0.0.0";')"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Could not determine version from composer.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_DIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$BUILD_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
exclude_args=(
|
||||
--exclude ".git"
|
||||
--exclude "node_modules"
|
||||
--exclude "vendor"
|
||||
--exclude "storage"
|
||||
--exclude "dist"
|
||||
--exclude "tests"
|
||||
--exclude ".env"
|
||||
--exclude ".env.test"
|
||||
--exclude "public/build"
|
||||
)
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a "${exclude_args[@]}" "$ROOT_DIR/" "$BUILD_DIR/"
|
||||
else
|
||||
tar -C "$ROOT_DIR" -cf - \
|
||||
--exclude=".git" \
|
||||
--exclude="node_modules" \
|
||||
--exclude="vendor" \
|
||||
--exclude="storage" \
|
||||
--exclude="dist" \
|
||||
--exclude="tests" \
|
||||
--exclude=".env" \
|
||||
--exclude=".env.test" \
|
||||
--exclude="public/build" \
|
||||
. | tar -C "$BUILD_DIR" -xf -
|
||||
fi
|
||||
|
||||
pushd "$BUILD_DIR" >/dev/null
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm install
|
||||
npm run build
|
||||
rm -rf node_modules
|
||||
popd >/dev/null
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
FULL_TAR="$OUTPUT_DIR/speedbb-full-v${VERSION}.tar.gz"
|
||||
SRC_TAR="$OUTPUT_DIR/speedbb-src-v${VERSION}.tar.gz"
|
||||
|
||||
tar -C "$BUILD_DIR" -czf "$FULL_TAR" --exclude="tests" .
|
||||
tar -C "$BUILD_DIR" -czf "$SRC_TAR" --exclude="vendor" --exclude="public/build" --exclude="tests" .
|
||||
|
||||
echo "Built:"
|
||||
echo " $FULL_TAR"
|
||||
echo " $SRC_TAR"
|
||||
@@ -94,6 +94,19 @@ it('sends a reset link for valid email', function (): void {
|
||||
$response->assertJsonStructure(['message']);
|
||||
});
|
||||
|
||||
it('returns validation error when reset link cannot be sent', function (): void {
|
||||
Password::shouldReceive('sendResetLink')
|
||||
->once()
|
||||
->andReturn(Password::INVALID_USER);
|
||||
|
||||
$response = $this->postJson('/api/forgot-password', [
|
||||
'email' => 'missing@example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
it('resets a password with a valid token', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'reset2@example.com',
|
||||
@@ -116,6 +129,22 @@ it('resets a password with a valid token', function (): void {
|
||||
expect(Hash::check('NewPassword123!', $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns validation error when reset fails', function (): void {
|
||||
Password::shouldReceive('reset')
|
||||
->once()
|
||||
->andReturn(Password::INVALID_TOKEN);
|
||||
|
||||
$response = $this->postJson('/api/reset-password', [
|
||||
'email' => 'resetfail@example.com',
|
||||
'password' => 'NewPassword123!',
|
||||
'password_confirmation' => 'NewPassword123!',
|
||||
'token' => 'bad-token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
it('verifies email and redirects to login', function (): void {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
@@ -133,6 +162,19 @@ it('verifies email and redirects to login', function (): void {
|
||||
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects invalid email verification hash', function (): void {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$url = URL::signedRoute('verification.verify', [
|
||||
'id' => $user->id,
|
||||
'hash' => sha1('wrong'),
|
||||
]);
|
||||
|
||||
$response = $this->get($url);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('updates password for authenticated users', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Role;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
@@ -32,6 +35,365 @@ it('can filter forums by parent exists', function (): void {
|
||||
$response->assertJsonFragment(['id' => $forum->id]);
|
||||
});
|
||||
|
||||
it('filters forums by parent id and type', function (): void {
|
||||
$category = Forum::create([
|
||||
'name' => 'Category 2',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum B',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/forums?parent=/api/forums/{$category->id}");
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['id' => $forum->id]);
|
||||
|
||||
$response = $this->getJson('/api/forums?type=category');
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['id' => $category->id]);
|
||||
});
|
||||
|
||||
it('shows forum with last post data', function (): void {
|
||||
$role = Role::create(['name' => 'ROLE_MEMBER', 'color' => '#00ff00']);
|
||||
$user = User::factory()->create();
|
||||
$user->roles()->attach($role);
|
||||
$user->load('roles');
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category 3',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum C',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Reply',
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/forums/{$forum->id}");
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment([
|
||||
'id' => $forum->id,
|
||||
'last_post_user_id' => $user->id,
|
||||
]);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['last_post_user_group_color'])->toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('creates category and shifts positions', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
Forum::create([
|
||||
'name' => 'Category A',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/forums', [
|
||||
'name' => 'Category B',
|
||||
'type' => 'category',
|
||||
'description' => 'Desc',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas('forums', [
|
||||
'name' => 'Category A',
|
||||
'position' => 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates forum parent and description', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$categoryA = Forum::create([
|
||||
'name' => 'Category A',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$categoryB = Forum::create([
|
||||
'name' => 'Category B',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 2,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum D',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $categoryA->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
||||
'parent' => "/api/forums/{$categoryB->id}",
|
||||
'description' => 'Updated',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('forums', [
|
||||
'id' => $forum->id,
|
||||
'parent_id' => $categoryB->id,
|
||||
'description' => 'Updated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates forum name and type', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category H',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum H',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
||||
'name' => 'Forum H Updated',
|
||||
'type' => 'forum',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('forums', [
|
||||
'id' => $forum->id,
|
||||
'name' => 'Forum H Updated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects forum update without category parent', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category Z',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum E',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
||||
'parent' => null,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['message' => 'Forums must belong to a category.']);
|
||||
});
|
||||
|
||||
it('rejects forum update with non-category parent', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category X',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$parent = Forum::create([
|
||||
'name' => 'Not Category',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum G',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
||||
'parent' => "/api/forums/{$parent->id}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['message' => 'Parent must be a category.']);
|
||||
});
|
||||
|
||||
it('destroys forum and sets deleted_by', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum F',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->deleteJson("/api/forums/{$forum->id}");
|
||||
$response->assertStatus(204);
|
||||
|
||||
$forum->refresh();
|
||||
expect($forum->deleted_by)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('reorders with string parent id', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$parent = Forum::create([
|
||||
'name' => 'Cat Parent',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$first = Forum::create([
|
||||
'name' => 'Forum 1',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$second = Forum::create([
|
||||
'name' => 'Forum 2',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/forums/reorder', [
|
||||
'parentId' => (string) $parent->id,
|
||||
'orderedIds' => [$second->id, $first->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
||||
});
|
||||
|
||||
it('reorders with empty parent id string', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$first = Forum::create([
|
||||
'name' => 'Cat X',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$second = Forum::create([
|
||||
'name' => 'Cat Y',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/forums/reorder', [
|
||||
'parentId' => '',
|
||||
'orderedIds' => [$second->id, $first->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
||||
});
|
||||
|
||||
it('reorders with parent id null string', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$first = Forum::create([
|
||||
'name' => 'Cat N1',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$second = Forum::create([
|
||||
'name' => 'Cat N2',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/forums/reorder', [
|
||||
'parentId' => 'null',
|
||||
'orderedIds' => [$second->id, $first->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
||||
});
|
||||
|
||||
it('creates forum under category and increments position', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category P',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
Forum::create([
|
||||
'name' => 'Forum P1',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/forums', [
|
||||
'name' => 'Forum P2',
|
||||
'type' => 'forum',
|
||||
'parent' => "/api/forums/{$category->id}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas('forums', [
|
||||
'name' => 'Forum P2',
|
||||
'position' => 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects forum without category parent', function (): void {
|
||||
Sanctum::actingAs(User::factory()->create());
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
@@ -46,3 +48,88 @@ it('returns portal summary payload', function (): void {
|
||||
$response->assertJsonFragment(['name' => 'Forum']);
|
||||
$response->assertJsonFragment(['title' => 'Thread']);
|
||||
});
|
||||
|
||||
it('includes avatar and rank data in portal threads', function (): void {
|
||||
$rank = Rank::create([
|
||||
'name' => 'Gold',
|
||||
'badge_type' => 'image',
|
||||
'badge_image_path' => 'ranks/gold.png',
|
||||
]);
|
||||
$role = Role::create(['name' => 'ROLE_SPECIAL', 'color' => '#ff0000']);
|
||||
$user = User::factory()->create([
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/portal/summary');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['threads'][0]['user_avatar_url'])->not->toBeNull();
|
||||
expect($payload['threads'][0]['user_rank_badge_url'])->not->toBeNull();
|
||||
expect($payload['threads'][0]['user_group_color'])->toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('handles empty forum last posts and resolveGroupColor', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category2',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum2',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/portal/summary');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['forums'][0]['last_post_user_group_color'])->toBeNull();
|
||||
});
|
||||
|
||||
it('handles summary when no forums exist', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/portal/summary');
|
||||
|
||||
$response->assertOk();
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['forums'])->toBe([]);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,30 @@ use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeThread(): Thread
|
||||
{
|
||||
$category = Forum::create([
|
||||
|
||||
@@ -90,3 +90,73 @@ it('lists thanks received for a user', function (): void {
|
||||
'thanker_name' => 'ThanksGiver',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires auth to thank and unthank posts', function (): void {
|
||||
$thread = makeThanksThread();
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$this->app['auth']->forgetGuards();
|
||||
$response = $this->postJson("/api/posts/{$post->id}/thanks");
|
||||
$response->assertStatus(401);
|
||||
|
||||
$this->app['auth']->forgetGuards();
|
||||
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
it('creates and deletes thanks for a post', function (): void {
|
||||
$thread = makeThanksThread();
|
||||
$user = User::factory()->create();
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->postJson("/api/posts/{$post->id}/thanks");
|
||||
$response->assertStatus(201);
|
||||
|
||||
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
|
||||
$response->assertStatus(204);
|
||||
});
|
||||
|
||||
it('serializes group colors for thanks', function (): void {
|
||||
$thread = makeThanksThread();
|
||||
$authorRole = \App\Models\Role::create(['name' => 'ROLE_AUTHOR', 'color' => '#ff0000']);
|
||||
$thankerRole = \App\Models\Role::create(['name' => 'ROLE_THANKER', 'color' => '#00ff00']);
|
||||
|
||||
$author = User::factory()->create(['name' => 'Author']);
|
||||
$author->roles()->attach($authorRole);
|
||||
$author->load('roles');
|
||||
$thanker = User::factory()->create(['name' => 'ThanksGiver']);
|
||||
$thanker->roles()->attach($thankerRole);
|
||||
$thanker->load('roles');
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $author->id,
|
||||
'body' => 'Helpful post',
|
||||
]);
|
||||
|
||||
PostThank::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $thanker->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($thanker);
|
||||
$response = $this->getJson("/api/user/{$thanker->id}/thanks/given");
|
||||
$response->assertOk();
|
||||
$payload = $response->getData(true);
|
||||
expect($payload[0]['post_author_group_color'])->toBe('#ff0000');
|
||||
|
||||
Sanctum::actingAs($author);
|
||||
$response = $this->getJson("/api/user/{$author->id}/thanks/received");
|
||||
$response->assertOk();
|
||||
$payload = $response->getData(true);
|
||||
expect($payload[0]['thanker_group_color'])->toBe('#00ff00');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<?php
|
||||
|
||||
it('renders bbcode preview', function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$user = \App\Models\User::factory()->create();
|
||||
\Laravel\Sanctum\Sanctum::actingAs($user);
|
||||
|
||||
@@ -13,6 +31,24 @@ it('renders bbcode preview', function (): void {
|
||||
});
|
||||
|
||||
it('validates preview body', function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$user = \App\Models\User::factory()->create();
|
||||
\Laravel\Sanctum\Sanctum::actingAs($user);
|
||||
|
||||
@@ -21,3 +57,7 @@ it('validates preview body', function (): void {
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['body']);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,31 @@ it('lists ranks for authenticated users', function (): void {
|
||||
$response->assertJsonFragment(['name' => 'Bronze']);
|
||||
});
|
||||
|
||||
it('forbids non-admin rank changes', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$rank = Rank::create(['name' => 'Nope']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/ranks', [
|
||||
'name' => 'Silver',
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
||||
'name' => 'Nope',
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->deleteJson("/api/ranks/{$rank->id}");
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
|
||||
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('creates ranks as admin', function (): void {
|
||||
$admin = makeAdminForRanks();
|
||||
Sanctum::actingAs($admin);
|
||||
@@ -45,6 +70,22 @@ it('creates ranks as admin', function (): void {
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates ranks with none badge type', function (): void {
|
||||
$admin = makeAdminForRanks();
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->postJson('/api/ranks', [
|
||||
'name' => 'NoBadge',
|
||||
'badge_type' => 'none',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment([
|
||||
'name' => 'NoBadge',
|
||||
'badge_text' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates ranks and clears badge images when switching to text', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -71,6 +112,47 @@ it('updates ranks and clears badge images when switching to text', function ():
|
||||
Storage::disk('public')->assertMissing('rank-badges/old.png');
|
||||
});
|
||||
|
||||
it('updates ranks with badge_type none', function (): void {
|
||||
$admin = makeAdminForRanks();
|
||||
$rank = Rank::create([
|
||||
'name' => 'Plain',
|
||||
'badge_type' => 'text',
|
||||
'badge_text' => 'P',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
||||
'name' => 'Plain',
|
||||
'badge_type' => 'none',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['badge_text' => null]);
|
||||
});
|
||||
|
||||
it('updates ranks to image badge and keeps existing image', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$admin = makeAdminForRanks();
|
||||
$rank = Rank::create([
|
||||
'name' => 'ImageRank',
|
||||
'badge_type' => 'image',
|
||||
'badge_text' => null,
|
||||
'badge_image_path' => 'rank-badges/existing.png',
|
||||
]);
|
||||
|
||||
Storage::disk('public')->put('rank-badges/existing.png', 'existing');
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
||||
'name' => 'ImageRank',
|
||||
'badge_type' => 'image',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
Storage::disk('public')->assertExists('rank-badges/existing.png');
|
||||
});
|
||||
|
||||
it('uploads a rank badge image', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -86,6 +168,48 @@ it('uploads a rank badge image', function (): void {
|
||||
$response->assertJsonFragment(['badge_type' => 'image']);
|
||||
});
|
||||
|
||||
it('includes badge image url in rank list when present', function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('rank-badges/show.png', 'img');
|
||||
|
||||
$user = User::factory()->create();
|
||||
Rank::create([
|
||||
'name' => 'WithImage',
|
||||
'badge_type' => 'image',
|
||||
'badge_image_path' => 'rank-badges/show.png',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
$response = $this->getJson('/api/ranks');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment([
|
||||
'name' => 'WithImage',
|
||||
]);
|
||||
expect($response->getData(true)[0]['badge_image_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('uploads badge image replaces existing one', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$admin = makeAdminForRanks();
|
||||
$rank = Rank::create([
|
||||
'name' => 'Replace',
|
||||
'badge_type' => 'image',
|
||||
'badge_image_path' => 'rank-badges/old.png',
|
||||
]);
|
||||
|
||||
Storage::disk('public')->put('rank-badges/old.png', 'old');
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
|
||||
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
Storage::disk('public')->assertMissing('rank-badges/old.png');
|
||||
});
|
||||
|
||||
it('deletes ranks as admin', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ it('creates normalized roles as admin', function (): void {
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists roles for admins', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
Role::create(['name' => 'ROLE_ALPHA', 'color' => '#111111']);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->getJson('/api/roles');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['name' => 'ROLE_ALPHA']);
|
||||
});
|
||||
|
||||
it('prevents renaming core roles', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
$core = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
||||
@@ -56,6 +67,49 @@ it('prevents renaming core roles', function (): void {
|
||||
$response->assertJsonFragment(['message' => 'Core roles cannot be renamed.']);
|
||||
});
|
||||
|
||||
it('prevents creating duplicate roles after normalization', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
Role::create(['name' => 'ROLE_TEST', 'color' => '#111111']);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->postJson('/api/roles', [
|
||||
'name' => 'test',
|
||||
'color' => '#222222',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['message' => 'Role already exists.']);
|
||||
});
|
||||
|
||||
it('updates role color when provided and keeps name', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
$role = Role::create(['name' => 'ROLE_EDIT', 'color' => '#111111']);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->patchJson("/api/roles/{$role->id}", [
|
||||
'name' => 'ROLE_EDIT',
|
||||
'color' => '#222222',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['color' => '#222222']);
|
||||
});
|
||||
|
||||
it('prevents updating to duplicate normalized name', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
$first = Role::create(['name' => 'ROLE_FIRST', 'color' => '#111111']);
|
||||
$second = Role::create(['name' => 'ROLE_SECOND', 'color' => '#111111']);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
$response = $this->patchJson("/api/roles/{$second->id}", [
|
||||
'name' => 'first',
|
||||
'color' => '#111111',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['message' => 'Role already exists.']);
|
||||
});
|
||||
|
||||
it('prevents deleting core roles', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
$core = Role::firstOrCreate(['name' => 'ROLE_USER'], ['color' => '#111111']);
|
||||
@@ -90,3 +144,37 @@ it('deletes non-core roles without assignments', function (): void {
|
||||
$response->assertStatus(204);
|
||||
$this->assertDatabaseMissing('roles', ['id' => $role->id]);
|
||||
});
|
||||
|
||||
it('forbids non-admin create update delete', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/roles', [
|
||||
'name' => 'helper',
|
||||
'color' => '#111111',
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
$role = Role::create(['name' => 'ROLE_TEMP', 'color' => '#111111']);
|
||||
$response = $this->patchJson("/api/roles/{$role->id}", [
|
||||
'name' => 'ROLE_TEMP',
|
||||
'color' => '#222222',
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
$response = $this->deleteJson("/api/roles/{$role->id}");
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('normalizes invalid role names to ROLE_', function (): void {
|
||||
$admin = makeAdminForRoles();
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->postJson('/api/roles', [
|
||||
'name' => '!!!',
|
||||
'color' => '#111111',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['name' => 'ROLE_']);
|
||||
});
|
||||
|
||||
@@ -87,3 +87,16 @@ it('bulk stores settings as admin', function (): void {
|
||||
'value' => 'Fast',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bulk store forbids non-admin users', function (): void {
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/settings/bulk', [
|
||||
'settings' => [
|
||||
['key' => 'site.name', 'value' => 'SpeedBB'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
462
tests/Feature/SystemUpdateControllerBranchesTest.php
Normal file
462
tests/Feature/SystemUpdateControllerBranchesTest.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
function makeAdminForSystemUpdate(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
function withFakeBin(array $scripts, callable $callback): void
|
||||
{
|
||||
$dir = storage_path('app/test-bin-' . Str::random(6));
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
foreach ($scripts as $name => $body) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
file_put_contents($path, $body);
|
||||
chmod($path, 0755);
|
||||
}
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$dir}");
|
||||
$_ENV['PATH'] = $dir;
|
||||
$_SERVER['PATH'] = $dir;
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
$_ENV['PATH'] = $originalPath;
|
||||
$_SERVER['PATH'] = $originalPath;
|
||||
if (is_dir($dir)) {
|
||||
$items = scandir($dir);
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('uses token auth header and tarball template', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
putenv('GITEA_TGZ_URL_TEMPLATE=https://git.example.test/tarball/{{TAG}}-{{VERSION}}.tgz');
|
||||
putenv('GITEA_TOKEN=secrettoken');
|
||||
|
||||
$tarballUrl = 'https://git.example.test/tarball/v1.2.3-1.2.3.tgz';
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => '',
|
||||
], 200),
|
||||
$tarballUrl => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
$artisanPath = base_path('artisan');
|
||||
$originalArtisan = file_get_contents($artisanPath);
|
||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
|
||||
chmod($artisanPath, 0755);
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function () use ($artisanPath, $originalArtisan): void {
|
||||
try {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['tag' => 'v1.2.3']);
|
||||
} finally {
|
||||
file_put_contents($artisanPath, $originalArtisan);
|
||||
}
|
||||
});
|
||||
|
||||
Http::assertSent(function ($request) use ($tarballUrl) {
|
||||
if ($request->url() === $tarballUrl) {
|
||||
return true;
|
||||
}
|
||||
return $request->hasHeader('Authorization', 'token secrettoken');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns update failed on unexpected exception', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake(function () {
|
||||
throw new RuntimeException('boom');
|
||||
});
|
||||
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Update failed.']);
|
||||
});
|
||||
|
||||
it('handles release check failures', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Release check failed: 500']);
|
||||
});
|
||||
|
||||
it('handles missing tag in release response', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => '',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Release tag not found.']);
|
||||
});
|
||||
|
||||
it('handles missing tarball url', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
putenv('GITEA_TGZ_URL_TEMPLATE=');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => '',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'No tarball URL available.']);
|
||||
});
|
||||
|
||||
it('handles tarball download failure', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('fail', 500),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Download failed: 500']);
|
||||
});
|
||||
|
||||
it('handles extract failure', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 1\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Failed to extract archive.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing extracted folder', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn([]);
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'No extracted folder found.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles rsync failure when available', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'rsync' => "#!/bin/sh\nexit 1\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'rsync failed.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles composer install failure after copyDirectory', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 1\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Composer install failed.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles npm install failure', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nif [ \"$1\" = \"install\" ]; then exit 1; fi\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'npm install failed.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles npm build failure', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nif [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then exit 1; fi\nexit 0\n",
|
||||
], function (): void {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'npm run build failed.']);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles migration failure', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
$artisanPath = base_path('artisan');
|
||||
$originalArtisan = file_get_contents($artisanPath);
|
||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(1);\n");
|
||||
chmod($artisanPath, 0755);
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function () use ($artisanPath, $originalArtisan): void {
|
||||
try {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertJsonFragment(['message' => 'Migrations failed.']);
|
||||
} finally {
|
||||
file_put_contents($artisanPath, $originalArtisan);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('handles fallback copyDirectory update success', function (): void {
|
||||
putenv('GITEA_OWNER=acme');
|
||||
putenv('GITEA_REPO=speedbb');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
||||
'tag_name' => 'v1.2.3',
|
||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
||||
], 200),
|
||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
||||
]);
|
||||
|
||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
||||
File::shouldReceive('put')->andReturnTrue();
|
||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
||||
|
||||
$artisanPath = base_path('artisan');
|
||||
$originalArtisan = file_get_contents($artisanPath);
|
||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
|
||||
chmod($artisanPath, 0755);
|
||||
|
||||
withFakeBin([
|
||||
'tar' => "#!/bin/sh\nexit 0\n",
|
||||
'composer' => "#!/bin/sh\nexit 0\n",
|
||||
'npm' => "#!/bin/sh\nexit 0\n",
|
||||
], function () use ($artisanPath, $originalArtisan): void {
|
||||
try {
|
||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
||||
$response = $this->postJson('/api/system/update');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['message' => 'Update finished.']);
|
||||
$response->assertJsonStructure(['used_rsync']);
|
||||
} finally {
|
||||
file_put_contents($artisanPath, $originalArtisan);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
it('forbids system update for non-admins', function (): void {
|
||||
|
||||
@@ -6,6 +6,30 @@ use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeForum(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
@@ -80,7 +104,7 @@ it('requires authentication to update a thread', function (): void {
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Original',
|
||||
'body' => 'Body',
|
||||
'body' => '',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/threads/{$thread->id}", [
|
||||
@@ -100,7 +124,7 @@ it('enforces thread update permissions', function (): void {
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Original',
|
||||
'body' => 'Body',
|
||||
'body' => '',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($other);
|
||||
@@ -151,7 +175,7 @@ it('enforces solved status permissions', function (): void {
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Original',
|
||||
'body' => 'Body',
|
||||
'body' => '',
|
||||
'solved' => false,
|
||||
]);
|
||||
|
||||
@@ -182,14 +206,14 @@ it('filters threads by forum', function (): void {
|
||||
'forum_id' => $forumA->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread A',
|
||||
'body' => 'Body A',
|
||||
'body' => '',
|
||||
]);
|
||||
|
||||
Thread::create([
|
||||
'forum_id' => $forumB->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread B',
|
||||
'body' => 'Body B',
|
||||
'body' => '',
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/threads?forum=/api/forums/{$forumA->id}");
|
||||
@@ -208,7 +232,7 @@ it('increments views count when showing a thread', function (): void {
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Viewed Thread',
|
||||
'body' => 'Body',
|
||||
'body' => '',
|
||||
'views_count' => 0,
|
||||
]);
|
||||
|
||||
|
||||
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
function makeForumForAttachments(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
return Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('store returns unauthorized without user', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$request = Request::create('/api/attachments', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('store returns file missing when file not provided', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => '/api/threads/1',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
try {
|
||||
$controller->store($request);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('file');
|
||||
}
|
||||
});
|
||||
|
||||
it('store returns file missing when request file is null after validation', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Mockery::mock(Request::class)->makePartial();
|
||||
$request->shouldReceive('user')->andReturn($user);
|
||||
$request->shouldReceive('validate')->andReturn([
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
'post' => null,
|
||||
'file' => 'ignored',
|
||||
]);
|
||||
$request->shouldReceive('file')->andReturn(null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store rejects disallowed extension and mime and size', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 1,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$ext = AttachmentExtension::create([
|
||||
'extension' => 'pdf',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['application/pdf'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->create('doc.pdf', 2, 'application/pdf');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->is_active = true;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->max_size_kb = 1000;
|
||||
$group->save();
|
||||
|
||||
$ext->allowed_mimes = ['image/png'];
|
||||
$ext->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$ext->allowed_mimes = ['application/pdf'];
|
||||
$ext->save();
|
||||
|
||||
$group->max_size_kb = 0;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store returns forbidden when user cannot attach to post', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->image('photo.png');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'post' => "/api/posts/{$post->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('index filters by post id', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$postA = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post A',
|
||||
]);
|
||||
$postB = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post B',
|
||||
]);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postA->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postA->id.'/a.txt',
|
||||
'original_name' => 'a.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postB->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postB->id.'/b.txt',
|
||||
'original_name' => 'b.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachments', 'GET', [
|
||||
'post' => "/api/posts/{$postA->id}",
|
||||
]);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect(count($payload))->toBe(1);
|
||||
expect($payload[0]['post_id'])->toBe($postA->id);
|
||||
});
|
||||
|
||||
it('show returns not found when attachment is not viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment([
|
||||
'disk' => 'local',
|
||||
'path' => 'missing',
|
||||
]);
|
||||
$attachment->setRawAttributes(['id' => 1, 'deleted_at' => now()]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(404);
|
||||
});
|
||||
|
||||
it('show returns attachment when viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('download aborts when file missing or not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('download aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when missing path or file', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
|
||||
$attachment->thumbnail_path = 'attachments/threads/'.$thread->id.'/thumb.jpg';
|
||||
$attachment->save();
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/file.pdf',
|
||||
'thumbnail_path' => 'attachments/threads/1/thumb.jpg',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('destroy returns unauthorized or forbidden', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment(['user_id' => 999]);
|
||||
|
||||
$request = Request::create('/api/attachments/1', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('private helpers cover parse and match branches', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$refThread = new ReflectionMethod($controller, 'parseThreadId');
|
||||
$refThread->setAccessible(true);
|
||||
$refPost = new ReflectionMethod($controller, 'parsePostId');
|
||||
$refPost->setAccessible(true);
|
||||
$refMatch = new ReflectionMethod($controller, 'matchesAllowed');
|
||||
$refMatch->setAccessible(true);
|
||||
$refResolve = new ReflectionMethod($controller, 'resolveExtension');
|
||||
$refResolve->setAccessible(true);
|
||||
|
||||
expect($refThread->invoke($controller, null))->toBeNull();
|
||||
expect($refThread->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($refThread->invoke($controller, '5'))->toBe(5);
|
||||
expect($refThread->invoke($controller, 'abc'))->toBeNull();
|
||||
|
||||
expect($refPost->invoke($controller, null))->toBeNull();
|
||||
expect($refPost->invoke($controller, '/posts/7'))->toBe(7);
|
||||
expect($refPost->invoke($controller, '9'))->toBe(9);
|
||||
expect($refPost->invoke($controller, 'nope'))->toBeNull();
|
||||
|
||||
expect($refMatch->invoke($controller, 'image/png', null))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', []))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/jpeg']))->toBeFalse();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/png']))->toBeTrue();
|
||||
|
||||
expect($refResolve->invoke($controller, ''))->toBeNull();
|
||||
});
|
||||
|
||||
it('canViewAttachment handles trashed and missing parents', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$refView = new ReflectionMethod($controller, 'canViewAttachment');
|
||||
$refView->setAccessible(true);
|
||||
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$attachment->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->restore();
|
||||
$thread->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$thread->restore();
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
$attachment->post_id = $post->id;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$post->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->post_id = null;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment returns thumbnail_url null when missing', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toBeNull();
|
||||
expect($payload['is_image'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment includes thumbnail url when present', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'png',
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => 'thumbs/file.png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['is_image'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('canManageAttachments handles null user and admin', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$ref = new ReflectionMethod($controller, 'canManageAttachments');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null, 1))->toBeFalse();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$role = \App\Models\Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
expect($ref->invoke($controller, $admin, 999))->toBeTrue();
|
||||
});
|
||||
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentExtensionController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminUserForExtensions(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('index returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$request = Request::create('/api/attachment-extensions', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store update destroy return forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$store = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
$response = $controller->store($store);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'gif']);
|
||||
|
||||
$update = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/gif'],
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
$response = $controller->update($update, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($destroy, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store rejects invalid or duplicate extension', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => '.',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::create(['extension' => 'png']);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'PNG',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store and update serialize group info', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$extension = AttachmentExtension::query()->where('extension', 'png')->firstOrFail();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/png', 'image/webp'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['group']['name'])->toBe('Images');
|
||||
});
|
||||
|
||||
it('destroy returns error when extension in use then succeeds', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'pdf']);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => $extension->id,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.pdf',
|
||||
'original_name' => 'test.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
Attachment::query()->delete();
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('public index only returns active grouped extensions', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
|
||||
$activeGroup = AttachmentGroup::create([
|
||||
'name' => 'Active',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$inactiveGroup = AttachmentGroup::create([
|
||||
'name' => 'Inactive',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $activeGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'zip',
|
||||
'attachment_group_id' => $inactiveGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'orphan',
|
||||
'attachment_group_id' => null,
|
||||
]);
|
||||
|
||||
$response = $controller->publicIndex();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toBe(['png']);
|
||||
});
|
||||
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentGroupController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminForGroupController(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$index = Request::create('/api/attachment-groups', 'GET');
|
||||
$index->setUserResolver(fn () => $user);
|
||||
expect($controller->index($index)->getStatusCode())->toBe(403);
|
||||
|
||||
$store = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
expect($controller->store($store)->getStatusCode())->toBe(403);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
expect($controller->update($update, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
expect($controller->destroy($destroy, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$reorder = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => null,
|
||||
'orderedIds' => [],
|
||||
]);
|
||||
$reorder->setUserResolver(fn () => $user);
|
||||
expect($controller->reorder($reorder)->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('stores group and rejects duplicates', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$request = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('updates group and handles parent change position', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parentA = AttachmentGroup::create([
|
||||
'name' => 'Parent A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$parentB = AttachmentGroup::create([
|
||||
'name' => 'Parent B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentA->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentB->id,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => false,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('update rejects duplicate group name', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy returns errors for in-use group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::query()->delete();
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy deletes empty group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Empty',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('reorders groups with string parent id handling', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => 'null',
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$groupA->refresh();
|
||||
$groupB->refresh();
|
||||
expect($groupB->position)->toBe(1);
|
||||
expect($groupA->position)->toBe(2);
|
||||
});
|
||||
|
||||
it('reorders groups with numeric parent id string', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parent = AttachmentGroup::create([
|
||||
'name' => 'Parent',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => (string) $parent->id,
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('normalizeParentId handles empty and null values', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$ref = new ReflectionMethod($controller, 'normalizeParentId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'null'))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthorized when no user', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden when user is not admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$user = User::factory()->create();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('returns logs for admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
AuditLog::create([
|
||||
'action' => 'test.action',
|
||||
'subject_type' => 'post',
|
||||
'subject_id' => 1,
|
||||
'metadata' => ['foo' => 'bar'],
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'test',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload)->toHaveCount(1);
|
||||
expect($payload[0]['user']['roles'][0])->toBe('ROLE_ADMIN');
|
||||
});
|
||||
155
tests/Unit/BbcodeFormatterTest.php
Normal file
155
tests/Unit/BbcodeFormatterTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace s9e\TextFormatter {
|
||||
class Parser
|
||||
{
|
||||
public function parse(string $text): string
|
||||
{
|
||||
return '<r/>';
|
||||
}
|
||||
}
|
||||
|
||||
class Renderer
|
||||
{
|
||||
public function render(string $xml): string
|
||||
{
|
||||
return '<p>ok</p>';
|
||||
}
|
||||
}
|
||||
|
||||
class Configurator
|
||||
{
|
||||
public static bool $returnEmpty = false;
|
||||
public object $plugins;
|
||||
public object $tags;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->plugins = new class {
|
||||
public function load(string $name): object
|
||||
{
|
||||
return new class {
|
||||
public function addFromRepository(string $name): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags = new class implements \ArrayAccess {
|
||||
public array $store = [];
|
||||
public function add($name)
|
||||
{
|
||||
$obj = new \stdClass();
|
||||
$this->store[$name] = $obj;
|
||||
return $obj;
|
||||
}
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return array_key_exists($offset, $this->store);
|
||||
}
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->store[$offset] ?? null;
|
||||
}
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->store[$offset] = $value;
|
||||
}
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->store[$offset]);
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags['QUOTE'] = new \stdClass();
|
||||
}
|
||||
|
||||
public function finalize(): array
|
||||
{
|
||||
if (self::$returnEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'parser' => new Parser(),
|
||||
'renderer' => new Renderer(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Actions\BbcodeFormatter;
|
||||
|
||||
it('returns empty string for null and empty input', function (): void {
|
||||
expect(BbcodeFormatter::format(null))->toBe('');
|
||||
expect(BbcodeFormatter::format(''))->toBe('');
|
||||
});
|
||||
|
||||
it('formats bbcode content', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<b>Bold</b>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toContain('<b>');
|
||||
});
|
||||
|
||||
it('initializes parser and renderer when not set', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('throws when bbcode formatter cannot initialize', function (): void {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = true;
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
try {
|
||||
BbcodeFormatter::format('test');
|
||||
$this->fail('Expected exception not thrown.');
|
||||
} catch (Throwable $e) {
|
||||
expect($e)->toBeInstanceOf(RuntimeException::class);
|
||||
} finally {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = false;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
}
|
||||
50
tests/Unit/ConsoleCommandTest.php
Normal file
50
tests/Unit/ConsoleCommandTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('version bump fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version bump fails when invalid version', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => 'bad']);
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version set fails when invalid version', function (): void {
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:set', ['version' => 'bad']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version fetch fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release fails when missing config', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN');
|
||||
putenv('GITEA_OWNER');
|
||||
putenv('GITEA_REPO');
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release handles create failure', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN=token');
|
||||
putenv('GITEA_OWNER=owner');
|
||||
putenv('GITEA_REPO=repo');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
159
tests/Unit/CronRunCommandTest.php
Normal file
159
tests/Unit/CronRunCommandTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
it('skips non-image attachments', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('counts missing files for images', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/missing.jpg',
|
||||
'original_name' => 'missing.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('skips when thumbnail already exists', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb.jpg', 'thumb');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('creates thumbnails in dry run mode', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--dry-run' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('forces thumbnail regeneration and updates attachment when created', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb-old.jpg', 'old');
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb-old.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')
|
||||
->once()
|
||||
->andReturn([
|
||||
'path' => 'attachments/thumb-new.jpg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 123,
|
||||
]);
|
||||
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--force' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$attachment->refresh();
|
||||
expect($attachment->thumbnail_path)->toBe('attachments/thumb-new.jpg');
|
||||
expect($attachment->thumbnail_size_bytes)->toBe(123);
|
||||
});
|
||||
|
||||
it('skips when thumbnail creation fails', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')->once()->andReturnNull();
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Mockery::close();
|
||||
});
|
||||
114
tests/Unit/ForumControllerUnitTest.php
Normal file
114
tests/Unit/ForumControllerUnitTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ForumController;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Role;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('parseIriId handles null and numeric', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, '/forums/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns empty for no ids', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, []))->toBe([]);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null when roles have no colors', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_EMPTY', 'color' => null]);
|
||||
$user->roles()->attach($role);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns latest post per forum', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category U',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum U',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$older = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Old',
|
||||
]);
|
||||
$newer = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'New',
|
||||
]);
|
||||
Post::whereKey($older->id)->update([
|
||||
'created_at' => now()->subDay(),
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
Post::whereKey($newer->id)->update([
|
||||
'created_at' => now()->addSeconds(10),
|
||||
'updated_at' => now()->addSeconds(10),
|
||||
]);
|
||||
|
||||
$result = $ref->invoke($controller, [$forum->id]);
|
||||
expect($result[$forum->id]->id)->toBe($newer->id);
|
||||
});
|
||||
146
tests/Unit/InstallerControllerTest.php
Normal file
146
tests/Unit/InstallerControllerTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function withEnvBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
$hadEnv = file_exists($path);
|
||||
$original = $hadEnv ? file_get_contents($path) : null;
|
||||
|
||||
try {
|
||||
$callback($path);
|
||||
} finally {
|
||||
if ($hadEnv) {
|
||||
file_put_contents($path, (string) $original);
|
||||
} elseif (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installerRequest(array $overrides = []): Request
|
||||
{
|
||||
$db = config('database.connections.mysql');
|
||||
|
||||
$data = array_merge([
|
||||
'app_url' => 'https://example.test',
|
||||
'db_host' => $db['host'] ?? '127.0.0.1',
|
||||
'db_port' => $db['port'] ?? 3306,
|
||||
'db_database' => $db['database'] ?? 'tracer_speedBB_test',
|
||||
'db_username' => $db['username'] ?? 'root',
|
||||
'db_password' => $db['password'] ?? '',
|
||||
'admin_name' => 'Admin',
|
||||
'admin_email' => 'admin@example.com',
|
||||
'admin_password' => 'Password123!',
|
||||
], $overrides);
|
||||
|
||||
return Request::create('https://example.test/install', 'POST', $data);
|
||||
}
|
||||
|
||||
it('shows installer when env missing', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects installer when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store redirects when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles db connection failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andThrow(new RuntimeException('boom'));
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['app_url' => 'https://example.test']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles migration failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(1);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store completes installation on success', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(0);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['admin_email' => 'success@example.com']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
|
||||
$user = User::where('email', 'success@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect(Role::where('name', 'ROLE_ADMIN')->exists())->toBeTrue();
|
||||
expect(Role::where('name', 'ROLE_FOUNDER')->exists())->toBeTrue();
|
||||
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
});
|
||||
});
|
||||
353
tests/Unit/PostControllerUnitTest.php
Normal file
353
tests/Unit/PostControllerUnitTest.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeForumForPostController(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
return Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns unauthorized on update when no user', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden on update when user is not owner or admin', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('updates post when user is owner', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'Updated']);
|
||||
$request->setUserResolver(fn () => $owner);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$post->refresh();
|
||||
expect($post->body)->toBe('Updated');
|
||||
});
|
||||
|
||||
it('parseIriId handles empty and numeric values', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes posts with attachments and rank data', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$role = Role::create(['name' => 'ROLE_MOD', 'color' => '#00ff00']);
|
||||
$rank = Rank::create(['name' => 'Gold', 'badge_image_path' => 'ranks/badge.png']);
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'location' => 'Here',
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'See [attachment]file.png[/attachment]',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.png',
|
||||
'thumbnail_path' => 'attachments/posts/'.$post->id.'/thumb.png',
|
||||
'original_name' => 'file.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$post->load(['user.rank', 'user.roles', 'attachments.group']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
expect($payload['user_group_color'])->toBe('#00ff00');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
});
|
||||
|
||||
it('serializes posts with null user and no attachments', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_avatar_url'])->toBeNull();
|
||||
expect($payload['user_rank_badge_url'])->toBeNull();
|
||||
expect($payload['user_group_color'])->toBeNull();
|
||||
expect($payload['attachments'])->toBe([]);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags handles inline images and links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => null,
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]file.png[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[img]');
|
||||
|
||||
$attachment->thumbnail_path = 'thumb';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
$result = $ref->invoke($controller, 'No match', collect([$attachment]));
|
||||
expect($result)->toContain('No match');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns original tag when attachment name missing in map', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'original_name' => 'actual.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]missing.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toBe($body);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags renders non-image attachments as links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'yes']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 3,
|
||||
'original_name' => 'doc.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]doc.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[url=');
|
||||
expect($result)->toContain('doc.txt');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns body when no attachments or map empty', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, 'Body', []))->toBe('Body');
|
||||
|
||||
$attachment = new Attachment([
|
||||
'original_name' => '',
|
||||
]);
|
||||
expect($ref->invoke($controller, 'Body', collect([$attachment])))->toBe('Body');
|
||||
});
|
||||
|
||||
it('displayImagesInline defaults to true when missing setting', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('displayImagesInline returns false for off values', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'off']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeFalse();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostThankController;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthenticated when no user in store/destroy', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$post = new Post([
|
||||
'id' => 1,
|
||||
]);
|
||||
$post->setRawAttributes(['id' => 1, 'thread_id' => 1, 'user_id' => null, 'body' => 'Post'], true);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->store($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
20
tests/Unit/ResetUserPasswordTest.php
Normal file
20
tests/Unit/ResetUserPasswordTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
it('resets user password after validation', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
|
||||
$action = new ResetUserPassword();
|
||||
$action->reset($user, [
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
63
tests/Unit/StatsControllerUnitTest.php
Normal file
63
tests/Unit/StatsControllerUnitTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('returns null board version when no version is set', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
Setting::where('key', 'build')->delete();
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['board_version'])->toBeNull();
|
||||
});
|
||||
|
||||
it('handles stats edge cases without crashing', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '9']);
|
||||
|
||||
DB::shouldReceive('connection->getDriverName')->andReturn('sqlite');
|
||||
DB::shouldReceive('selectOne')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['database_size_bytes'])->toBeNull();
|
||||
expect($payload['database_server'])->toBeNull();
|
||||
expect($payload['board_version'])->toBe('1.0.0 (build 9)');
|
||||
expect($payload['orphan_attachments'])->toBeInt();
|
||||
});
|
||||
|
||||
it('returns null for database size and avatar size on exceptions', function (): void {
|
||||
DB::shouldReceive('connection->getDriverName')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$refDb = new ReflectionMethod($controller, 'resolveDatabaseSize');
|
||||
$refDb->setAccessible(true);
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refDb->invoke($controller))->toBeNull();
|
||||
\Illuminate\Support\Facades\Storage::shouldReceive('disk')->andThrow(new RuntimeException('disk fail'));
|
||||
expect($refAvatar->invoke($controller))->toBeNull();
|
||||
});
|
||||
|
||||
it('sums avatar directory size', function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('avatars/a.png', 'a');
|
||||
Storage::disk('public')->put('avatars/b.png', 'bb');
|
||||
|
||||
$controller = new StatsController();
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refAvatar->invoke($controller))->toBe(3);
|
||||
});
|
||||
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\SystemStatusController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function withFakeBinForStatus(array $scripts, callable $callback): void
|
||||
{
|
||||
$dir = storage_path('app/test-bin-' . Str::random(6));
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
foreach ($scripts as $name => $body) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
file_put_contents($path, $body);
|
||||
chmod($path, 0755);
|
||||
}
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$dir}");
|
||||
$_ENV['PATH'] = $dir;
|
||||
$_SERVER['PATH'] = $dir;
|
||||
|
||||
try {
|
||||
$callback($dir);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
$_ENV['PATH'] = $originalPath;
|
||||
$_SERVER['PATH'] = $originalPath;
|
||||
if (is_dir($dir)) {
|
||||
$items = scandir($dir);
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('returns system status for admins', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'php' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.0\"; exit 0; fi\necho \"php\"\n",
|
||||
'composer' => "#!/bin/sh\necho \"composer 2.0.0\"\n",
|
||||
'node' => "#!/bin/sh\necho \"v20.0.0\"\n",
|
||||
'npm' => "#!/bin/sh\necho \"9.0.0\"\n",
|
||||
'tar' => "#!/bin/sh\necho \"tar 1.2.3\"\n",
|
||||
'rsync' => "#!/bin/sh\necho \"rsync 3.2.0\"\n",
|
||||
], function (string $dir): void {
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
$request = Request::create('/api/system/status', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$controller = new SystemStatusController();
|
||||
$response = $controller->__invoke($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toHaveKeys([
|
||||
'php',
|
||||
'php_default',
|
||||
'composer',
|
||||
'composer_version',
|
||||
'node',
|
||||
'node_version',
|
||||
'npm',
|
||||
'npm_version',
|
||||
'tar',
|
||||
'tar_version',
|
||||
'rsync',
|
||||
'rsync_version',
|
||||
'proc_functions',
|
||||
'storage_writable',
|
||||
'updates_writable',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('covers binary resolution edge cases', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'sh' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refBinary = new ReflectionMethod($controller, 'resolveBinary');
|
||||
$refBinary->setAccessible(true);
|
||||
|
||||
expect($refBinary->invoke($controller, 'php'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns php version when available', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfake' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.1\"; exit 0; fi\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfake';
|
||||
expect($refPhp->invoke($controller, $path))->toBe('8.4.1');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null php version when command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfail';
|
||||
expect($refPhp->invoke($controller, $path))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns binary version when regex matches', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'tool' => "#!/bin/sh\necho \"tool v1.2.3\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/tool';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBe('1.2.3');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output is empty', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'empty' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/empty';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output has no version', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'noversion' => "#!/bin/sh\necho \"tool version unknown\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/noversion';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'fail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/fail';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read file', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/missing.json';
|
||||
$result = $ref->invoke($controller, $path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson invalid', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/invalid.json';
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
$result = $ref->invoke($controller, $path);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read contents', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = storage_path('app/unreadable.json');
|
||||
file_put_contents($path, '{"a":1}');
|
||||
chmod($path, 0000);
|
||||
|
||||
$prev = set_error_handler(static fn () => true);
|
||||
$result = $ref->invoke($controller, $path);
|
||||
restore_error_handler();
|
||||
|
||||
chmod($path, 0644);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
it('parseIriId returns null for empty values and non numeric', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('parseIriId parses forum iris and numeric values', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, '/api/forums/123'))->toBe(123);
|
||||
expect($ref->invoke($controller, '456'))->toBe(456);
|
||||
});
|
||||
|
||||
it('serializes thread with rank badge url when present', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$rank = \App\Models\Rank::create([
|
||||
'name' => 'Rank',
|
||||
'badge_image_path' => 'ranks/badge.png',
|
||||
]);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$thread->load(['user.rank', 'attachments']);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'serializeThread');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $thread);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('replaces attachment tags with inline image without thumb', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'true']);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]image.jpg[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/image.jpg',
|
||||
'original_name' => 'image.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[img]');
|
||||
});
|
||||
|
||||
it('defaults to inline images when setting is missing', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns null group color when roles relation is null', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
@@ -12,6 +12,30 @@ use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeForumForThreadController(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
@@ -130,7 +154,7 @@ it('serializes threads with attachments, group colors, and inline images', funct
|
||||
expect($payload['user_group_color'])->toBe('#ff0000');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['body_html'])->toContain('<img');
|
||||
expect($payload['body_html'])->toContain('<p');
|
||||
expect($payload['last_post_id'])->toBe($post->id);
|
||||
});
|
||||
|
||||
|
||||
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
it('updates password when current password matches', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
$action->update($user, [
|
||||
'current_password' => 'OldPass123!',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects wrong current password', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
try {
|
||||
$action->update($user, [
|
||||
'current_password' => 'WrongPass',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('current_password');
|
||||
}
|
||||
});
|
||||
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Models\User;
|
||||
|
||||
it('updates profile without email verification when email unchanged', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->name)->toBe('New Name');
|
||||
expect($user->name_canonical)->toBe('new name');
|
||||
expect($user->email)->toBe('old@example.com');
|
||||
});
|
||||
|
||||
it('resets verification and sends notification when email changes', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'new@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email)->toBe('new@example.com');
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
122
tests/Unit/VersionBumpCommandTest.php
Normal file
122
tests/Unit/VersionBumpCommandTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_release_file_get_contents_false']) && str_ends_with($path, 'CHANGELOG.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_bump_json_encode_false'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
it('bumps patch version and syncs composer metadata', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.09-beta']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('1.2.10-beta');
|
||||
|
||||
$data = json_decode((string) file_get_contents($path), true);
|
||||
expect($data['version'] ?? null)->toBe('1.2.10-beta');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json is not readable', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_json_encode_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
125
tests/Unit/VersionFetchCommandTest.php
Normal file
125
tests/Unit/VersionFetchCommandTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_fetch_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackupForFetch(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_fetch_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_fetch_json_encode_false'] = false;
|
||||
$originalPath = $GLOBALS['version_fetch_path'] ?? null;
|
||||
if ($originalPath !== null) {
|
||||
putenv("PATH={$originalPath}");
|
||||
$_ENV['PATH'] = $originalPath;
|
||||
$_SERVER['PATH'] = $originalPath;
|
||||
unset($GLOBALS['version_fetch_path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('fetches build count and syncs composer metadata', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$build = Setting::where('key', 'build')->value('value');
|
||||
expect(is_numeric($build))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when build count cannot be resolved', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_path'] = getenv('PATH') ?: '';
|
||||
putenv('PATH=/nope');
|
||||
$_ENV['PATH'] = '/nope';
|
||||
$_SERVER['PATH'] = '/nope';
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackupForFetch(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json is not readable', function (): void {
|
||||
withComposerBackupForFetch(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_file_get_contents_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_json_encode_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
183
tests/Unit/VersionReleaseCommandTest.php
Normal file
183
tests/Unit/VersionReleaseCommandTest.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_release_file_get_contents_false']) && str_ends_with($path, 'CHANGELOG.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Console\Commands\VersionRelease;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function withChangelogBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('CHANGELOG.md');
|
||||
$had = file_exists($path);
|
||||
$original = $had ? file_get_contents($path) : null;
|
||||
|
||||
try {
|
||||
$callback($path);
|
||||
} finally {
|
||||
if ($had) {
|
||||
file_put_contents($path, (string) $original);
|
||||
} elseif (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setGiteaEnvForRelease(): void
|
||||
{
|
||||
putenv('GITEA_TOKEN=token');
|
||||
putenv('GITEA_OWNER=owner');
|
||||
putenv('GITEA_REPO=repo');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
putenv('GITEA_PRERELEASE=false');
|
||||
}
|
||||
|
||||
it('fails when version missing', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when gitea config missing', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN');
|
||||
putenv('GITEA_OWNER');
|
||||
putenv('GITEA_REPO');
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('creates release successfully with changelog body', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
withChangelogBackup(function (string $path): void {
|
||||
file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Added thing\n\n## 1.2.2\n- Old\n");
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response(['id' => 1], 201),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$payload = $request->data();
|
||||
return $payload['tag_name'] === 'v1.2.3'
|
||||
&& str_contains($payload['body'], 'Added thing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when create response is error', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when existing release cannot be fetched', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when existing release has no id', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => null], 200),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('updates existing release when create conflicts', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response(['id' => 99], 200),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('fails when updating existing release fails', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 422),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('returns default changelog body when file missing', function (): void {
|
||||
withChangelogBackup(function (string $path): void {
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
$command = new VersionRelease();
|
||||
$ref = new ReflectionMethod($command, 'resolveChangelogBody');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$body = $ref->invoke($command, '1.2.3');
|
||||
expect($body)->toBe('See commit history for details.');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default changelog body when read fails', function (): void {
|
||||
withChangelogBackup(function (string $path): void {
|
||||
file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Something\n");
|
||||
$GLOBALS['version_release_file_get_contents_false'] = true;
|
||||
|
||||
$command = new VersionRelease();
|
||||
$ref = new ReflectionMethod($command, 'resolveChangelogBody');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$body = $ref->invoke($command, '1.2.3');
|
||||
expect($body)->toBe('See commit history for details.');
|
||||
|
||||
$GLOBALS['version_release_file_get_contents_false'] = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
111
tests/Unit/VersionSetCommandTest.php
Normal file
111
tests/Unit/VersionSetCommandTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_set_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_set_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackupForSet(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_set_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_set_json_encode_false'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
it('sets version when no current version', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.3.4']);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('2.3.4');
|
||||
|
||||
$data = json_decode((string) file_get_contents($path), true);
|
||||
expect($data['version'] ?? null)->toBe('2.3.4');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates version when current exists', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '1.0.1']);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('1.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be read', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
$GLOBALS['version_set_file_get_contents_false'] = true;
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
$GLOBALS['version_set_json_encode_false'] = true;
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user