Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
94
tests/FakesPireon.php
Normal file
94
tests/FakesPireon.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Fakes the Pireon identity server so tests exercise the real OAuth code paths
|
||||
* without any network access.
|
||||
*/
|
||||
trait FakesPireon
|
||||
{
|
||||
protected string $pireonServer = 'https://oauth.pireon.test';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $identity Overrides for the GET /api/user payload.
|
||||
* @param array<string, mixed> $token Overrides for the POST /oauth/token payload.
|
||||
*/
|
||||
protected function fakePireon(array $identity = [], array $token = []): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response([
|
||||
'access_token' => 'fake-access-token',
|
||||
'refresh_token' => 'fake-refresh-token',
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'scope' => 'account:read',
|
||||
...$token,
|
||||
]),
|
||||
|
||||
"{$this->pireonServer}/api/user" => Http::response([
|
||||
'id' => 4242,
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'is_active' => true,
|
||||
'roles' => ['user'],
|
||||
'app_roles' => ['admin'],
|
||||
'approved_at' => '2026-01-01T00:00:00+00:00',
|
||||
'created_at' => '2025-12-01T00:00:00+00:00',
|
||||
...$identity,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the identity server reject whatever token it is handed.
|
||||
*/
|
||||
protected function fakePireonRejectingTokens(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/api/user" => Http::response(['message' => 'Unauthenticated.'], 401),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A local user holding the given roles, as if they had already signed in.
|
||||
*
|
||||
* @param array<int, string>|string $roles
|
||||
*/
|
||||
protected function userWithRoles(array|string $roles, array $attributes = []): User
|
||||
{
|
||||
$user = User::factory()->create($attributes);
|
||||
$user->syncRoles((array) $roles);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate API requests as $user by faking the upstream introspection
|
||||
* call that the "api" guard performs.
|
||||
*/
|
||||
protected function apiHeadersFor(User $user): array
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/api/user" => Http::response([
|
||||
'id' => $user->oauth_id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'is_active' => true,
|
||||
'roles' => ['user'],
|
||||
'app_roles' => [],
|
||||
'approved_at' => '2026-01-01T00:00:00+00:00',
|
||||
]),
|
||||
]);
|
||||
|
||||
return [
|
||||
'Authorization' => 'Bearer token-for-'.$user->oauth_id,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
}
|
||||
115
tests/Feature/Api/ApiDocsTest.php
Normal file
115
tests/Feature/Api/ApiDocsTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The browsable API documentation.
|
||||
*
|
||||
* The specification was always there as a file; what this covers is that it is
|
||||
* readable, that it stays in step with the routes it claims to describe, and
|
||||
* that nothing about it depends on somebody else's CDN.
|
||||
*/
|
||||
class ApiDocsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function the_docs_page_opens_without_signing_in(): void
|
||||
{
|
||||
// The people who need it are writing the public frontend and have no
|
||||
// reason to hold a panel account.
|
||||
$this->get('/docs/api')
|
||||
->assertOk()
|
||||
->assertSee('swagger', escape: false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_points_at_the_installation_being_looked_at(): void
|
||||
{
|
||||
$html = (string) $this->get('/docs/api')->assertOk()->getContent();
|
||||
|
||||
// Not the canonical URL from the configuration: the same page is
|
||||
// opened on a laptop, on staging and in production, and "try it out"
|
||||
// has to reach whichever one it is.
|
||||
$this->assertStringContainsString(json_encode(url('/api')), $html);
|
||||
$this->assertStringContainsString(json_encode(asset('openapi.yaml')), $html);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_carries_its_own_assets(): void
|
||||
{
|
||||
$html = (string) $this->get('/docs/api')->assertOk()->getContent();
|
||||
|
||||
// The docs have to work on a laptop with no internet, and a script tag
|
||||
// pointing at somebody else's server is a dependency this application
|
||||
// does not otherwise have.
|
||||
$this->assertStringNotContainsString('cdn.', $html);
|
||||
$this->assertStringNotContainsString('unpkg', $html);
|
||||
|
||||
foreach (['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js'] as $asset) {
|
||||
$this->assertFileExists(public_path("vendor/swagger-ui/{$asset}"));
|
||||
$this->assertStringContainsString("vendor/swagger-ui/{$asset}", $html);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_specification_parses(): void
|
||||
{
|
||||
$spec = Yaml::parseFile(public_path('openapi.yaml'));
|
||||
|
||||
$this->assertIsArray($spec);
|
||||
$this->assertArrayHasKey('paths', $spec);
|
||||
$this->assertNotEmpty($spec['paths']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path the specification advertises exists as a route.
|
||||
*
|
||||
* Documentation that describes an endpoint nobody implemented is worse
|
||||
* than none: it is believed.
|
||||
*/
|
||||
#[Test]
|
||||
public function every_documented_endpoint_is_routed(): void
|
||||
{
|
||||
$spec = Yaml::parseFile(public_path('openapi.yaml'));
|
||||
|
||||
$routes = collect(app('router')->getRoutes()->getRoutes())
|
||||
->flatMap(fn ($route) => collect($route->methods())
|
||||
->map(fn (string $method) => strtolower($method).' '.$route->uri()))
|
||||
->all();
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($spec['paths'] as $path => $operations) {
|
||||
// The specification is served under an `/api` base URL, and route
|
||||
// parameters are named independently on either side.
|
||||
$uri = 'api'.preg_replace('/\{[^}]+\}/', '{param}', $path);
|
||||
|
||||
// A path item may also carry `parameters`, `summary` and friends;
|
||||
// only the HTTP methods describe an endpoint.
|
||||
$methods = array_intersect(
|
||||
array_keys($operations),
|
||||
['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'],
|
||||
);
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$normalised = collect($routes)
|
||||
->map(fn (string $route) => preg_replace('/\{[^}]+\}/', '{param}', $route))
|
||||
->contains(strtolower($method).' '.$uri);
|
||||
|
||||
if (! $normalised) {
|
||||
$missing[] = strtoupper($method)." {$path}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $missing, "Documented but not routed:\n".implode("\n", $missing));
|
||||
}
|
||||
}
|
||||
84
tests/Feature/Api/ApiTypesTest.php
Normal file
84
tests/Feature/Api/ApiTypesTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The generated types, and the one thing that can go wrong with them.
|
||||
*
|
||||
* A type file is only worth having while it is true. Committed and then left
|
||||
* behind by a change to the specification, it is worse than none: somebody
|
||||
* writes a frontend against an editor that is confidently wrong.
|
||||
*
|
||||
* So the file is checked in — the people who need it should be able to read it
|
||||
* without running anything — and `--check` is what keeps it honest.
|
||||
*/
|
||||
class ApiTypesTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function the_committed_types_match_the_specification(): void
|
||||
{
|
||||
$this->artisan('api:types --check')->assertExitCode(0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_describes_the_public_payloads(): void
|
||||
{
|
||||
$types = (string) file_get_contents(public_path('api-types.d.ts'));
|
||||
|
||||
// The record shapes.
|
||||
$this->assertStringContainsString('export interface PublicProject {', $types);
|
||||
$this->assertStringContainsString('export interface Image {', $types);
|
||||
|
||||
// And the map from an address to what it answers with, which is what
|
||||
// makes a typed client possible without repeating any of this.
|
||||
$this->assertStringContainsString("'/public/v1/projects': PublicProject[];", $types);
|
||||
$this->assertStringContainsString("'/public/v1/projects/{slug}': PublicProject;", $types);
|
||||
}
|
||||
|
||||
/**
|
||||
* `nullable` is how this specification says "may not be there", and the
|
||||
* contract test holds the API to the same reading. The types have to agree
|
||||
* with both or they would be describing a third thing.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_nullable_field_is_optional_and_a_required_one_is_not(): void
|
||||
{
|
||||
$types = (string) file_get_contents(public_path('api-types.d.ts'));
|
||||
|
||||
$this->assertMatchesRegularExpression('/^ id: number;$/m', $types);
|
||||
$this->assertMatchesRegularExpression('/^ tagline\?: string;$/m', $types);
|
||||
}
|
||||
|
||||
/**
|
||||
* Administration schemas are in the same document and deliberately not
|
||||
* here: they are read by callers holding a token, not by the frontend, and
|
||||
* a type file twice the size is one nobody reads.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_leaves_out_what_the_frontend_never_sees(): void
|
||||
{
|
||||
$types = (string) file_get_contents(public_path('api-types.d.ts'));
|
||||
|
||||
$this->assertStringNotContainsString('export interface User', $types);
|
||||
$this->assertStringNotContainsString('export interface Role', $types);
|
||||
$this->assertStringNotContainsString('PasswordGrant', $types);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generating_twice_produces_the_same_file(): void
|
||||
{
|
||||
$before = (string) file_get_contents(public_path('api-types.d.ts'));
|
||||
|
||||
$this->artisan('api:types')->assertExitCode(0);
|
||||
|
||||
// Anything unstable here — a hash map iterated in whatever order, a
|
||||
// timestamp in the header — would show up as a diff on every run and
|
||||
// teach everybody to ignore this file's changes.
|
||||
$this->assertSame($before, (string) file_get_contents(public_path('api-types.d.ts')));
|
||||
}
|
||||
}
|
||||
218
tests/Feature/Api/ContentViewsTest.php
Normal file
218
tests/Feature/Api/ContentViewsTest.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ContentView;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What the frontend read, reported back.
|
||||
*
|
||||
* Two things need holding still. The first is that this stays a tally: one row
|
||||
* per record, per language, per day, however many reports arrive. The second
|
||||
* is what it refuses to collect — the moment a visitor could be reconstructed
|
||||
* out of this table it has become something nobody agreed to run.
|
||||
*/
|
||||
class ContentViewsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected string $token = 'ingest-secret';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['site.telemetry.token' => $this->token]);
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function report(array $payload): TestResponse
|
||||
{
|
||||
return $this->withHeaders(['X-Ingest-Token' => $this->token])
|
||||
->postJson('/api/public/v1/telemetry/views', $payload);
|
||||
}
|
||||
|
||||
protected function page(string $slug = 'quienes-somos'): Page
|
||||
{
|
||||
return Page::factory()->create([
|
||||
'slug' => ['es' => $slug, 'en' => "{$slug}-en", 'ca' => "{$slug}-ca"],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_counts_a_read(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 1)
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
|
||||
$this->assertDatabaseHas('content_views', [
|
||||
'viewable_type' => $page->getMorphClass(),
|
||||
'viewable_id' => $page->id,
|
||||
'locale' => 'es',
|
||||
'count' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property that keeps this from becoming a log.
|
||||
*/
|
||||
#[Test]
|
||||
public function reports_for_the_same_day_add_up_in_one_row(): void
|
||||
{
|
||||
$this->page();
|
||||
|
||||
foreach (range(1, 4) as $ignored) {
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
}
|
||||
|
||||
$this->assertSame(1, ContentView::query()->count());
|
||||
$this->assertSame(4, (int) ContentView::query()->first()->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_batch_can_carry_its_own_counts(): void
|
||||
{
|
||||
$this->page();
|
||||
Project::factory()->create(['slug' => ['es' => 'mi-web', 'en' => 'my-web', 'ca' => 'el-meu-web']]);
|
||||
|
||||
$this->report(['views' => [
|
||||
['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es', 'count' => 12],
|
||||
['type' => 'project', 'slug' => 'mi-web', 'locale' => 'es', 'count' => 3],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 2);
|
||||
|
||||
$this->assertSame(15, (int) ContentView::query()->sum('count'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same record read in two languages is two rows: which language people
|
||||
* read something in is most of what the figures are for.
|
||||
*/
|
||||
#[Test]
|
||||
public function each_language_is_counted_separately(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos-ca', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
$this->assertSame(2, ContentView::query()->count());
|
||||
$this->assertSame(2, $page->viewsSince(30));
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug that no longer resolves is a 404, and the endpoint next door
|
||||
* exists for those. Counting it here would attach a read to nothing.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_slug_that_resolves_to_nothing_is_not_counted(): void
|
||||
{
|
||||
$this->report(['type' => 'page', 'slug' => 'no-existe', 'locale' => 'es'])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.received', 1)
|
||||
->assertJsonPath('data.recorded', 0);
|
||||
|
||||
$this->assertSame(0, ContentView::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_without_the_secret(): void
|
||||
{
|
||||
$this->page();
|
||||
|
||||
$this->postJson('/api/public/v1/telemetry/views', ['type' => 'page', 'slug' => 'quienes-somos'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, ContentView::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_what_it_cannot_use(): void
|
||||
{
|
||||
$this->report(['type' => 'invento', 'slug' => 'x'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('views.0.type');
|
||||
|
||||
// The frontend counts its own renders; it does not get to invent an
|
||||
// audience of any size it likes.
|
||||
$this->page();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'count' => 999999])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('views.0.count');
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole privacy argument for this table, as a test: there is nowhere
|
||||
* to put an address, a session or a time of day, so no future change can
|
||||
* quietly start keeping one.
|
||||
*/
|
||||
#[Test]
|
||||
public function nothing_about_a_visitor_is_stored(): void
|
||||
{
|
||||
$this->page();
|
||||
$this->report(['type' => 'page', 'slug' => 'quienes-somos', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$columns = array_keys((array) ContentView::query()->first()->getAttributes());
|
||||
|
||||
$this->assertSame([
|
||||
'id', 'viewable_type', 'viewable_id', 'locale', 'day', 'count',
|
||||
'created_at', 'updated_at',
|
||||
], $columns);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_listing_figure_comes_from_one_query(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
ContentView::record($page, 'es', 5);
|
||||
ContentView::record($page, 'ca', 2);
|
||||
|
||||
$row = Page::query()->withRecentViews()->find($page->id);
|
||||
|
||||
$this->assertSame(7, (int) $row->recent_views);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function figures_older_than_the_window_do_not_count_as_recent(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$page->views()->create([
|
||||
'locale' => 'es',
|
||||
'day' => now()->subDays(90)->toDateString(),
|
||||
'count' => 40,
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $page->viewsSince(30));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pruning_forgets_figures_nobody_compares_against(): void
|
||||
{
|
||||
$page = $this->page();
|
||||
|
||||
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(500)->toDateString(), 'count' => 9]);
|
||||
$page->views()->create(['locale' => 'es', 'day' => now()->subDays(10)->toDateString(), 'count' => 3]);
|
||||
|
||||
$this->artisan('content:prune')->assertExitCode(0);
|
||||
|
||||
$this->assertSame(1, ContentView::query()->count());
|
||||
$this->assertSame(3, (int) ContentView::query()->first()->count);
|
||||
}
|
||||
}
|
||||
268
tests/Feature/Api/PageApiTest.php
Normal file
268
tests/Feature/Api/PageApiTest.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Page;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PageApiTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_a_request_without_a_token(): void
|
||||
{
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_a_token_the_identity_server_does_not_recognise(): void
|
||||
{
|
||||
$this->fakePireonRejectingTokens();
|
||||
|
||||
$this->getJson('/api/v1/pages', ['Authorization' => 'Bearer revoked-token'])
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_resolves_the_caller_and_their_local_permissions(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
$response = $this->getJson('/api/v1/me', $this->apiHeadersFor($user));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.email', $user->email)
|
||||
->assertJsonPath('data.roles.0', 'editor');
|
||||
|
||||
// Permissions come from this app, not from the identity server.
|
||||
$this->assertContains('pages.create', $response->json('data.permissions'));
|
||||
$this->assertNotContains('roles.create', $response->json('data.permissions'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_locally_deactivated_user_is_denied_even_with_a_valid_token(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor', ['is_active' => false]);
|
||||
|
||||
$this->getJson('/api/v1/pages', $this->apiHeadersFor($user))
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_content_in_the_requested_locale(): void
|
||||
{
|
||||
// No stored locale preference, so Accept-Language is free to decide
|
||||
// when no explicit ?lang= is given.
|
||||
$user = $this->userWithRoles('editor', ['locale' => null]);
|
||||
|
||||
Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
|
||||
'slug' => ['es' => 'hola', 'en' => 'hello', 'ca' => 'hola-ca'],
|
||||
]);
|
||||
|
||||
$headers = $this->apiHeadersFor($user);
|
||||
|
||||
$this->getJson('/api/v1/pages?lang=en', $headers)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Hello')
|
||||
->assertJsonPath('data.0.locale', 'en');
|
||||
|
||||
$this->getJson('/api/v1/pages?lang=ca', $headers)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Hola-ca');
|
||||
|
||||
// Accept-Language is honoured when no explicit ?lang= is given.
|
||||
$this->getJson('/api/v1/pages', $headers + ['Accept-Language' => 'en-GB,en;q=0.9'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Hello')
|
||||
->assertHeader('Content-Language', 'en');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_can_return_every_translation_on_request(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Hola', 'en' => 'Hello', 'ca' => 'Hola-ca'],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/pages?with=translations', $this->apiHeadersFor($user))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.translations.title.es', 'Hola')
|
||||
->assertJsonPath('data.0.translations.title.en', 'Hello')
|
||||
->assertJsonPath('data.0.translations.title.ca', 'Hola-ca');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_falls_back_to_the_default_locale_for_a_missing_translation(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Solo español'],
|
||||
'slug' => ['es' => 'solo-espanol'],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/pages?lang=ca', $this->apiHeadersFor($user))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Solo español');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_can_create_a_page_with_several_translations(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Nueva página', 'en' => 'New page'],
|
||||
'slug' => ['es' => 'nueva-pagina', 'en' => 'new-page'],
|
||||
'category_id' => $category->id,
|
||||
'status' => 'draft',
|
||||
], $this->apiHeadersFor($user));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$page = Page::firstWhere('id', $response->json('data.id'));
|
||||
|
||||
$this->assertSame('Nueva página', $page->getTranslation('title', 'es'));
|
||||
$this->assertSame('New page', $page->getTranslation('title', 'en'));
|
||||
$this->assertSame($user->id, $page->author_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_partial_update_does_not_wipe_other_languages(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
$page = Page::factory()->draft()->create([
|
||||
'title' => ['es' => 'Original', 'en' => 'Original EN'],
|
||||
'slug' => ['es' => 'original', 'en' => 'original-en'],
|
||||
'author_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->patchJson("/api/v1/pages/{$page->id}", [
|
||||
'title' => ['ca' => 'Nou títol'],
|
||||
], $this->apiHeadersFor($user))->assertOk();
|
||||
|
||||
$page->refresh();
|
||||
|
||||
$this->assertSame('Nou títol', $page->getTranslation('title', 'ca'));
|
||||
$this->assertSame('Original', $page->getTranslation('title', 'es'));
|
||||
$this->assertSame('Original EN', $page->getTranslation('title', 'en'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_a_slug_already_used_in_the_same_locale(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
Page::factory()->create(['slug' => ['es' => 'repetido']]);
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Otra'],
|
||||
'slug' => ['es' => 'repetido'],
|
||||
], $this->apiHeadersFor($user))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('slug');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_same_slug_is_allowed_in_a_different_locale(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
Page::factory()->create(['slug' => ['es' => 'compartido']]);
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Otra'],
|
||||
'slug' => ['es' => 'distinto', 'en' => 'compartido'],
|
||||
], $this->apiHeadersFor($user))->assertCreated();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_an_unsupported_locale_key(): void
|
||||
{
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Título', 'de' => 'Titel'],
|
||||
'slug' => ['es' => 'titulo'],
|
||||
], $this->apiHeadersFor($user))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('title');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_cannot_create_a_page(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer');
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'No permitido'],
|
||||
'slug' => ['es' => 'no-permitido'],
|
||||
], $this->apiHeadersFor($user))->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_only_sees_published_pages(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer');
|
||||
|
||||
Page::factory()->published()->create();
|
||||
Page::factory()->draft()->create();
|
||||
|
||||
$response = $this->getJson('/api/v1/pages', $this->apiHeadersFor($user))->assertOk();
|
||||
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertSame(PageStatus::Published->value, $response->json('data.0.status'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_requires_its_own_permission(): void
|
||||
{
|
||||
// An editor who may write content but has had publish revoked.
|
||||
$user = $this->userWithRoles('editor');
|
||||
$user->revokePermissionTo('pages.publish');
|
||||
$user->roles()->first()->revokePermissionTo('pages.publish');
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Intento'],
|
||||
'slug' => ['es' => 'intento'],
|
||||
'status' => 'published',
|
||||
], $this->apiHeadersFor($user))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('status');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_super_admin_bypasses_every_permission_check(): void
|
||||
{
|
||||
$user = $this->userWithRoles('super_admin');
|
||||
|
||||
$this->assertSame(0, $user->roles()->first()->permissions()->count());
|
||||
|
||||
$this->postJson('/api/v1/pages', [
|
||||
'title' => ['es' => 'Puedo todo'],
|
||||
'slug' => ['es' => 'puedo-todo'],
|
||||
'status' => 'published',
|
||||
], $this->apiHeadersFor($user))->assertCreated();
|
||||
}
|
||||
}
|
||||
275
tests/Feature/Api/PublicApiContractTest.php
Normal file
275
tests/Feature/Api/PublicApiContractTest.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Technology;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The public API answers with what the specification says it answers with.
|
||||
*
|
||||
* `ApiDocsTest` already checks that every documented path is routed. This is
|
||||
* the other half, and the one that matters more now: that the *shape* the
|
||||
* frontend was promised is the shape it gets.
|
||||
*
|
||||
* The failure this exists to prevent has no symptom on this side. Renaming a
|
||||
* field in a `PublicApi/*Resource` is a one-line change that passes every
|
||||
* other test in this suite, ships, and breaks a page on another machine in
|
||||
* another repository — written against a specification that still describes
|
||||
* the old name. With the frontend framework still undecided, whoever writes it
|
||||
* will be reading that document rather than this code, so it has to be true.
|
||||
*/
|
||||
class PublicApiContractTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $spec;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
|
||||
$this->spec = (array) Yaml::parseFile(public_path('openapi.yaml'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough of everything for each endpoint to answer with a full record
|
||||
* rather than an empty list, which would prove nothing at all.
|
||||
*/
|
||||
protected function seedTheSite(): void
|
||||
{
|
||||
$client = Client::factory()->create();
|
||||
|
||||
$project = Project::factory()->published()->create([
|
||||
'client_id' => $client->id,
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
]);
|
||||
|
||||
Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Quiénes somos'],
|
||||
'slug' => ['es' => 'quienes-somos'],
|
||||
]);
|
||||
|
||||
Member::factory()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'slug' => 'ada-lovelace',
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
Call::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'title' => ['es' => 'Backend'],
|
||||
'slug' => ['es' => 'backend'],
|
||||
]);
|
||||
|
||||
Service::factory()->create([
|
||||
'title' => ['es' => 'Desarrollo'],
|
||||
'slug' => ['es' => 'desarrollo'],
|
||||
]);
|
||||
|
||||
Testimonial::factory()->create();
|
||||
Talk::factory()->create();
|
||||
|
||||
// Attached to the project on purpose: `/technologies` lists only what
|
||||
// is actually in use, so a loose one would leave that endpoint with
|
||||
// nothing to compare against.
|
||||
$project->technologies()->attach(Technology::factory()->create());
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint => whether it answers with one whole record.
|
||||
*
|
||||
* A detail endpoint returns the full shape, so every documented field has
|
||||
* to be there. A listing legitimately leaves out the heavy ones — the
|
||||
* specification says as much, field by field — so it is only held to the
|
||||
* rule that everything it does send is documented.
|
||||
*
|
||||
* @return array<string, array{path: string, detail: bool}>
|
||||
*/
|
||||
public static function endpoints(): array
|
||||
{
|
||||
return [
|
||||
'projects' => ['path' => '/public/v1/projects', 'detail' => false],
|
||||
'project' => ['path' => '/public/v1/projects/{slug}', 'detail' => true],
|
||||
'pages' => ['path' => '/public/v1/pages', 'detail' => false],
|
||||
'page' => ['path' => '/public/v1/pages/{slug}', 'detail' => true],
|
||||
'members' => ['path' => '/public/v1/members', 'detail' => false],
|
||||
'member' => ['path' => '/public/v1/members/{slug}', 'detail' => true],
|
||||
'calls' => ['path' => '/public/v1/calls', 'detail' => false],
|
||||
'call' => ['path' => '/public/v1/calls/{slug}', 'detail' => true],
|
||||
'services' => ['path' => '/public/v1/services', 'detail' => false],
|
||||
'service' => ['path' => '/public/v1/services/{slug}', 'detail' => true],
|
||||
'clients' => ['path' => '/public/v1/clients', 'detail' => false],
|
||||
'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false],
|
||||
'talks' => ['path' => '/public/v1/talks', 'detail' => false],
|
||||
'technologies' => ['path' => '/public/v1/technologies', 'detail' => false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The URLs the seeded records actually live at.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function slugs(): array
|
||||
{
|
||||
return [
|
||||
'/public/v1/projects/{slug}' => '/public/v1/projects/mi-proyecto',
|
||||
'/public/v1/pages/{slug}' => '/public/v1/pages/quienes-somos',
|
||||
'/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace',
|
||||
'/public/v1/calls/{slug}' => '/public/v1/calls/backend',
|
||||
'/public/v1/services/{slug}' => '/public/v1/services/desarrollo',
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_public_endpoint_answers_in_the_shape_it_documents(): void
|
||||
{
|
||||
$this->seedTheSite();
|
||||
|
||||
$problems = [];
|
||||
|
||||
foreach (self::endpoints() as $name => $endpoint) {
|
||||
$url = $this->slugs()[$endpoint['path']] ?? $endpoint['path'];
|
||||
|
||||
$response = $this->getJson('/api'.$url);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
$problems[] = "{$name}: answered {$response->status()}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $response->json('data');
|
||||
|
||||
// A listing that came back empty would pass every check below
|
||||
// without having compared anything.
|
||||
$record = array_is_list((array) $payload) ? ($payload[0] ?? null) : $payload;
|
||||
|
||||
if (! is_array($record)) {
|
||||
$problems[] = "{$name}: no record to compare against";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$documented = $this->documentedKeys($endpoint['path']);
|
||||
|
||||
foreach (array_diff(array_keys($record), $documented) as $undocumented) {
|
||||
$problems[] = "{$name}: sends `{$undocumented}`, which the specification does not describe";
|
||||
}
|
||||
|
||||
if ($endpoint['detail']) {
|
||||
foreach (array_diff($this->alwaysPresent($endpoint['path']), array_keys($record)) as $missing) {
|
||||
$problems[] = "{$name}: documents `{$missing}`, which it does not send";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $problems, "The public API and its specification disagree:\n".implode("\n", $problems));
|
||||
}
|
||||
|
||||
/**
|
||||
* The field names the specification promises for one endpoint's records.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function documentedKeys(string $path): array
|
||||
{
|
||||
return array_keys($this->recordSchema($path)['properties'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of a single record, whichever way the endpoint wraps it.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function recordSchema(string $path): array
|
||||
{
|
||||
$schema = $this->resolve(
|
||||
$this->spec['paths'][$path]['get']['responses'][200]['content']['application/json']['schema'] ?? [],
|
||||
);
|
||||
|
||||
$data = $this->resolve($schema['properties']['data'] ?? []);
|
||||
|
||||
// A listing documents its records under `items`; a detail endpoint
|
||||
// describes them directly.
|
||||
if (($data['type'] ?? null) === 'array') {
|
||||
$data = $this->resolve($data['items'] ?? []);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented fields a detail endpoint must always answer with.
|
||||
*
|
||||
* Anything the specification marks `nullable` is left out of this half of
|
||||
* the check. That is the vocabulary this document already uses for a field
|
||||
* that may not be there — a member's `role` exists only while they are
|
||||
* nested under a project, and the schema says so. The other direction
|
||||
* stays strict for every field either way: whatever the API sends has to
|
||||
* be described, nullable or not.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function alwaysPresent(string $path): array
|
||||
{
|
||||
$record = $this->recordSchema($path);
|
||||
|
||||
return array_keys(array_filter(
|
||||
(array) ($record['properties'] ?? []),
|
||||
fn (array $property) => ($property['nullable'] ?? false) !== true,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow `$ref` and flatten `allOf`, which is how the specification says
|
||||
* "this, plus a couple of fields" without repeating a schema.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function resolve(array $schema): array
|
||||
{
|
||||
if (isset($schema['$ref'])) {
|
||||
$name = basename((string) $schema['$ref']);
|
||||
|
||||
return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? []));
|
||||
}
|
||||
|
||||
if (isset($schema['allOf'])) {
|
||||
$merged = ['type' => 'object', 'properties' => []];
|
||||
|
||||
foreach ((array) $schema['allOf'] as $part) {
|
||||
$resolved = $this->resolve((array) $part);
|
||||
$merged['properties'] = [
|
||||
...$merged['properties'],
|
||||
...($resolved['properties'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return [...$schema, ...$merged];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
293
tests/Feature/Api/PublicApiTest.php
Normal file
293
tests/Feature/Api/PublicApiTest.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Repository;
|
||||
use App\Models\Technology;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The contract the public frontend depends on.
|
||||
*/
|
||||
class PublicApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Symfony's Request::create() injects "Accept-Language: en-us,en;q=0.5"
|
||||
// by default, and the API honours content negotiation — so without
|
||||
// this every test would silently be asking for English. Real callers
|
||||
// send their own header or ?lang=; these tests say so explicitly.
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_serves_published_projects_without_any_token(): void
|
||||
{
|
||||
Project::factory()->published()->create();
|
||||
Project::factory()->draft()->create();
|
||||
|
||||
$response = $this->getJson('/api/public/v1/projects')->assertOk();
|
||||
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_resolves_a_project_by_slug_within_the_active_locale(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
|
||||
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'Mi proyecto');
|
||||
|
||||
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'El meu projecte');
|
||||
|
||||
// A Catalan slug must not resolve while the Spanish locale is active:
|
||||
// that would serve one URL under two addresses.
|
||||
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_returns_hreflang_alternates_for_every_translated_locale(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'],
|
||||
'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'],
|
||||
]);
|
||||
|
||||
$alternates = $this->getJson('/api/public/v1/projects/uno')
|
||||
->assertOk()
|
||||
->json('data.alternates');
|
||||
|
||||
// The default locale carries no prefix; the others do, each with its
|
||||
// own translated path segment.
|
||||
$this->assertSame('/proyectos/uno', $alternates['es']);
|
||||
$this->assertSame('/en/projects/one', $alternates['en']);
|
||||
$this->assertSame('/ca/projectes/u', $alternates['ca']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_locale_without_a_translation_gets_no_alternate(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Solo español'],
|
||||
'slug' => ['es' => 'solo-espanol'],
|
||||
]);
|
||||
|
||||
$alternates = $this->getJson('/api/public/v1/projects/solo-espanol')
|
||||
->assertOk()
|
||||
->json('data.alternates');
|
||||
|
||||
// Emitting an alternate here would advertise a URL that 404s.
|
||||
$this->assertSame(['es'], array_keys($alternates));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listings_do_not_lazy_load_relations(): void
|
||||
{
|
||||
// Guards the N+1 that strict mode caught during development: the
|
||||
// needs_help flag must come from a count, not from the relation.
|
||||
$project = Project::factory()->published()->create(['needs_help' => false]);
|
||||
Call::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->getJson('/api/public/v1/projects')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.needs_help', true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unpublished_content_is_hidden_unless_the_preview_secret_is_presented(): void
|
||||
{
|
||||
config()->set('site.preview_token', 'the-secret');
|
||||
|
||||
Project::factory()->published()->create();
|
||||
Project::factory()->draft()->create();
|
||||
|
||||
$this->assertCount(1, $this->getJson('/api/public/v1/projects')->json('data'));
|
||||
|
||||
$this->assertCount(2, $this->getJson('/api/public/v1/projects', [
|
||||
'X-Preview-Token' => 'the-secret',
|
||||
])->json('data'));
|
||||
|
||||
$this->assertCount(1, $this->getJson('/api/public/v1/projects', [
|
||||
'X-Preview-Token' => 'wrong',
|
||||
])->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_unset_preview_secret_never_grants_access(): void
|
||||
{
|
||||
config()->set('site.preview_token', null);
|
||||
|
||||
Project::factory()->draft()->create();
|
||||
|
||||
$this->assertCount(0, $this->getJson('/api/public/v1/projects', [
|
||||
'X-Preview-Token' => '',
|
||||
])->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preview_responses_are_never_cached(): void
|
||||
{
|
||||
config()->set('site.preview_token', 'the-secret');
|
||||
|
||||
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function public_responses_carry_caching_headers_and_answer_304(): void
|
||||
{
|
||||
Project::factory()->published()->create();
|
||||
|
||||
$response = $this->getJson('/api/public/v1/projects')->assertOk();
|
||||
|
||||
$etag = $response->headers->get('ETag');
|
||||
$this->assertNotNull($etag);
|
||||
$this->assertStringContainsString('s-maxage', $response->headers->get('Cache-Control'));
|
||||
$this->assertStringContainsString('stale-while-revalidate', $response->headers->get('Cache-Control'));
|
||||
|
||||
// Language is negotiated, so a shared cache must vary on it.
|
||||
$this->assertStringContainsString('Accept-Language', $response->headers->get('Vary'));
|
||||
|
||||
$this->getJson('/api/public/v1/projects', ['If-None-Match' => $etag])
|
||||
->assertStatus(304);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_open_call_states_the_time_it_asks_for(): void
|
||||
{
|
||||
Call::factory()->create([
|
||||
'slug' => ['es' => 'frontend'],
|
||||
'hours_per_week' => 4,
|
||||
'duration_weeks' => 8,
|
||||
'project_id' => null,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/calls/frontend')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.hours_per_week', 4)
|
||||
->assertJsonPath('data.duration_weeks', 8)
|
||||
->assertJsonPath('data.total_hours', 32);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_call_never_leaks_an_unpublished_project(): void
|
||||
{
|
||||
$hidden = Project::factory()->draft()->create();
|
||||
Call::factory()->create(['project_id' => $hidden->id]);
|
||||
Call::factory()->create(['project_id' => null]);
|
||||
|
||||
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function closed_calls_are_not_listed(): void
|
||||
{
|
||||
Call::factory()->create(['project_id' => null]);
|
||||
Call::factory()->closed()->create(['project_id' => null]);
|
||||
Call::factory()->create(['project_id' => null, 'closes_at' => now()->subDay()]);
|
||||
|
||||
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function members_hide_the_non_public_and_the_departed(): void
|
||||
{
|
||||
Member::factory()->create(['name' => 'Visible']);
|
||||
Member::factory()->hidden()->create();
|
||||
Member::factory()->past()->create();
|
||||
|
||||
$data = $this->getJson('/api/public/v1/members')->assertOk()->json('data');
|
||||
|
||||
$this->assertCount(1, $data);
|
||||
$this->assertSame('Visible', $data[0]['name']);
|
||||
|
||||
// Alumni are still reachable when the frontend asks for them.
|
||||
$this->assertCount(2, $this->getJson('/api/public/v1/members?include_past=1')->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_radar_only_lists_placed_technologies(): void
|
||||
{
|
||||
Technology::factory()->count(3)->create();
|
||||
Technology::factory()->count(2)->offRadar()->create();
|
||||
|
||||
$this->assertCount(3, $this->getJson('/api/public/v1/radar')->json('data'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function good_first_issues_exclude_claimed_and_hidden_ones(): void
|
||||
{
|
||||
$visible = Repository::factory()->create();
|
||||
$hidden = Repository::factory()->hidden()->create();
|
||||
|
||||
$visible->issues()->create([
|
||||
'number' => 1, 'title' => 'Free to take',
|
||||
'html_url' => 'https://example.test/1',
|
||||
'is_good_first_issue' => true, 'assignee' => null,
|
||||
]);
|
||||
|
||||
$visible->issues()->create([
|
||||
'number' => 2, 'title' => 'Already claimed',
|
||||
'html_url' => 'https://example.test/2',
|
||||
'is_good_first_issue' => true, 'assignee' => 'someone',
|
||||
]);
|
||||
|
||||
$hidden->issues()->create([
|
||||
'number' => 1, 'title' => 'In a hidden repo',
|
||||
'html_url' => 'https://example.test/3',
|
||||
'is_good_first_issue' => true, 'assignee' => null,
|
||||
]);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/good-first-issues')->assertOk()->json('data');
|
||||
|
||||
$this->assertCount(1, $data);
|
||||
$this->assertSame('Free to take', $data[0]['title']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_slugs_endpoint_lists_every_public_url(): void
|
||||
{
|
||||
Project::factory()->published()->create(['slug' => ['es' => 'proyecto', 'ca' => 'projecte']]);
|
||||
Member::factory()->create(['slug' => 'ada']);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
|
||||
|
||||
$this->assertSame('/proyectos/proyecto', $data['projects'][0]['alternates']['es']);
|
||||
$this->assertSame('/ca/projectes/projecte', $data['projects'][0]['alternates']['ca']);
|
||||
$this->assertSame('/equipo/ada', $data['members'][0]['alternates']['es']);
|
||||
$this->assertSame('/ca/equip/ada', $data['members'][0]['alternates']['ca']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_site_endpoint_reports_locales_and_headline_numbers(): void
|
||||
{
|
||||
Project::factory()->published()->active()->create();
|
||||
Member::factory()->create();
|
||||
|
||||
$this->getJson('/api/public/v1/site')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.default_locale', 'es')
|
||||
->assertJsonPath('data.stats.projects', 1)
|
||||
->assertJsonPath('data.stats.active_projects', 1)
|
||||
->assertJsonPath('data.stats.members', 1)
|
||||
->assertJsonCount(3, 'data.locales');
|
||||
}
|
||||
}
|
||||
178
tests/Feature/Api/PublicImagesTest.php
Normal file
178
tests/Feature/Api/PublicImagesTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What the frontend receives for an image.
|
||||
*
|
||||
* A path was never enough: rendering one properly server-side needs its size,
|
||||
* narrower versions of it, and a description. All of that is known here, so
|
||||
* all of it is sent.
|
||||
*/
|
||||
class PublicImagesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
|
||||
// These tests are about the shape of the response, not about GD.
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
protected function project(array $attributes = []): Project
|
||||
{
|
||||
return Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
'cover_image' => 'projects/cover.jpg',
|
||||
'cover_image_alt' => ['es' => 'Captura del panel de la asociación'],
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function markProcessed(string $path = 'projects/cover.jpg'): Media
|
||||
{
|
||||
$media = Media::query()->where('path', $path)->sole();
|
||||
|
||||
$media->forceFill([
|
||||
'mime' => 'image/jpeg',
|
||||
'width' => 1600,
|
||||
'height' => 900,
|
||||
'placeholder' => 'data:image/webp;base64,UklGRhoAAABXRUJQ',
|
||||
'variants' => [
|
||||
['format' => 'webp', 'width' => 400, 'path' => 'projects/cover-400.webp', 'size' => 9000],
|
||||
['format' => 'webp', 'width' => 800, 'path' => 'projects/cover-800.webp', 'size' => 30000],
|
||||
],
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_arrives_as_an_object_with_everything_needed_to_render_it(): void
|
||||
{
|
||||
$this->project();
|
||||
$this->markProcessed();
|
||||
|
||||
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.cover_image');
|
||||
|
||||
// Absolute, because the frontend is another application on another
|
||||
// host and a path only this app can resolve is no use to it.
|
||||
$this->assertStringStartsWith('http', $image['url']);
|
||||
|
||||
// The pair that stops the page reflowing as images load.
|
||||
$this->assertSame(1600, $image['width']);
|
||||
$this->assertSame(900, $image['height']);
|
||||
$this->assertSame(1.7778, $image['aspect_ratio']);
|
||||
|
||||
$this->assertSame('Captura del panel de la asociación', $image['alt']);
|
||||
$this->assertStringStartsWith('data:image/', $image['placeholder']);
|
||||
$this->assertStringContainsString('400w', $image['srcset']['webp']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_description_follows_the_language_being_read(): void
|
||||
{
|
||||
$this->project([
|
||||
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
|
||||
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
|
||||
'cover_image_alt' => ['es' => 'Captura del panel', 'ca' => 'Captura del tauler'],
|
||||
]);
|
||||
$this->markProcessed();
|
||||
|
||||
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image.alt', 'Captura del tauler');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_with_no_description_says_so_rather_than_omitting_it(): void
|
||||
{
|
||||
$this->project(['cover_image_alt' => null]);
|
||||
$this->markProcessed();
|
||||
|
||||
// `alt=""` is how a page says "this is decorative". A missing key
|
||||
// would leave the frontend guessing.
|
||||
$this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image.alt', '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_still_renders_before_its_versions_are_ready(): void
|
||||
{
|
||||
$this->project();
|
||||
|
||||
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.cover_image');
|
||||
|
||||
// Degrades into a plain <img> instead of a hole in the page.
|
||||
$this->assertStringStartsWith('http', $image['url']);
|
||||
$this->assertNull($image['width']);
|
||||
$this->assertNull($image['srcset']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_record_without_an_image_returns_null(): void
|
||||
{
|
||||
$this->project(['cover_image' => null, 'cover_image_alt' => null]);
|
||||
|
||||
$this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image', null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_members_portrait_is_described_by_their_name(): void
|
||||
{
|
||||
Member::factory()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'slug' => 'ada',
|
||||
'avatar' => 'members/ada.jpg',
|
||||
]);
|
||||
|
||||
// Nothing to type, so nothing to get wrong.
|
||||
$this->getJson('/api/public/v1/members/ada')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.avatar.alt', 'Ada Lovelace');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_listing_does_not_cost_a_query_per_image(): void
|
||||
{
|
||||
Project::factory()->count(5)->published()->sequence(fn ($sequence) => [
|
||||
'cover_image' => "projects/cover-{$sequence->index}.jpg",
|
||||
])->create();
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
$this->getJson('/api/public/v1/projects')->assertOk();
|
||||
|
||||
$queries = collect(DB::getQueryLog())
|
||||
->filter(fn (array $q) => str_contains($q['query'], 'from "media"'));
|
||||
|
||||
DB::disableQueryLog();
|
||||
|
||||
// One eager load for the whole page, not one lookup per row.
|
||||
$this->assertLessThanOrEqual(1, $queries->count());
|
||||
}
|
||||
}
|
||||
269
tests/Feature/Api/ShowcaseApiTest.php
Normal file
269
tests/Feature/Api/ShowcaseApiTest.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The pieces a landing page is made of.
|
||||
*
|
||||
* Four collections, no "home page" payload and no ordering of sections
|
||||
* anywhere: what gets composed, and in what order, is the frontend's business.
|
||||
*/
|
||||
class ShowcaseApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_lists_published_services_in_the_order_they_were_arranged(): void
|
||||
{
|
||||
Service::factory()->create(['title' => ['es' => 'Segundo'], 'slug' => ['es' => 'segundo'], 'position' => 2]);
|
||||
Service::factory()->create(['title' => ['es' => 'Primero'], 'slug' => ['es' => 'primero'], 'position' => 1]);
|
||||
Service::factory()->draft()->create(['title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador']]);
|
||||
|
||||
$titles = $this->getJson('/api/public/v1/services')
|
||||
->assertOk()
|
||||
->json('data.*.title');
|
||||
|
||||
$this->assertSame(['Primero', 'Segundo'], $titles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_service_resolves_by_slug_within_its_language(): void
|
||||
{
|
||||
Service::factory()->create([
|
||||
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
|
||||
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/services/desarrollo')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'Desarrollo');
|
||||
|
||||
$this->getJson('/api/public/v1/services/desenvolupament?lang=ca')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.title', 'Desenvolupament');
|
||||
|
||||
// A Catalan slug must not answer in Spanish, exactly as everywhere
|
||||
// else in this API.
|
||||
$this->getJson('/api/public/v1/services/desenvolupament')->assertNotFound();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_service_carries_the_work_that_backs_it(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'],
|
||||
]);
|
||||
|
||||
$published = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Web de la asociación'], 'slug' => ['es' => 'web'],
|
||||
]);
|
||||
$draft = Project::factory()->draft()->create([
|
||||
'title' => ['es' => 'A medias'], 'slug' => ['es' => 'a-medias'],
|
||||
]);
|
||||
|
||||
$service->projects()->attach([$published->id, $draft->id]);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/services/desarrollo')->assertOk()->json('data');
|
||||
|
||||
// Without real work behind it a service is a promise — but an
|
||||
// unpublished project is not something the public may see.
|
||||
$this->assertCount(1, $data['projects']);
|
||||
$this->assertSame('Web de la asociación', $data['projects'][0]['title']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_draft_service_is_reachable_only_with_the_preview_secret(): void
|
||||
{
|
||||
config()->set('site.preview_token', 'secreto');
|
||||
|
||||
Service::factory()->draft()->create([
|
||||
'title' => ['es' => 'Sin publicar'], 'slug' => ['es' => 'sin-publicar'],
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/services/sin-publicar')->assertNotFound();
|
||||
|
||||
$this->withHeaders(['X-Preview-Token' => 'secreto'])
|
||||
->getJson('/api/public/v1/services/sin-publicar')
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_wall_of_logos_leaves_out_whoever_asked_to_be_left_out(): void
|
||||
{
|
||||
$shy = Client::factory()->private()->create(['name' => 'Discreta']);
|
||||
$shown = Client::factory()->create(['name' => 'Visible']);
|
||||
|
||||
Project::factory()->published()->create(['client_id' => $shy->id]);
|
||||
Project::factory()->published()->create(['client_id' => $shown->id]);
|
||||
|
||||
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
|
||||
|
||||
// Appearing on somebody else's website is their call, not ours.
|
||||
$this->assertSame(['Visible'], $names);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_client_with_nothing_published_is_not_a_logo_the_site_can_justify(): void
|
||||
{
|
||||
$withWork = Client::factory()->create(['name' => 'Con obra']);
|
||||
Project::factory()->published()->create(['client_id' => $withWork->id]);
|
||||
|
||||
$withoutWork = Client::factory()->create(['name' => 'Sin obra']);
|
||||
Project::factory()->draft()->create(['client_id' => $withoutWork->id]);
|
||||
|
||||
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
|
||||
|
||||
$this->assertSame(['Con obra'], $names);
|
||||
|
||||
// Unless the frontend asks for all of them anyway.
|
||||
$all = $this->getJson('/api/public/v1/clients?with_projects=0')->assertOk()->json('data.*.name');
|
||||
$this->assertCount(2, $all);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_translated_quote_says_that_it_was_translated(): void
|
||||
{
|
||||
Testimonial::factory()->create([
|
||||
'quote' => ['es' => 'Nos cambiaron la forma de trabajar', 'en' => 'They changed how we work'],
|
||||
'original_locale' => 'es',
|
||||
'author_name' => 'Marta',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/testimonials')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.is_original', true)
|
||||
->assertJsonPath('data.0.original_locale', 'es');
|
||||
|
||||
// Read in English these are our words, not hers, and the frontend is
|
||||
// told so it can say "translated from Spanish" rather than pretend.
|
||||
$this->withHeaders(['Accept-Language' => 'en'])
|
||||
->getJson('/api/public/v1/testimonials')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.is_original', false)
|
||||
->assertJsonPath('data.0.quote', 'They changed how we work');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function testimonials_can_be_asked_for_by_what_they_vouch_for(): void
|
||||
{
|
||||
$client = Client::factory()->create(['name' => 'Asociación', 'slug' => 'asociacion']);
|
||||
$service = Service::factory()->create([
|
||||
'title' => ['es' => 'Formación'], 'slug' => ['es' => 'formacion'],
|
||||
]);
|
||||
|
||||
Testimonial::factory()->create(['client_id' => $client->id, 'author_name' => 'De cliente']);
|
||||
Testimonial::factory()->create(['service_id' => $service->id, 'author_name' => 'De servicio']);
|
||||
Testimonial::factory()->create(['author_name' => 'Suelto']);
|
||||
|
||||
// Attached to something rather than to "the home page", so a page asks
|
||||
// for the ones that belong on it.
|
||||
$this->getJson('/api/public/v1/testimonials?client=asociacion')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.author.name', 'De cliente');
|
||||
|
||||
$this->getJson('/api/public/v1/testimonials?service=formacion')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.author.name', 'De servicio');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_private_testimonial_never_leaves_the_panel(): void
|
||||
{
|
||||
Testimonial::factory()->private()->create(['author_name' => 'Sin permiso']);
|
||||
|
||||
$this->getJson('/api/public/v1/testimonials')
|
||||
->assertOk()
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function talks_come_back_newest_first_with_their_speaker(): void
|
||||
{
|
||||
$member = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada']);
|
||||
|
||||
Talk::factory()->create(['title' => 'La vieja', 'held_at' => now()->subYears(2), 'member_id' => $member->id]);
|
||||
Talk::factory()->create(['title' => 'La reciente', 'held_at' => now()->subMonth(), 'member_id' => $member->id]);
|
||||
Talk::factory()->create(['title' => 'Oculta', 'is_public' => false]);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/talks')->assertOk()->json('data');
|
||||
|
||||
$this->assertSame(['La reciente', 'La vieja'], array_column($data, 'title'));
|
||||
$this->assertSame('Ada', $data[0]['speaker']['name']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_talks_title_is_not_translated(): void
|
||||
{
|
||||
Talk::factory()->create([
|
||||
'title' => 'Taming reactive Node.js',
|
||||
'language' => 'en',
|
||||
'summary' => ['es' => 'Sobre streams', 'en' => 'About streams'],
|
||||
]);
|
||||
|
||||
// The title is the name of a talk given once, in one language;
|
||||
// translating it would invent a talk that never happened.
|
||||
$this->getJson('/api/public/v1/talks')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.title', 'Taming reactive Node.js')
|
||||
->assertJsonPath('data.0.language', 'en')
|
||||
->assertJsonPath('data.0.summary', 'Sobre streams');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_project_now_carries_a_client_rather_than_a_typed_name(): void
|
||||
{
|
||||
$client = Client::factory()->create(['name' => 'Cruz Roja', 'slug' => 'cruz-roja']);
|
||||
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Su web'], 'slug' => ['es' => 'su-web'],
|
||||
'client_id' => $client->id,
|
||||
]);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/projects/su-web')->assertOk()->json('data');
|
||||
|
||||
$this->assertSame('Cruz Roja', $data['client']['name']);
|
||||
$this->assertSame('cruz-roja', $data['client']['slug']);
|
||||
|
||||
// The old free-text pair is gone, not renamed.
|
||||
$this->assertArrayNotHasKey('beneficiary', $data);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function services_appear_in_the_url_list_the_frontend_builds_its_sitemap_from(): void
|
||||
{
|
||||
Service::factory()->create([
|
||||
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
|
||||
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
|
||||
]);
|
||||
Service::factory()->draft()->create(['slug' => ['es' => 'borrador']]);
|
||||
|
||||
$services = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data.services');
|
||||
|
||||
$this->assertCount(1, $services);
|
||||
$this->assertSame('/servicios/desarrollo', $services[0]['alternates']['es']);
|
||||
$this->assertSame('/ca/serveis/desenvolupament', $services[0]['alternates']['ca']);
|
||||
}
|
||||
}
|
||||
349
tests/Feature/Api/SubmissionApiTest.php
Normal file
349
tests/Feature/Api/SubmissionApiTest.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ApplicationReceived;
|
||||
use App\Support\Maintenance;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The only endpoints on the public API a stranger can write to.
|
||||
*
|
||||
* Everything here is about the two ways that can go wrong: a real person
|
||||
* losing their message, and a bot filling the table. The tests are written so
|
||||
* that a change which trades one for the other fails.
|
||||
*/
|
||||
class SubmissionApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Symfony's Request::create() injects "Accept-Language: en-us", and
|
||||
// this API honours content negotiation. Ask for Spanish explicitly.
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function payload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.org',
|
||||
'message' => 'Tengo unas horas los fines de semana y me gustaría echar una mano.',
|
||||
'hours_per_week' => 4,
|
||||
'links' => ['github' => 'https://github.com/ada'],
|
||||
'consent' => true,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
protected function openCall(): Call
|
||||
{
|
||||
return Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'title' => ['es' => 'Backend', 'en' => 'Backend', 'ca' => 'Backend'],
|
||||
'slug' => ['es' => 'backend', 'en' => 'backend', 'ca' => 'rerefons'],
|
||||
'opens_at' => now()->subWeek(),
|
||||
'closes_at' => now()->addWeek(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_accepts_an_application_to_an_open_call(): void
|
||||
{
|
||||
$call = $this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', 'new')
|
||||
->assertJsonPath('data.duplicate', false)
|
||||
->assertJsonStructure(['data' => ['id'], 'message']);
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertSame($call->id, $application->call_id);
|
||||
$this->assertSame(ApplicationKind::Contribute, $application->kind);
|
||||
$this->assertSame(ApplicationStatus::New, $application->status);
|
||||
$this->assertSame('ada@example.org', $application->email);
|
||||
$this->assertSame(4, $application->hours_per_week);
|
||||
|
||||
// Snapshotted, so the record still reads correctly once the call is
|
||||
// renamed or deleted.
|
||||
$this->assertSame('Backend', $application->call_title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_an_application_to_a_call_that_is_not_open(): void
|
||||
{
|
||||
Call::factory()->closed()->create([
|
||||
'project_id' => null,
|
||||
'slug' => ['es' => 'cerrada'],
|
||||
]);
|
||||
|
||||
// Taking somebody's time to write a message for a position that is
|
||||
// already gone is worse than showing them no form at all.
|
||||
$this->postJson('/api/public/v1/calls/cerrada/apply', $this->payload())
|
||||
->assertNotFound();
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_requires_the_fields_that_make_a_reply_possible(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', [
|
||||
'name' => 'A',
|
||||
'email' => 'not-an-email',
|
||||
'message' => 'hola',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['name', 'email', 'message', 'consent']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_will_not_store_personal_data_without_consent(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload(['consent' => false]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('consent');
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_honeypot_submission_is_filed_as_spam_rather_than_dropped(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
config('site.submissions.honeypot') => 'http://buy-cheap-things.example',
|
||||
]))->assertCreated()
|
||||
// The bot is told nothing: an obvious rejection is a hint to try
|
||||
// again differently.
|
||||
->assertJsonPath('data.status', 'new');
|
||||
|
||||
// Nothing is thrown away. The honeypot is a heuristic, and a browser
|
||||
// extension can trip it for a real person.
|
||||
$this->assertSame(ApplicationStatus::Spam, Application::sole()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_repeat_submission_is_treated_as_the_same_one(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// A double-clicked submit button is not a second person, and an error
|
||||
// would only make the visitor try harder.
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertOk()
|
||||
->assertJsonPath('data.duplicate', true);
|
||||
|
||||
$this->assertSame(1, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_same_person_may_still_write_about_something_else(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/contact', $this->payload())->assertCreated();
|
||||
|
||||
$this->assertSame(2, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_general_form_distinguishes_offering_help_from_asking_for_it(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/contact', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
'organisation' => 'Asociación Vecinal',
|
||||
'email' => 'hola@asociacion.example',
|
||||
]))->assertCreated();
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertSame(ApplicationKind::Request, $application->kind);
|
||||
$this->assertSame('Asociación Vecinal', $application->organisation);
|
||||
$this->assertNull($application->call_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_organisation_asking_for_help_has_to_say_who_it_is(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/contact', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
]))->assertUnprocessable()
|
||||
->assertJsonValidationErrors('organisation');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_kind_cannot_be_chosen_when_answering_a_call(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// Applying to a call is always an offer of time; letting the payload
|
||||
// say otherwise would put replies in the wrong queue.
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
'kind' => ApplicationKind::Request->value,
|
||||
]))->assertUnprocessable()
|
||||
->assertJsonValidationErrors('kind');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_the_language_the_visitor_wrote_in(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// Reached through the Catalan URL of the same call — a slug only
|
||||
// resolves inside its own language.
|
||||
$this->postJson('/api/public/v1/calls/rerefons/apply?lang=ca', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// So the reply goes out in the same language they used.
|
||||
$this->assertSame('ca', Application::sole()->locale);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_keeps_a_hash_of_the_address_rather_than_the_address(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
$application = Application::sole();
|
||||
|
||||
$this->assertNotNull($application->ip_hash);
|
||||
$this->assertSame(64, strlen($application->ip_hash));
|
||||
$this->assertStringNotContainsString('127.0.0.1', $application->ip_hash);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_confirmation_is_never_cached(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
$response = $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertCreated();
|
||||
|
||||
// A shared cache holding one of these would hand it to the next
|
||||
// visitor.
|
||||
$this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_tells_the_people_who_can_read_the_inbox(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$editor = User::factory()->create()->syncRoles('editor')->fresh();
|
||||
$viewer = User::factory()->create()->syncRoles('viewer')->fresh();
|
||||
|
||||
$this->openCall();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
|
||||
Notification::assertSentTo($editor, ApplicationReceived::class);
|
||||
|
||||
// A viewer has no business reading strangers' contact details, so
|
||||
// they are not told about them either.
|
||||
Notification::assertNotSentTo($viewer, ApplicationReceived::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function spam_does_not_wake_anybody_up(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$editor = User::factory()->create()->syncRoles('editor')->fresh();
|
||||
|
||||
$this->openCall();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload([
|
||||
config('site.submissions.honeypot') => 'spam',
|
||||
]))->assertCreated();
|
||||
|
||||
Notification::assertNotSentTo($editor, ApplicationReceived::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function submissions_stop_while_the_public_site_is_in_maintenance(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
Maintenance::enable(['es' => 'Volvemos en un rato']);
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('maintenance', true);
|
||||
|
||||
$this->assertSame(0, Application::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_forms_can_be_closed_without_a_deploy(): void
|
||||
{
|
||||
config()->set('site.submissions.enabled', false);
|
||||
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('submissions_enabled', false);
|
||||
|
||||
// And the frontend learns it from /site, so it hides the form instead
|
||||
// of rendering one that would be refused.
|
||||
$this->getJson('/api/public/v1/site')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.submissions_enabled', false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function one_address_cannot_flood_the_inbox(): void
|
||||
{
|
||||
config()->set('site.submissions.rate_limit', 2);
|
||||
// Otherwise the second request would be answered as a duplicate and
|
||||
// never reach the limiter.
|
||||
config()->set('site.submissions.deduplicate_minutes', 0);
|
||||
|
||||
$this->openCall();
|
||||
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated();
|
||||
$this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertTooManyRequests();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_read_endpoints_stay_read_only(): void
|
||||
{
|
||||
$this->openCall();
|
||||
|
||||
// The write routes are explicit; nothing else on the public API
|
||||
// acquired a verb along the way.
|
||||
$this->postJson('/api/public/v1/projects')->assertMethodNotAllowed();
|
||||
$this->postJson('/api/public/v1/calls')->assertMethodNotAllowed();
|
||||
}
|
||||
}
|
||||
301
tests/Feature/Api/TelemetryApiTest.php
Normal file
301
tests/Feature/Api/TelemetryApiTest.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\Maintenance;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The frontend reporting what it could not resolve.
|
||||
*
|
||||
* The endpoint is small; what needs holding still is everything around it. It
|
||||
* is a write endpoint on the public API, so it is gated by a secret and can
|
||||
* never be open by accident. It is fed by strangers' traffic, so a scan must
|
||||
* not be able to turn the table into a log. And what it collects is only worth
|
||||
* collecting if it lines up exactly with what a redirect matches against.
|
||||
*/
|
||||
class TelemetryApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected string $token = 'ingest-secret';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['site.telemetry.token' => $this->token]);
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function report(array $payload): TestResponse
|
||||
{
|
||||
return $this->withHeaders(['X-Ingest-Token' => $this->token])
|
||||
->postJson('/api/public/v1/telemetry/not-found', $payload);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_an_address_that_could_not_be_resolved(): void
|
||||
{
|
||||
$this->report([
|
||||
'path' => '/proyectos/nombre-viejo',
|
||||
'locale' => 'es',
|
||||
'referrer' => 'https://example.net/enlace',
|
||||
])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 1)
|
||||
// A confirmation is nobody else's, and this one is not cacheable
|
||||
// for the same reason a submission's is not.
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', [
|
||||
'path' => '/proyectos/nombre-viejo',
|
||||
'locale' => 'es',
|
||||
'hits' => 1,
|
||||
'referrer' => 'https://example.net/enlace',
|
||||
'status' => MissingUrlStatus::Pending->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property that makes this a counter rather than a log: a crawler
|
||||
* walking a dead section must cost one row, not one row per request.
|
||||
*/
|
||||
#[Test]
|
||||
public function reporting_the_same_address_again_counts_instead_of_repeating(): void
|
||||
{
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
$this->report(['path' => '/equipo/quien-sea', 'locale' => 'es'])->assertAccepted();
|
||||
}
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame(3, (int) MissingUrl::query()->first()->hits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths are normalised exactly as redirects are. If these two ever
|
||||
* disagree, the list fills with addresses that already have a rule.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_normalises_the_path_the_way_a_redirect_does(): void
|
||||
{
|
||||
$this->report(['path' => '/proyectos/algo/', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/proyectos/algo?utm_source=twitter', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame('/proyectos/algo', MissingUrl::query()->first()->path);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_accepts_a_batch(): void
|
||||
{
|
||||
$this->report(['hits' => [
|
||||
['path' => '/uno', 'locale' => 'es'],
|
||||
['path' => '/dos', 'locale' => 'en'],
|
||||
['path' => '/tres'],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.recorded', 3);
|
||||
|
||||
$this->assertSame(3, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale is half of the unique key, so it can never be null — the
|
||||
* negotiated one stands in when the frontend does not say.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_report_without_a_locale_falls_back_to_the_negotiated_one(): void
|
||||
{
|
||||
$this->withHeaders(['Accept-Language' => 'ca'])
|
||||
->report(['path' => '/sense-idioma'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/sense-idioma', 'locale' => 'ca']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same address in two languages is two problems: they are fixed by two
|
||||
* different redirects, because the paths differ per language.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_same_path_in_two_languages_is_two_rows(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
$this->assertSame(2, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_drops_scans_for_things_that_were_never_here(): void
|
||||
{
|
||||
$this->report(['hits' => [
|
||||
['path' => '/wp-admin/setup-config.php'],
|
||||
['path' => '/.env'],
|
||||
['path' => '/proyectos/de-verdad'],
|
||||
]])
|
||||
->assertAccepted()
|
||||
->assertJsonPath('data.received', 3)
|
||||
// Reported honestly: the frontend can tell its filter from ours.
|
||||
->assertJsonPath('data.recorded', 1);
|
||||
|
||||
$this->assertSame(1, MissingUrl::query()->count());
|
||||
$this->assertSame('/proyectos/de-verdad', MissingUrl::query()->first()->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* A later sighting without a referrer is what a bot produces. It must not
|
||||
* erase the referrer that told us who is linking to a dead address.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_later_sighting_never_erases_a_known_referrer(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es', 'referrer' => 'https://example.net/post'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$this->assertSame('https://example.net/post', MissingUrl::query()->first()->referrer);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_without_the_secret(): void
|
||||
{
|
||||
$this->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->withHeaders(['X-Ingest-Token' => 'wrong'])
|
||||
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule the preview secret already follows: an unset secret disables
|
||||
* the endpoint, it never opens it.
|
||||
*/
|
||||
#[Test]
|
||||
public function an_unset_secret_grants_nobody_access(): void
|
||||
{
|
||||
config(['site.telemetry.token' => null]);
|
||||
|
||||
$this->withHeaders(['X-Ingest-Token' => ''])
|
||||
->postJson('/api/public/v1/telemetry/not-found', ['path' => '/algo'])
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_what_it_cannot_use(): void
|
||||
{
|
||||
$this->report(['path' => 'https://otro-dominio.example/algo'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('hits.0.path');
|
||||
|
||||
$this->report(['hits' => []])->assertUnprocessable();
|
||||
|
||||
$this->report(['path' => '/algo', 'locale' => 'de'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('hits.0.locale');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_caps_how_much_one_request_may_carry(): void
|
||||
{
|
||||
config(['site.telemetry.max_batch' => 2]);
|
||||
|
||||
$this->report(['hits' => [
|
||||
['path' => '/uno'], ['path' => '/dos'], ['path' => '/tres'],
|
||||
]])->assertUnprocessable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance is about what visitors are shown. A 404 during it is still a
|
||||
* 404 — and it is the most likely moment to produce the interesting ones.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_keeps_listening_while_the_site_is_down_for_maintenance(): void
|
||||
{
|
||||
app(Maintenance::class)->enable();
|
||||
|
||||
$this->report(['path' => '/durante-el-mantenimiento', 'locale' => 'es'])
|
||||
->assertAccepted();
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/durante-el-mantenimiento']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop this feature exists to close: a report becomes a redirect, and
|
||||
* the report stops asking to be dealt with.
|
||||
*/
|
||||
#[Test]
|
||||
public function writing_a_redirect_resolves_the_report_it_answers(): void
|
||||
{
|
||||
$this->report(['path' => '/proyectos/viejo', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
Redirect::create([
|
||||
'from' => '/proyectos/viejo',
|
||||
'to' => '/proyectos/nuevo',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
MissingUrlStatus::Resolved,
|
||||
MissingUrl::query()->first()->status,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_redirect_for_every_language_resolves_every_languages_report(): void
|
||||
{
|
||||
$this->report(['path' => '/algo', 'locale' => 'es'])->assertAccepted();
|
||||
$this->report(['path' => '/algo', 'locale' => 'ca'])->assertAccepted();
|
||||
|
||||
Redirect::create([
|
||||
'from' => '/algo',
|
||||
'to' => '/otra-cosa',
|
||||
'status' => 301,
|
||||
'locale' => null,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(0, MissingUrl::query()->pending()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect that was written and then deleted leaves the address broken
|
||||
* again. Being told about it once must not mean being told never again.
|
||||
*/
|
||||
#[Test]
|
||||
public function an_address_seen_again_after_being_dealt_with_comes_back(): void
|
||||
{
|
||||
$url = MissingUrl::factory()->resolved()->create([
|
||||
'path' => '/vuelve',
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$this->report(['path' => '/vuelve', 'locale' => 'es'])->assertAccepted();
|
||||
|
||||
$url->refresh();
|
||||
|
||||
$this->assertSame(MissingUrlStatus::Pending, $url->status);
|
||||
$this->assertNull($url->resolved_at);
|
||||
}
|
||||
}
|
||||
121
tests/Feature/Api/TokenEndpointTest.php
Normal file
121
tests/Feature/Api/TokenEndpointTest.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokenEndpointTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_exchanges_credentials_for_a_token_and_provisions_the_user(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'secret',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('access_token', 'fake-access-token')
|
||||
->assertJsonPath('user.email', 'ada@example.com')
|
||||
->assertJsonPath('user.roles.0', config('pireon.default_role'));
|
||||
|
||||
$this->assertDatabaseHas('users', ['oauth_id' => 4242]);
|
||||
|
||||
// The password grant must reach the identity server, never a local
|
||||
// password column.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
|
||||
&& $request['grant_type'] === 'password');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_does_not_leak_why_a_credential_was_rejected(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response([
|
||||
'error' => 'invalid_grant',
|
||||
'error_description' => 'The user credentials were incorrect.',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'wrong',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$this->assertStringNotContainsString('credentials were incorrect', $response->content());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_an_account_awaiting_approval(): void
|
||||
{
|
||||
$this->fakePireon(identity: ['approved_at' => null]);
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'secret',
|
||||
])
|
||||
->assertForbidden()
|
||||
->assertJsonPath('message', __('pireon.pending_approval'));
|
||||
|
||||
$this->assertDatabaseCount('users', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_password_grant_can_be_disabled(): void
|
||||
{
|
||||
config()->set('pireon.password_grant_enabled', false);
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'secret',
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_validates_the_grant_type(): void
|
||||
{
|
||||
$this->postJson('/api/v1/auth/token', ['grant_type' => 'implicit'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('grant_type');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles(['viewer']);
|
||||
|
||||
$headers = $this->apiHeadersFor($user);
|
||||
|
||||
$this->getJson('/api/v1/me', $headers)->assertOk();
|
||||
$this->getJson('/api/v1/me', $headers)->assertOk();
|
||||
$this->getJson('/api/v1/me', $headers)->assertOk();
|
||||
|
||||
// Introspection is cached, so three requests cost one upstream call.
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
}
|
||||
257
tests/Feature/Auth/PasswordLoginTest.php
Normal file
257
tests/Feature/Auth/PasswordLoginTest.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Filament\Pages\Auth\PireonLogin;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Signing in with an e-mail and a password.
|
||||
*
|
||||
* The identity server still decides whether the credentials are right — there
|
||||
* are no local passwords — but it does so out of sight: nobody signing in is
|
||||
* sent anywhere or told that another server exists.
|
||||
*/
|
||||
class PasswordLoginTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
config()->set('pireon.password_grant_enabled', true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function correct_credentials_sign_somebody_in(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm([
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'correcta',
|
||||
'remember' => true,
|
||||
])
|
||||
->call('authenticate')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->assertTrue(Auth::check());
|
||||
|
||||
// The local account is provisioned on the way in, so the very first
|
||||
// page already has roles to authorise against.
|
||||
$user = User::query()->where('email', 'ada@example.com')->sole();
|
||||
$this->assertSame(Auth::id(), $user->id);
|
||||
$this->assertSame(4242, $user->oauth_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_password_goes_to_the_identity_server_and_nowhere_else(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 's3cr3t'])
|
||||
->call('authenticate');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
|
||||
&& $request['grant_type'] === 'password'
|
||||
&& $request['password'] === 's3cr3t');
|
||||
|
||||
// It is never written down: the local row has no usable password.
|
||||
$user = User::query()->where('email', 'ada@example.com')->sole();
|
||||
$this->assertNotSame('s3cr3t', $user->password);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function wrong_credentials_say_the_same_thing_as_an_unknown_address(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'quien@example.com', 'password' => 'mala'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email']);
|
||||
|
||||
// The identity server distinguishes the two; repeating that here would
|
||||
// turn the form into a way of finding out who has an account.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_identity_server_that_breaks_is_not_blamed_on_the_password(): void
|
||||
{
|
||||
// What a Passport server answers when it cannot sign a token at all —
|
||||
// missing keys, say. It has judged nothing about these credentials.
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
|
||||
]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
|
||||
|
||||
// Saying "those credentials are wrong" here sends somebody to check a
|
||||
// password that was never the problem.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_identity_server_that_cannot_be_reached_says_so_too(): void
|
||||
{
|
||||
Http::fake(fn () => throw new ConnectionException('Connection refused'));
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.server_unavailable')]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_api_token_endpoint_makes_the_same_distinction(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['message' => 'Server Error'], 500),
|
||||
]);
|
||||
|
||||
// 503, not 401: nothing was decided about these credentials.
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'correcta',
|
||||
])->assertStatus(503);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deactivated_account_is_told_why(): void
|
||||
{
|
||||
$this->fakePireon(['is_active' => false]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.inactive')]);
|
||||
|
||||
// Said only once the password was right, so it reveals nothing to
|
||||
// somebody guessing.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_account_still_awaiting_approval_is_told_so(): void
|
||||
{
|
||||
$this->fakePireon(['approved_at' => null]);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.pending_approval')]);
|
||||
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_valid_account_with_no_role_here_is_still_refused(): void
|
||||
{
|
||||
// Nothing grants the default role in this test, so the user arrives
|
||||
// with no way into the panel.
|
||||
config()->set('pireon.default_role', null);
|
||||
|
||||
$this->fakePireon();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.no_permission')]);
|
||||
|
||||
// Being a valid account upstream is not the same as being allowed in
|
||||
// here: roles are this application's business.
|
||||
$this->assertFalse(Auth::check());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_says_so_plainly_when_password_sign_in_is_switched_off(): void
|
||||
{
|
||||
config()->set('pireon.password_grant_enabled', false);
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate')
|
||||
->assertHasFormErrors(['email' => __('pireon.password_grant_disabled')]);
|
||||
|
||||
// A misconfiguration is an operator's problem, not a visitor's, so it
|
||||
// does not masquerade as "those credentials are incorrect".
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_stops_after_five_attempts(): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->pireonServer}/oauth/token" => Http::response(['error' => 'invalid_grant'], 401),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'mala']);
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$component->call('authenticate')->assertHasFormErrors(['email']);
|
||||
}
|
||||
|
||||
// The sixth is refused here rather than spending the identity
|
||||
// server's own allowance.
|
||||
Http::fake();
|
||||
$component->call('authenticate');
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_session_id_is_rotated_on_the_way_in(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
$before = session()->getId();
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate');
|
||||
|
||||
// A session fixated before signing in must not survive now that it
|
||||
// carries an identity.
|
||||
$this->assertNotSame($before, session()->getId());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_users_language_follows_them_into_the_panel(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
// A pre-existing account: the default role is only granted to brand
|
||||
// new identities, so this one needs one of its own to get in.
|
||||
User::factory()
|
||||
->create(['email' => 'ada@example.com', 'oauth_id' => 4242, 'locale' => 'ca'])
|
||||
->assignRole('viewer');
|
||||
|
||||
Livewire::test(PireonLogin::class)
|
||||
->fillForm(['email' => 'ada@example.com', 'password' => 'correcta'])
|
||||
->call('authenticate');
|
||||
|
||||
$this->assertSame('ca', session(config('locales.session_key')));
|
||||
}
|
||||
}
|
||||
147
tests/Feature/Auth/PireonLoginTest.php
Normal file
147
tests/Feature/Auth/PireonLoginTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PireonLoginTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_redirects_to_the_identity_server_with_a_pkce_challenge(): void
|
||||
{
|
||||
$response = $this->get(route('auth.redirect'));
|
||||
|
||||
$response->assertRedirectContains($this->pireonServer.'/oauth/authorize');
|
||||
|
||||
$query = [];
|
||||
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
|
||||
|
||||
$this->assertSame('code', $query['response_type']);
|
||||
$this->assertSame('test-client-id', $query['client_id']);
|
||||
$this->assertSame('S256', $query['code_challenge_method']);
|
||||
$this->assertNotEmpty($query['code_challenge']);
|
||||
|
||||
// The verifier itself must stay server-side; only its hash travels.
|
||||
$verifier = session('pireon.code_verifier');
|
||||
$this->assertNotEmpty($verifier);
|
||||
$this->assertStringNotContainsString($verifier, (string) $response->headers->get('Location'));
|
||||
|
||||
$expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
$this->assertSame($expectedChallenge, $query['code_challenge']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_signs_the_user_in_and_provisions_a_local_account(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
$response = $this
|
||||
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
|
||||
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$user = User::where('oauth_id', 4242)->first();
|
||||
|
||||
$this->assertNotNull($user);
|
||||
$this->assertSame('ada@example.com', $user->email);
|
||||
$this->assertAuthenticatedAs($user);
|
||||
|
||||
// New identities get the least-privileged role, never the app_roles
|
||||
// the identity server reported.
|
||||
$this->assertTrue($user->hasRole(config('pireon.default_role')));
|
||||
$this->assertFalse($user->hasRole('admin'));
|
||||
$this->assertSame(['user'], $user->remote_roles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_rejects_a_callback_whose_state_does_not_match(): void
|
||||
{
|
||||
$this->fakePireon();
|
||||
|
||||
$response = $this
|
||||
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
|
||||
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'attacker-state']));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
$this->assertDatabaseCount('users', 0);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_an_account_still_awaiting_approval(): void
|
||||
{
|
||||
$this->fakePireon(identity: ['approved_at' => null]);
|
||||
|
||||
$response = $this
|
||||
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
|
||||
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertSessionHas('error', __('pireon.pending_approval'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_refuses_a_deactivated_account(): void
|
||||
{
|
||||
$this->fakePireon(identity: ['is_active' => false]);
|
||||
|
||||
$response = $this
|
||||
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
|
||||
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
|
||||
|
||||
$response->assertSessionHas('error', __('pireon.inactive'));
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_adopts_a_pre_existing_local_account_with_the_same_email(): void
|
||||
{
|
||||
$existing = User::factory()->unlinked()->create(['email' => 'ada@example.com']);
|
||||
$existing->assignRole('admin');
|
||||
|
||||
$this->fakePireon();
|
||||
|
||||
$this->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
|
||||
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
|
||||
|
||||
$this->assertDatabaseCount('users', 1);
|
||||
|
||||
$existing->refresh();
|
||||
$this->assertSame(4242, $existing->oauth_id);
|
||||
|
||||
// Roles granted locally survive the first real sign-in.
|
||||
$this->assertTrue($existing->hasRole('admin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function signing_out_clears_the_stored_oauth_token(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession(['pireon.token' => ['access_token' => 'abc']])
|
||||
->post(route('logout'))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull(session('pireon.token'));
|
||||
}
|
||||
}
|
||||
279
tests/Feature/Git/GiteaSyncTest.php
Normal file
279
tests/Feature/Git/GiteaSyncTest.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Git;
|
||||
|
||||
use App\Enums\GitProvider;
|
||||
use App\Models\Repository;
|
||||
use App\Services\Git\GitException;
|
||||
use App\Services\Git\GitManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The collective hosts its own Gitea, so this is the driver that matters.
|
||||
*/
|
||||
class GiteaSyncTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected string $instance = 'https://git.pireon.test';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config()->set('git.providers.gitea.url', $this->instance);
|
||||
config()->set('git.providers.gitea.token', 'a-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $repo
|
||||
* @param array<int, array<string, mixed>> $issues
|
||||
* @param array<int, array<string, mixed>> $commits
|
||||
*/
|
||||
protected function fakeGitea(array $repo = [], array $issues = [], array $commits = []): void
|
||||
{
|
||||
Http::fake([
|
||||
"{$this->instance}/api/v1/repos/*/languages" => Http::response(['PHP' => 90000, 'Blade' => 12000]),
|
||||
"{$this->instance}/api/v1/repos/*/topics" => Http::response(['topics' => ['laravel', 'comunidad']]),
|
||||
"{$this->instance}/api/v1/repos/*/issues*" => Http::response($issues),
|
||||
"{$this->instance}/api/v1/repos/*/commits*" => Http::response($commits ?: [
|
||||
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
|
||||
['author' => ['login' => 'grace'], 'commit' => ['author' => ['email' => 'grace@x.test']]],
|
||||
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
|
||||
]),
|
||||
"{$this->instance}/api/v1/repos/*" => Http::response([
|
||||
'full_name' => 'colectivo/web',
|
||||
'html_url' => "{$this->instance}/colectivo/web",
|
||||
'description' => 'La web del colectivo',
|
||||
'website' => 'https://colectivo.test',
|
||||
'default_branch' => 'main',
|
||||
'language' => 'PHP',
|
||||
// Gitea's counter names differ from GitHub's.
|
||||
'stars_count' => 42,
|
||||
'forks_count' => 3,
|
||||
'watchers_count' => 7,
|
||||
'open_issues_count' => 5,
|
||||
'archived' => false,
|
||||
'fork' => false,
|
||||
'updated_at' => '2026-08-01T10:00:00Z',
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
...$repo,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_reads_giteas_counter_names(): void
|
||||
{
|
||||
$this->fakeGitea();
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/web',
|
||||
'stars' => 0,
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$repository->refresh();
|
||||
|
||||
$this->assertSame(42, $repository->stars);
|
||||
$this->assertSame(3, $repository->forks);
|
||||
$this->assertSame(7, $repository->watchers);
|
||||
$this->assertSame(5, $repository->open_issues_count);
|
||||
$this->assertSame('PHP', $repository->primary_language);
|
||||
$this->assertSame(['laravel', 'comunidad'], $repository->topics);
|
||||
$this->assertSame('https://colectivo.test', $repository->homepage);
|
||||
$this->assertNull($repository->sync_error);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_authenticates_with_giteas_token_scheme(): void
|
||||
{
|
||||
$this->fakeGitea();
|
||||
|
||||
app(GitManager::class)->sync(
|
||||
Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web'])
|
||||
);
|
||||
|
||||
// Gitea expects "token <t>", not "Bearer <t>".
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'token a-token'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_asks_gitea_to_exclude_pull_requests(): void
|
||||
{
|
||||
$this->fakeGitea();
|
||||
|
||||
app(GitManager::class)->sync(
|
||||
Repository::factory()->create(['provider' => GitProvider::Gitea, 'full_name' => 'colectivo/web'])
|
||||
);
|
||||
|
||||
// Unlike GitHub, Gitea can filter PRs server-side.
|
||||
Http::assertSent(fn ($request) => ! str_contains($request->url(), '/issues')
|
||||
|| str_contains($request->url(), 'type=issues'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_mirrors_only_newcomer_friendly_issues(): void
|
||||
{
|
||||
$this->fakeGitea(issues: [
|
||||
[
|
||||
'number' => 1, 'title' => 'Buena para empezar', 'html_url' => "{$this->instance}/x/1",
|
||||
'labels' => [['name' => 'good first issue']], 'comments' => 2,
|
||||
'user' => ['login' => 'ada'], 'assignees' => [],
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
[
|
||||
'number' => 2, 'title' => 'Se busca ayuda', 'html_url' => "{$this->instance}/x/2",
|
||||
'labels' => [['name' => 'Se busca ayuda']], 'comments' => 0,
|
||||
'user' => ['login' => 'ada'],
|
||||
// Gitea reports assignees as a list.
|
||||
'assignees' => [['login' => 'grace']],
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
[
|
||||
'number' => 3, 'title' => 'Refactor interno', 'html_url' => "{$this->instance}/x/3",
|
||||
'labels' => [['name' => 'chore']], 'comments' => 0,
|
||||
'user' => ['login' => 'ada'], 'assignees' => [],
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
]);
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/web',
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$this->assertSame(2, $repository->issues()->count());
|
||||
$this->assertSame(1, $repository->goodFirstIssues()->count());
|
||||
|
||||
// The Spanish label spelling from config/git.php matches too.
|
||||
$claimed = $repository->issues()->where('number', 2)->first();
|
||||
$this->assertTrue($claimed->is_help_wanted);
|
||||
$this->assertSame('grace', $claimed->assignee);
|
||||
$this->assertFalse($claimed->isUnclaimed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_contributor_count_is_derived_from_distinct_commit_authors(): void
|
||||
{
|
||||
$this->fakeGitea();
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/web',
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
// Gitea has no contributors endpoint: three commits by two people.
|
||||
$this->assertSame(2, $repository->fresh()->contributors_count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function commit_authors_without_an_account_still_count_once(): void
|
||||
{
|
||||
$this->fakeGitea(commits: [
|
||||
['commit' => ['author' => ['email' => 'sin-cuenta@x.test']]],
|
||||
['commit' => ['author' => ['email' => 'Sin-Cuenta@x.test']]],
|
||||
['author' => ['login' => 'ada'], 'commit' => ['author' => ['email' => 'ada@x.test']]],
|
||||
]);
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/web',
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$this->assertSame(2, $repository->fresh()->contributors_count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_private_repository_without_a_token_reports_the_real_cause(): void
|
||||
{
|
||||
Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 403)]);
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/privado',
|
||||
]);
|
||||
|
||||
try {
|
||||
app(GitManager::class)->sync($repository);
|
||||
$this->fail('Expected a GitException.');
|
||||
} catch (GitException $e) {
|
||||
$this->assertSame(403, $e->status);
|
||||
$this->assertStringContainsString('GITEA_TOKEN', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_instance_that_is_down_is_treated_as_transient(): void
|
||||
{
|
||||
Http::fake(["{$this->instance}/api/v1/repos/*" => Http::response([], 502)]);
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'colectivo/web',
|
||||
]);
|
||||
|
||||
try {
|
||||
app(GitManager::class)->sync($repository);
|
||||
$this->fail('Expected a GitException.');
|
||||
} catch (GitException $e) {
|
||||
// Self-hosted instances restart; the job should retry, not give up.
|
||||
$this->assertTrue($e->isTransient);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function without_an_instance_url_gitea_is_not_offered_as_a_provider(): void
|
||||
{
|
||||
config()->set('git.providers.gitea.url', null);
|
||||
|
||||
// A driver with nowhere to point is worse than no driver: it would
|
||||
// only produce failures later.
|
||||
$this->assertFalse(GitProvider::Gitea->isSupported());
|
||||
|
||||
$repository = Repository::factory()->create(['provider' => GitProvider::Gitea]);
|
||||
|
||||
$this->expectException(GitException::class);
|
||||
$this->expectExceptionMessage('GITEA_URL');
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_bare_name_is_qualified_with_the_configured_org(): void
|
||||
{
|
||||
config()->set('git.providers.gitea.default_owner', 'colectivo');
|
||||
|
||||
$this->fakeGitea();
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
'provider' => GitProvider::Gitea,
|
||||
'full_name' => 'web',
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$this->assertSame('colectivo/web', $repository->fresh()->full_name);
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), '/repos/colectivo/web'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function gitea_is_the_default_provider(): void
|
||||
{
|
||||
$this->assertSame(GitProvider::Gitea, GitProvider::default());
|
||||
$this->assertSame('gitea', Repository::factory()->create()->provider->value);
|
||||
}
|
||||
}
|
||||
281
tests/Feature/Git/RepositorySyncTest.php
Normal file
281
tests/Feature/Git/RepositorySyncTest.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Git;
|
||||
|
||||
use App\Enums\GitProvider;
|
||||
use App\Models\Repository;
|
||||
use App\Services\Git\GitException;
|
||||
use App\Services\Git\GitManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The GitHub driver is kept for mirrors and the odd repository hosted
|
||||
* elsewhere; Gitea is the collective's own instance (see GiteaSyncTest).
|
||||
* These tests also stand as the proof that the provider interface really is
|
||||
* substitutable.
|
||||
*/
|
||||
class RepositorySyncTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Gitea is the default, so GitHub has to be asked for explicitly.
|
||||
$this->githubDefaults = ['provider' => GitProvider::GitHub];
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected array $githubDefaults = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $repo
|
||||
* @param array<int, array<string, mixed>> $issues
|
||||
*/
|
||||
protected function fakeGitHub(array $repo = [], array $issues = []): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/repos/*/languages' => Http::response(['PHP' => 90000, 'Blade' => 12000]),
|
||||
'api.github.com/repos/*/contributors*' => Http::response([['login' => 'a']], 200, [
|
||||
// GitHub reports the total through the Link header's last page.
|
||||
'Link' => '<https://api.github.com/repositories/1/contributors?per_page=1&page=7>; rel="last"',
|
||||
]),
|
||||
'api.github.com/repos/*/issues*' => Http::response($issues),
|
||||
'api.github.com/repos/*' => Http::response([
|
||||
'full_name' => 'colectivo/web',
|
||||
'html_url' => 'https://github.com/colectivo/web',
|
||||
'description' => 'La web del colectivo',
|
||||
'default_branch' => 'main',
|
||||
'language' => 'PHP',
|
||||
'license' => ['spdx_id' => 'AGPL-3.0'],
|
||||
'topics' => ['laravel', 'community'],
|
||||
'stargazers_count' => 128,
|
||||
'forks_count' => 9,
|
||||
'subscribers_count' => 5,
|
||||
'open_issues_count' => 12,
|
||||
'archived' => false,
|
||||
'fork' => false,
|
||||
'pushed_at' => '2026-08-01T10:00:00Z',
|
||||
'created_at' => '2024-01-01T00:00:00Z',
|
||||
...$repo,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_mirrors_the_repository_stats(): void
|
||||
{
|
||||
$this->fakeGitHub();
|
||||
|
||||
$repository = Repository::factory()->create([
|
||||
...$this->githubDefaults,
|
||||
'full_name' => 'colectivo/web',
|
||||
'stars' => 0,
|
||||
'last_synced_at' => null,
|
||||
]);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$repository->refresh();
|
||||
|
||||
$this->assertSame(128, $repository->stars);
|
||||
$this->assertSame('PHP', $repository->primary_language);
|
||||
$this->assertSame('AGPL-3.0', $repository->license);
|
||||
$this->assertSame(['laravel', 'community'], $repository->topics);
|
||||
$this->assertSame(['PHP' => 90000, 'Blade' => 12000], $repository->languages);
|
||||
|
||||
// Contributor count is read from the Link header's last page rather
|
||||
// than by paging through every contributor.
|
||||
$this->assertSame(7, $repository->contributors_count);
|
||||
|
||||
$this->assertNotNull($repository->last_synced_at);
|
||||
$this->assertNull($repository->sync_error);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_only_mirrors_newcomer_friendly_issues(): void
|
||||
{
|
||||
$this->fakeGitHub(issues: [
|
||||
[
|
||||
'number' => 1, 'title' => 'Good one', 'html_url' => 'https://x/1',
|
||||
'labels' => [['name' => 'good first issue']], 'comments' => 2,
|
||||
'user' => ['login' => 'ada'], 'assignee' => null,
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
[
|
||||
'number' => 2, 'title' => 'Help please', 'html_url' => 'https://x/2',
|
||||
'labels' => [['name' => 'Help Wanted']], 'comments' => 0,
|
||||
'user' => ['login' => 'ada'], 'assignee' => ['login' => 'bob'],
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
// Ordinary issue: noise for a newcomer, so it is not mirrored.
|
||||
[
|
||||
'number' => 3, 'title' => 'Refactor internals', 'html_url' => 'https://x/3',
|
||||
'labels' => [['name' => 'chore']], 'comments' => 0,
|
||||
'user' => ['login' => 'ada'], 'assignee' => null,
|
||||
'created_at' => '2026-07-01T00:00:00Z', 'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
// A pull request is not something anyone picks up.
|
||||
[
|
||||
'number' => 4, 'title' => 'A PR', 'html_url' => 'https://x/4',
|
||||
'labels' => [['name' => 'good first issue']], 'pull_request' => ['url' => 'x'],
|
||||
'user' => ['login' => 'ada'], 'created_at' => '2026-07-01T00:00:00Z',
|
||||
'updated_at' => '2026-07-02T00:00:00Z',
|
||||
],
|
||||
]);
|
||||
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$this->assertSame(2, $repository->issues()->count());
|
||||
$this->assertSame(1, $repository->goodFirstIssues()->count());
|
||||
|
||||
// Label matching is case-insensitive, per config/git.php.
|
||||
$helpWanted = $repository->issues()->where('number', 2)->first();
|
||||
$this->assertTrue($helpWanted->is_help_wanted);
|
||||
$this->assertSame('bob', $helpWanted->assignee);
|
||||
$this->assertFalse($helpWanted->isUnclaimed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function issues_closed_upstream_disappear_here(): void
|
||||
{
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
|
||||
|
||||
$repository->issues()->create([
|
||||
'number' => 99, 'title' => 'Closed since', 'html_url' => 'https://x/99',
|
||||
'is_good_first_issue' => true,
|
||||
]);
|
||||
|
||||
$this->fakeGitHub(issues: []);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
// Advertising an issue that is actually closed wastes a would-be
|
||||
// contributor's time, so the mirror is replaced, not merged.
|
||||
$this->assertSame(0, $repository->issues()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_missing_repository_is_recorded_and_not_retried(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response(['message' => 'Not Found'], 404),
|
||||
]);
|
||||
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']);
|
||||
|
||||
try {
|
||||
app(GitManager::class)->sync($repository);
|
||||
$this->fail('Expected a GitException.');
|
||||
} catch (GitException $e) {
|
||||
$this->assertSame(404, $e->status);
|
||||
$this->assertFalse($e->isTransient);
|
||||
}
|
||||
|
||||
$repository->refresh();
|
||||
|
||||
$this->assertStringContainsString('does not exist', $repository->sync_error);
|
||||
$this->assertSame(1, $repository->sync_failures);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function repeated_failures_flag_the_repository_in_the_panel(): void
|
||||
{
|
||||
Http::fake(['api.github.com/*' => Http::response([], 404)]);
|
||||
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/gone']);
|
||||
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
rescue(fn () => app(GitManager::class)->sync($repository));
|
||||
}
|
||||
|
||||
$this->assertSame(3, $repository->fresh()->sync_failures);
|
||||
$this->assertTrue(Repository::query()->failing()->exists());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_rate_limit_is_reported_as_transient(): void
|
||||
{
|
||||
Http::fake([
|
||||
'api.github.com/*' => Http::response(['message' => 'rate limit'], 403, [
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
'X-RateLimit-Reset' => (string) (time() + 120),
|
||||
]),
|
||||
]);
|
||||
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
|
||||
|
||||
try {
|
||||
app(GitManager::class)->sync($repository);
|
||||
$this->fail('Expected a GitException.');
|
||||
} catch (GitException $e) {
|
||||
// Transient means the job should back off and retry, not give up.
|
||||
$this->assertTrue($e->isTransient);
|
||||
$this->assertSame(429, $e->status);
|
||||
$this->assertStringContainsString('GITHUB_TOKEN', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_successful_sync_clears_a_previous_error(): void
|
||||
{
|
||||
$repository = Repository::factory()->failing()->create([...$this->githubDefaults, 'full_name' => 'colectivo/web']);
|
||||
|
||||
$this->fakeGitHub();
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
$repository->refresh();
|
||||
|
||||
$this->assertNull($repository->sync_error);
|
||||
$this->assertSame(0, $repository->sync_failures);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function synced_columns_cannot_be_mass_assigned(): void
|
||||
{
|
||||
// A hand-typed star count is a lie waiting to happen, so the sync
|
||||
// columns are deliberately absent from the fillable list.
|
||||
$repository = new Repository;
|
||||
$repository->fill(['full_name' => 'colectivo/web', 'stars' => 9999]);
|
||||
|
||||
$this->assertSame('colectivo/web', $repository->full_name);
|
||||
$this->assertNull($repository->stars);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_bare_name_is_qualified_with_the_configured_org(): void
|
||||
{
|
||||
config()->set('git.providers.github.default_owner', 'colectivo');
|
||||
|
||||
$this->fakeGitHub();
|
||||
|
||||
$repository = Repository::factory()->create([...$this->githubDefaults, 'full_name' => 'web']);
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
|
||||
// The provider's own answer wins, so the record self-corrects.
|
||||
$this->assertSame('colectivo/web', $repository->fresh()->full_name);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/repos/colectivo/web'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_unsupported_provider_fails_clearly(): void
|
||||
{
|
||||
$repository = Repository::factory()->create(['provider' => 'gitlab']);
|
||||
|
||||
$this->expectException(GitException::class);
|
||||
$this->expectExceptionMessage('No driver is registered for the [gitlab] provider');
|
||||
|
||||
app(GitManager::class)->sync($repository);
|
||||
}
|
||||
}
|
||||
243
tests/Feature/Media/ImageProcessingTest.php
Normal file
243
tests/Feature/Media/ImageProcessingTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Media;
|
||||
|
||||
use App\Jobs\ProcessMedia;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Services\Media\ImageProcessor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What an image has to know about itself before a server-rendered page can
|
||||
* lay it out properly: its size, narrower versions of it, and something to
|
||||
* paint while it loads.
|
||||
*/
|
||||
class ImageProcessingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake(config('media.disk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A real encoded image, because the whole point of this code is what it
|
||||
* does to actual pixels.
|
||||
*/
|
||||
protected function putImage(string $path, int $width = 1600, int $height = 900): Media
|
||||
{
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
imagefill($image, 0, 0, imagecolorallocate($image, 30, 90, 180));
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, 90);
|
||||
$contents = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
Storage::disk(config('media.disk'))->put($path, $contents);
|
||||
|
||||
return Media::query()->create(['disk' => config('media.disk'), 'path' => $path]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_the_dimensions_a_page_needs_to_reserve_the_box(): void
|
||||
{
|
||||
$media = $this->putImage('projects/cover.jpg', 1600, 900);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$media->refresh();
|
||||
|
||||
// Without these the page reflows as each image arrives.
|
||||
$this->assertSame(1600, $media->width);
|
||||
$this->assertSame(900, $media->height);
|
||||
$this->assertSame(1.7778, $media->aspectRatio());
|
||||
$this->assertSame('image/jpeg', $media->mime);
|
||||
$this->assertNotNull($media->processed_at);
|
||||
$this->assertNull($media->error);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_derives_narrower_versions_in_a_modern_format(): void
|
||||
{
|
||||
$media = $this->putImage('projects/cover.jpg', 1600, 900);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$media->refresh();
|
||||
$disk = Storage::disk(config('media.disk'));
|
||||
|
||||
$this->assertNotEmpty($media->variants);
|
||||
|
||||
foreach ($media->variants as $variant) {
|
||||
$this->assertTrue($disk->exists($variant['path']), "missing {$variant['path']}");
|
||||
$this->assertLessThan(1600, $variant['width']);
|
||||
}
|
||||
|
||||
// WebP is the one format every supported PHP can write; AVIF is
|
||||
// produced too wherever the runtime can encode it.
|
||||
$this->assertContains('webp', array_column($media->variants, 'format'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_never_scales_an_image_up(): void
|
||||
{
|
||||
$media = $this->putImage('projects/small.jpg', 500, 500);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$widths = array_column($media->refresh()->variants, 'width');
|
||||
|
||||
// A 500px original must not be republished as a blurry "1600px"
|
||||
// version; only the widths below it are real.
|
||||
$this->assertSame([400], array_values(array_unique($widths)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_builds_a_srcset_the_frontend_can_use_verbatim(): void
|
||||
{
|
||||
$media = $this->putImage('projects/cover.jpg', 1600, 900);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$srcset = $media->refresh()->srcset();
|
||||
|
||||
$this->assertArrayHasKey('webp', $srcset);
|
||||
$this->assertStringContainsString('400w', $srcset['webp']);
|
||||
$this->assertStringContainsString('800w', $srcset['webp']);
|
||||
|
||||
// Absolute, because the frontend is a different application on a
|
||||
// different host.
|
||||
$this->assertStringStartsWith('http', $srcset['webp']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_produces_a_placeholder_small_enough_to_inline(): void
|
||||
{
|
||||
$media = $this->putImage('projects/cover.jpg');
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$placeholder = $media->refresh()->placeholder;
|
||||
|
||||
$this->assertStringStartsWith('data:image/', $placeholder);
|
||||
|
||||
// It travels inside every JSON response mentioning the image, so its
|
||||
// size is part of the contract, not an implementation detail.
|
||||
$this->assertLessThan(2048, strlen($placeholder));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reprocessing_replaces_the_previous_versions_rather_than_piling_up(): void
|
||||
{
|
||||
$media = $this->putImage('projects/cover.jpg');
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
$first = $media->refresh()->variants;
|
||||
|
||||
app(ImageProcessor::class)->process($media->refresh());
|
||||
$second = $media->refresh()->variants;
|
||||
|
||||
$this->assertCount(count($first), $second);
|
||||
|
||||
$disk = Storage::disk(config('media.disk'));
|
||||
foreach ($second as $variant) {
|
||||
$this->assertTrue($disk->exists($variant['path']));
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_vector_is_copied_through_untouched(): void
|
||||
{
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 120"></svg>';
|
||||
Storage::disk(config('media.disk'))->put('projects/logo.svg', $svg);
|
||||
|
||||
$media = Media::query()->create(['disk' => config('media.disk'), 'path' => 'projects/logo.svg']);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$media->refresh();
|
||||
|
||||
// Resampling a vector loses the point of it, but the frontend still
|
||||
// needs a size to reserve the box — so the viewBox is read.
|
||||
$this->assertSame(240, $media->width);
|
||||
$this->assertSame(120, $media->height);
|
||||
$this->assertSame([], $media->variants);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_missing_file_is_recorded_rather_than_thrown(): void
|
||||
{
|
||||
$media = Media::query()->create([
|
||||
'disk' => config('media.disk'),
|
||||
'path' => 'projects/gone.jpg',
|
||||
]);
|
||||
|
||||
app(ImageProcessor::class)->process($media);
|
||||
|
||||
$media->refresh();
|
||||
|
||||
// The upload keeps working as a plain image; a broken derivation must
|
||||
// not take a page down with it.
|
||||
$this->assertNotNull($media->error);
|
||||
$this->assertNotNull($media->processed_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saving_an_image_path_registers_it_and_queues_the_work(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$project = Project::factory()->create(['cover_image' => 'projects/new.jpg']);
|
||||
|
||||
$media = Media::query()->where('path', 'projects/new.jpg')->sole();
|
||||
|
||||
$this->assertSame(config('media.disk'), $media->disk);
|
||||
Queue::assertPushed(ProcessMedia::class);
|
||||
|
||||
// An unrelated save must not queue the same work again.
|
||||
Queue::fake();
|
||||
$project->update(['is_featured' => true]);
|
||||
Queue::assertNotPushed(ProcessMedia::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function two_records_pointing_at_one_upload_share_a_single_row(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
|
||||
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
|
||||
|
||||
// The file is the identity, so the second one costs no extra
|
||||
// processing and no second set of derived versions.
|
||||
$this->assertSame(1, Media::query()->where('path', 'projects/shared.jpg')->count());
|
||||
Queue::assertPushed(ProcessMedia::class, 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_picks_up_whatever_the_queue_did_not(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$this->putImage('projects/pending.jpg');
|
||||
|
||||
$this->assertSame(1, Media::query()->pending()->count());
|
||||
|
||||
$this->artisan('media:process')->assertSuccessful();
|
||||
|
||||
// The scheduler runs this, so a worker that is down costs a delay
|
||||
// rather than an image that stays half-done forever.
|
||||
$this->assertSame(0, Media::query()->pending()->count());
|
||||
}
|
||||
}
|
||||
153
tests/Feature/Media/ProjectGalleryTest.php
Normal file
153
tests/Feature/Media/ProjectGalleryTest.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Media;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Models\ImageAttachment;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* More than one image on a project: the screenshots and the before-and-after
|
||||
* that make a write-up worth reading.
|
||||
*/
|
||||
class ProjectGalleryTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
protected function projectWithGallery(): Project
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
]);
|
||||
|
||||
$project->images()->createMany([
|
||||
[
|
||||
'path' => 'projects/gallery/after.jpg',
|
||||
'alt' => ['es' => 'El panel nuevo'],
|
||||
'caption' => ['es' => 'Después'],
|
||||
'position' => 2,
|
||||
],
|
||||
[
|
||||
'path' => 'projects/gallery/before.jpg',
|
||||
'alt' => ['es' => 'La hoja de cálculo que usaban'],
|
||||
'caption' => ['es' => 'Antes'],
|
||||
'position' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_gallery_comes_back_in_the_order_it_was_arranged(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
$gallery = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.gallery');
|
||||
|
||||
// An unordered gallery is a different gallery every time it is read,
|
||||
// and "before" after "after" tells the wrong story.
|
||||
$this->assertSame(['Antes', 'Después'], array_column($gallery, 'caption'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function each_image_carries_everything_needed_to_render_it(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
Media::query()->where('path', 'projects/gallery/before.jpg')->sole()->forceFill([
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'variants' => [
|
||||
['format' => 'webp', 'width' => 400, 'path' => 'projects/gallery/before-400.webp', 'size' => 8000],
|
||||
],
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$first = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.gallery.0');
|
||||
|
||||
$this->assertStringStartsWith('http', $first['url']);
|
||||
$this->assertSame(1200, $first['width']);
|
||||
$this->assertSame('La hoja de cálculo que usaban', $first['alt']);
|
||||
$this->assertStringContainsString('400w', $first['srcset']['webp']);
|
||||
|
||||
// A caption is not a description: one is shown to everyone, the other
|
||||
// is read by whoever cannot see the image.
|
||||
$this->assertSame('Antes', $first['caption']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_listing_does_not_carry_every_screenshot_of_every_project(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
$project = $this->getJson('/api/public/v1/projects')
|
||||
->assertOk()
|
||||
->json('data.0');
|
||||
|
||||
$this->assertArrayNotHasKey('gallery', $project);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function adding_a_gallery_image_registers_it_like_any_other_upload(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$project->images()->create(['path' => 'projects/gallery/new.jpg']);
|
||||
|
||||
// Nothing special about a gallery image: same media row, same derived
|
||||
// versions, same queue.
|
||||
$this->assertSame(1, Media::query()->where('path', 'projects/gallery/new.jpg')->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function erasing_a_project_for_good_takes_its_gallery_with_it(): void
|
||||
{
|
||||
$project = $this->projectWithGallery();
|
||||
|
||||
$project->delete();
|
||||
|
||||
// A soft delete keeps it — that is the point of a soft delete.
|
||||
$this->assertSame(2, ImageAttachment::query()->count());
|
||||
|
||||
$project->forceDelete();
|
||||
|
||||
// A morph relation has no foreign key to cascade for it, so this is
|
||||
// handled explicitly and worth pinning down.
|
||||
$this->assertSame(0, ImageAttachment::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_gallery_editor_renders(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$project = $this->projectWithGallery();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(ProjectResource::getUrl('edit', ['record' => $project]))
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
159
tests/Feature/Panel/ApplicationsInboxTest.php
Normal file
159
tests/Feature/Panel/ApplicationsInboxTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use App\Filament\Resources\Calls\CallResource;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The inbox is the one resource holding personal data belonging to people
|
||||
* outside the collective, so who can open it is part of its contract.
|
||||
*/
|
||||
class ApplicationsInboxTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_inbox_renders(): void
|
||||
{
|
||||
$call = Call::factory()->create(['project_id' => null]);
|
||||
|
||||
Application::factory()->count(2)->create();
|
||||
Application::factory()->forCall($call->id)->create();
|
||||
Application::factory()->request()->create();
|
||||
Application::factory()->spam()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(ApplicationResource::getUrl('index'))->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_submission_can_be_opened(): void
|
||||
{
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(ApplicationResource::getUrl('view', ['record' => $application]))
|
||||
->assertSuccessful()
|
||||
->assertSee($application->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_replies_to_a_call_show_on_the_call_itself(): void
|
||||
{
|
||||
$call = Call::factory()->create(['project_id' => null]);
|
||||
|
||||
Application::factory()->forCall($call->id)->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
// "Is anybody interested?" is a question you have while looking at the
|
||||
// opening, not while working through the whole inbox.
|
||||
$this->get(CallResource::getUrl('edit', ['record' => $call]))
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_runs_the_calls_so_an_editor_answers_them(): void
|
||||
{
|
||||
$editor = $this->userWithRoles('editor');
|
||||
|
||||
$this->assertTrue($editor->can('applications.view_any'));
|
||||
$this->assertTrue($editor->can('applications.update'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_never_sees_a_strangers_contact_details(): void
|
||||
{
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
|
||||
$this->actingAs($viewer);
|
||||
|
||||
// Reading the site's content and reading the people who wrote in are
|
||||
// deliberately different rights.
|
||||
$this->assertFalse($viewer->can('applications.view_any'));
|
||||
$this->assertFalse(ApplicationResource::canViewAny());
|
||||
|
||||
$this->get(ApplicationResource::getUrl('index'))->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nobody_creates_a_submission_by_hand(): void
|
||||
{
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
// They arrive from the public site. A hand-written one would be a
|
||||
// record of a conversation that never happened.
|
||||
$this->assertFalse(ApplicationResource::canCreate());
|
||||
$this->assertFalse($admin->can('create', Application::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function answering_records_who_answered(): void
|
||||
{
|
||||
$admin = $this->userWithRoles('admin');
|
||||
$application = Application::factory()->create();
|
||||
|
||||
$application->markAs(ApplicationStatus::Contacted, $admin);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
// An inbox worked by several people otherwise turns into two replies
|
||||
// or none.
|
||||
$this->assertSame(ApplicationStatus::Contacted, $application->status);
|
||||
$this->assertSame($admin->id, $application->handled_by);
|
||||
$this->assertNotNull($application->handled_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_badge_counts_the_people_still_waiting(): void
|
||||
{
|
||||
Application::factory()->count(2)->create();
|
||||
Application::factory()->handled()->create();
|
||||
Application::factory()->spam()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->assertSame('2', ApplicationResource::getNavigationBadge());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_submission_survives_the_call_it_answered(): void
|
||||
{
|
||||
$call = Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'title' => ['es' => 'Backend'],
|
||||
]);
|
||||
|
||||
$application = Application::factory()->forCall($call->id, 'Backend')->create();
|
||||
|
||||
$call->delete();
|
||||
|
||||
$application->refresh();
|
||||
|
||||
// The opening was tidied up; the person who wrote in was not.
|
||||
$this->assertNull($application->call_id);
|
||||
$this->assertSame('Backend', $application->subject());
|
||||
}
|
||||
}
|
||||
154
tests/Feature/Panel/BulkPublishingTest.php
Normal file
154
tests/Feature/Panel/BulkPublishingTest.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Filament\Resources\Pages\Pages\ListPages;
|
||||
use App\Filament\Resources\Projects\Pages\ListProjects;
|
||||
use App\Filament\Resources\Services\Pages\ListServices;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Publishing and requesting review, for a whole selection at once.
|
||||
*
|
||||
* The single-row versions of both already existed; what these tests hold
|
||||
* still is that the bulk versions make the same judgement calls the single
|
||||
* ones do — who may press the button, and which selected rows are actually
|
||||
* eligible — rather than a cruder "do it to everything ticked".
|
||||
*/
|
||||
class BulkPublishingTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_a_selection_of_pages_skips_what_is_already_live(): void
|
||||
{
|
||||
$draft = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
|
||||
$alreadyLive = Page::factory()->published()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(ListPages::class)
|
||||
->callTableBulkAction('publish', [$draft, $alreadyLive]);
|
||||
|
||||
$this->assertSame(PageStatus::Published, $draft->fresh()->status);
|
||||
$this->assertNotNull($draft->fresh()->published_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishing is what closes a pending review — the model's own hook does
|
||||
* that on any `update()`, and the bulk action has to go through it one
|
||||
* record at a time rather than a single UPDATE query, or this silently
|
||||
* stops working.
|
||||
*/
|
||||
#[Test]
|
||||
public function bulk_publishing_closes_a_pending_review(): void
|
||||
{
|
||||
$page = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
|
||||
$page->requestReview();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(ListPages::class)
|
||||
->callTableBulkAction('publish', [$page]);
|
||||
|
||||
$this->assertFalse($page->fresh()->isAwaitingReview());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function somebody_who_cannot_publish_does_not_see_the_bulk_action(): void
|
||||
{
|
||||
Page::factory()->create(['status' => PageStatus::Draft]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('writer'));
|
||||
|
||||
Livewire::test(ListPages::class)
|
||||
->assertTableBulkActionHidden('publish');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function requesting_review_in_bulk_skips_what_is_already_awaiting_or_published(): void
|
||||
{
|
||||
$eligible = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
|
||||
|
||||
$alreadyAwaiting = Page::factory()->create(['status' => PageStatus::Draft, 'published_at' => null]);
|
||||
$alreadyAwaiting->requestReview();
|
||||
|
||||
$alreadyPublished = Page::factory()->published()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('writer'));
|
||||
|
||||
Livewire::test(ListPages::class)
|
||||
->callTableBulkAction('request_review_bulk', [$eligible, $alreadyAwaiting, $alreadyPublished]);
|
||||
|
||||
$this->assertTrue($eligible->fresh()->isAwaitingReview());
|
||||
|
||||
// Untouched: neither had anything to gain from being asked again.
|
||||
$this->assertSame(
|
||||
$alreadyAwaiting->fresh()->review_requested_at->timestamp,
|
||||
$alreadyAwaiting->review_requested_at->timestamp,
|
||||
);
|
||||
$this->assertFalse($alreadyPublished->fresh()->isAwaitingReview());
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as the single button: somebody who can already publish
|
||||
* has no use for asking permission first.
|
||||
*/
|
||||
#[Test]
|
||||
public function somebody_who_can_already_publish_does_not_see_the_bulk_request_action(): void
|
||||
{
|
||||
Page::factory()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(ListPages::class)
|
||||
->assertTableBulkActionHidden('request_review_bulk');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_projects(): void
|
||||
{
|
||||
$project = Project::factory()->create(['published_at' => null]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(ListProjects::class)
|
||||
->callTableBulkAction('publish', [$project]);
|
||||
|
||||
$this->assertNotNull($project->fresh()->published_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_services(): void
|
||||
{
|
||||
$service = Service::factory()->create(['is_published' => false, 'published_at' => null]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(ListServices::class)
|
||||
->callTableBulkAction('publish', [$service]);
|
||||
|
||||
$service->refresh();
|
||||
|
||||
$this->assertTrue($service->is_published);
|
||||
$this->assertNotNull($service->published_at);
|
||||
}
|
||||
}
|
||||
155
tests/Feature/Panel/CollectiveResourcesTest.php
Normal file
155
tests/Feature/Panel/CollectiveResourcesTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Calls\CallResource;
|
||||
use App\Filament\Resources\Members\MemberResource;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Filament\Resources\Repositories\RepositoryResource;
|
||||
use App\Filament\Resources\Technologies\TechnologyResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Repository;
|
||||
use App\Models\Technology;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CollectiveResourcesTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_collective_resource_renders(): void
|
||||
{
|
||||
Project::factory()->count(2)->create();
|
||||
Member::factory()->count(2)->create();
|
||||
Technology::factory()->count(2)->create();
|
||||
Call::factory()->create(['project_id' => null]);
|
||||
Repository::factory()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
foreach ([
|
||||
ProjectResource::class,
|
||||
MemberResource::class,
|
||||
TechnologyResource::class,
|
||||
CallResource::class,
|
||||
RepositoryResource::class,
|
||||
] as $resource) {
|
||||
$this->get($resource::getUrl('index'))
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_can_manage_content_but_not_sync_repositories(): void
|
||||
{
|
||||
$editor = $this->userWithRoles('editor');
|
||||
|
||||
$this->actingAs($editor);
|
||||
|
||||
$this->assertTrue($editor->can('projects.create'));
|
||||
$this->assertTrue($editor->can('calls.create'));
|
||||
$this->assertTrue($editor->can('members.update'));
|
||||
|
||||
// Syncing spends a shared rate limit, so it is gated separately from
|
||||
// ordinary editing.
|
||||
$this->assertTrue($editor->can('repositories.view_any'));
|
||||
$this->assertFalse($editor->can('repositories.sync'));
|
||||
$this->assertFalse($editor->can('repositories.create'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_cannot_reach_the_collective_editors(): void
|
||||
{
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$this->assertTrue(ProjectResource::canViewAny());
|
||||
$this->assertFalse(ProjectResource::canCreate());
|
||||
$this->assertFalse(MemberResource::canCreate());
|
||||
$this->assertFalse(TechnologyResource::canCreate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_a_project_is_a_separate_permission_from_editing_it(): void
|
||||
{
|
||||
$editor = $this->userWithRoles('editor');
|
||||
$editor->roles()->first()->revokePermissionTo('projects.publish');
|
||||
$editor->forgetCachedPermissions();
|
||||
|
||||
$project = Project::factory()->draft()->create();
|
||||
|
||||
$this->assertTrue($editor->can('update', $project));
|
||||
$this->assertFalse($editor->can('publish', $project));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_member_may_always_edit_their_own_profile(): void
|
||||
{
|
||||
// Someone with no member-editing permission at all.
|
||||
$user = $this->userWithRoles('viewer');
|
||||
$own = Member::factory()->create(['user_id' => $user->id]);
|
||||
$other = Member::factory()->create();
|
||||
|
||||
$this->assertTrue($user->can('update', $own));
|
||||
$this->assertFalse($user->can('update', $other));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_technology_still_in_use_cannot_be_deleted(): void
|
||||
{
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
$used = Technology::factory()->create();
|
||||
$unused = Technology::factory()->create();
|
||||
|
||||
Project::factory()->create()->technologies()->attach($used);
|
||||
|
||||
// Deleting it would silently strip the tag across the whole site.
|
||||
$this->assertFalse($admin->can('delete', $used));
|
||||
$this->assertTrue($admin->can('delete', $unused));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_member_is_not_a_panel_user(): void
|
||||
{
|
||||
$member = Member::factory()->create();
|
||||
|
||||
// The two are deliberately separate: most members never need admin
|
||||
// access, and revoking admin must not remove someone from the website.
|
||||
$this->assertNull($member->user_id);
|
||||
$this->assertDatabaseCount('users', 0);
|
||||
$this->assertTrue($member->is_public);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function removing_a_panel_user_leaves_the_member_on_the_site(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$user->delete();
|
||||
|
||||
$member->refresh();
|
||||
|
||||
$this->assertTrue($member->exists);
|
||||
$this->assertNull($member->user_id);
|
||||
$this->assertTrue($member->is_public);
|
||||
}
|
||||
}
|
||||
221
tests/Feature/Panel/ConcurrentEditingTest.php
Normal file
221
tests/Feature/Panel/ConcurrentEditingTest.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Projects\Pages\EditProject;
|
||||
use App\Filament\Resources\SettingsResource\Pages\EditSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Presence;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Two people, one record, one edit screen each.
|
||||
*
|
||||
* Nothing in Filament's own EditRecord page notices this happening, and the
|
||||
* consequence of not noticing is the worst kind: whoever saves second
|
||||
* silently erases whoever saved first, with no error and no trace beyond a
|
||||
* revision that looks like an ordinary edit.
|
||||
*
|
||||
* Two things are tested here because they answer two different questions. The
|
||||
* warning on open answers "should I even start" — advisory, shown once, easy
|
||||
* to ignore. The refusal on save answers "did this just happen anyway" —
|
||||
* exact, unavoidable, and the one that actually prevents the data loss. The
|
||||
* second must hold even when the first was never seen.
|
||||
*/
|
||||
class ConcurrentEditingTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_save_is_refused_when_the_record_changed_underneath_it(): void
|
||||
{
|
||||
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]);
|
||||
|
||||
// Somebody else — a second tab, a teammate, does not matter which —
|
||||
// saves a change after this screen was opened. The truncated-second
|
||||
// precision of `updated_at` means a real difference needs distance in
|
||||
// time, not just a second call; a day is more than enough and mirrors
|
||||
// the wall-clock gap an actual second editor would produce.
|
||||
$this->travel(1)->day();
|
||||
$project->fresh()->update(['title' => ['es' => 'Cambiado por otra persona']]);
|
||||
|
||||
$editor
|
||||
->fillForm(['title' => ['es' => 'Lo que yo escribía']])
|
||||
->call('save')
|
||||
->assertNotified(__('panel.concurrency.title'));
|
||||
|
||||
// Not "no form errors" — the save must not have reached the database
|
||||
// at all, and the other person's write must be exactly what survives.
|
||||
$this->assertSame('Cambiado por otra persona', $project->fresh()->getTranslation('title', 'es', false));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_conflict_message_names_who_changed_it_when_the_history_knows(): void
|
||||
{
|
||||
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
|
||||
$other = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()]);
|
||||
|
||||
$this->travel(1)->day();
|
||||
$this->actingAs($other);
|
||||
$project->fresh()->update(['title' => ['es' => 'Cambiado']]);
|
||||
|
||||
$editor
|
||||
->fillForm(['title' => ['es' => 'Mi versión']])
|
||||
->call('save')
|
||||
->assertNotified(__('panel.concurrency.title'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saving_without_interference_works_exactly_as_before(): void
|
||||
{
|
||||
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()])
|
||||
->fillForm(['title' => ['es' => 'Actualizado']])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->assertSame('Actualizado', $project->fresh()->getTranslation('title', 'es', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving twice from the same open screen — write, then write again — is
|
||||
* not a conflict with yourself. Each save has to become the new baseline,
|
||||
* or nobody could ever save more than once without reloading.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_second_save_from_the_same_screen_is_not_a_conflict_with_itself(): void
|
||||
{
|
||||
$project = Project::factory()->create(['title' => ['es' => 'Original']]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$editor = Livewire::test(EditProject::class, ['record' => $project->getKey()])
|
||||
->fillForm(['title' => ['es' => 'Primero']])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->travel(1)->day();
|
||||
|
||||
$editor
|
||||
->fillForm(['title' => ['es' => 'Segundo']])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors()
|
||||
->assertNotNotified(__('panel.concurrency.title'));
|
||||
|
||||
$this->assertSame('Segundo', $project->fresh()->getTranslation('title', 'es', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard is generic, not written against any one resource. Settings is
|
||||
* the one model with a string primary key rather than an
|
||||
* auto-incrementing id, which is exactly the kind of assumption a naive
|
||||
* `WHERE id = ?` would get wrong.
|
||||
*/
|
||||
#[Test]
|
||||
public function it_also_guards_a_model_with_a_string_primary_key(): void
|
||||
{
|
||||
Setting::set('site.maintenance', ['enabled' => false]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$editor = Livewire::test(EditSettings::class, ['record' => 'site.maintenance']);
|
||||
|
||||
$this->travel(1)->day();
|
||||
Setting::set('site.maintenance', ['enabled' => true]);
|
||||
|
||||
$editor
|
||||
->fillForm(['value' => json_encode(['enabled' => false, 'changed_by_me' => true])])
|
||||
->call('save')
|
||||
->assertNotified(__('panel.concurrency.title'));
|
||||
|
||||
$this->assertSame(['enabled' => true], Setting::get('site.maintenance'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function opening_a_record_someone_else_has_open_warns_the_second_person(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$first = $this->userWithRoles('admin', ['name' => 'Ada Lovelace']);
|
||||
|
||||
$this->actingAs($first);
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()])
|
||||
->assertNotified(__('panel.presence.title'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first person to open a record has no reason to expect company, and
|
||||
* nothing this screen could tell them would help — so they are the one
|
||||
* case that stays silent.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_first_person_to_open_a_record_is_not_warned(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()])
|
||||
->assertNotNotified(__('panel.presence.title'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function presence_fades_after_the_window_passes(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$first = $this->userWithRoles('admin');
|
||||
|
||||
$this->actingAs($first);
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()]);
|
||||
|
||||
// Long past the window: a tab left open and forgotten must stop
|
||||
// haunting the record, not just for the display but for anyone else
|
||||
// who opens it afterwards.
|
||||
$this->travel(20)->minutes();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(EditProject::class, ['record' => $project->getKey()])
|
||||
->assertNotNotified(__('panel.presence.title'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function presence_never_counts_the_viewer_themselves(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$user = $this->userWithRoles('admin');
|
||||
|
||||
$this->assertTrue(Presence::touch($project, $user)->isEmpty());
|
||||
|
||||
// Opening it again a moment later is still just one person here.
|
||||
$this->assertTrue(Presence::touch($project, $user)->isEmpty());
|
||||
}
|
||||
}
|
||||
66
tests/Feature/Panel/ContentFormsTest.php
Normal file
66
tests/Feature/Panel/ContentFormsTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Members\MemberResource;
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The editing forms themselves, which the resource listing tests never reach.
|
||||
*/
|
||||
class ContentFormsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_content_form_renders_empty(): void
|
||||
{
|
||||
foreach ([PageResource::class, ProjectResource::class, MemberResource::class] as $resource) {
|
||||
$this->get($resource::getUrl('create'))->assertSuccessful();
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_content_form_renders_with_an_image_and_its_description(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'featured_image' => 'pages/hero.jpg',
|
||||
'featured_image_alt' => ['es' => 'Una captura'],
|
||||
]);
|
||||
|
||||
$project = Project::factory()->create([
|
||||
'cover_image' => 'projects/cover.jpg',
|
||||
'cover_image_alt' => ['es' => 'La portada'],
|
||||
]);
|
||||
|
||||
$member = Member::factory()->create(['avatar' => 'members/ada.jpg']);
|
||||
|
||||
// The alt-text tabs only appear once there is an image to describe,
|
||||
// so a form carrying one exercises a path the empty one does not.
|
||||
$this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful();
|
||||
$this->get(ProjectResource::getUrl('edit', ['record' => $project]))->assertSuccessful();
|
||||
$this->get(MemberResource::getUrl('edit', ['record' => $member]))->assertSuccessful();
|
||||
}
|
||||
}
|
||||
73
tests/Feature/Panel/DocsNavigationTest.php
Normal file
73
tests/Feature/Panel/DocsNavigationTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The link to the API documentation in the panel's sidebar.
|
||||
*
|
||||
* It points outside the panel, which is the interesting part: the page opens
|
||||
* in a new tab so that reaching for the reference does not throw away whatever
|
||||
* was half-written in the panel.
|
||||
*/
|
||||
class DocsNavigationTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_sidebar_links_to_the_documentation(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$html = (string) $this->get('/admin')->assertSuccessful()->getContent();
|
||||
|
||||
$this->assertStringContainsString(route('docs.api'), $html);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/href="[^"]*docs\/api"[^>]*target="_blank"/',
|
||||
$html,
|
||||
'The documentation should open in a new tab.',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function anybody_who_can_reach_the_panel_can_reach_the_docs(): void
|
||||
{
|
||||
// Reading the API reference is not a privilege: the page itself is
|
||||
// public, so hiding the link would only make it harder to find.
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
$this->get('/admin')
|
||||
->assertSuccessful()
|
||||
->assertSee(route('docs.api'), escape: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel is configured once per process, so a label resolved there
|
||||
* would freeze in whatever language happened to be active at boot. This is
|
||||
* the test that catches that, and it has to run in its own request to
|
||||
* mean anything.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_label_follows_the_language_being_browsed(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$this->get('/admin?lang=en')
|
||||
->assertSuccessful()
|
||||
->assertSee(__('docs.title', [], 'en'));
|
||||
}
|
||||
}
|
||||
164
tests/Feature/Panel/DuplicateTitlesTest.php
Normal file
164
tests/Feature/Panel/DuplicateTitlesTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Resources\Pages\Pages\CreatePage;
|
||||
use App\Filament\Resources\Pages\Pages\EditPage;
|
||||
use App\Filament\Resources\Projects\Pages\CreateProject;
|
||||
use App\Filament\Resources\Services\Pages\CreateService;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Support\DuplicateTitles;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* A nudge, not a rule.
|
||||
*
|
||||
* The slug already has a hard uniqueness constraint — two records cannot
|
||||
* answer at the same address. What this catches is the case that constraint
|
||||
* cannot: two records with different addresses and almost the same title,
|
||||
* which is what actually happens when nobody remembers something already
|
||||
* exists. It warns; it has never once refused to save.
|
||||
*/
|
||||
class DuplicateTitlesTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_near_identical_title_is_found(): void
|
||||
{
|
||||
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
|
||||
|
||||
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios v2', 'es', null);
|
||||
|
||||
$this->assertCount(1, $matches);
|
||||
$this->assertSame('Gestor de socios', $matches->first()->getTranslation('title', 'es', false));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_unrelated_title_is_not_a_match(): void
|
||||
{
|
||||
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
|
||||
|
||||
$matches = DuplicateTitles::similarTo(Page::class, 'Convocatoria de primavera', 'es', null);
|
||||
|
||||
$this->assertTrue($matches->isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* The record being edited must never flag itself: typing in an existing
|
||||
* page's own title field would otherwise "find" a perfect match on every
|
||||
* keystroke.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_record_is_never_a_duplicate_of_itself(): void
|
||||
{
|
||||
$page = Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
|
||||
|
||||
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', $page->id);
|
||||
|
||||
$this->assertTrue($matches->isEmpty());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_empty_title_matches_nothing(): void
|
||||
{
|
||||
Page::factory()->create(['title' => ['es' => 'Gestor de socios']]);
|
||||
|
||||
$this->assertTrue(DuplicateTitles::similarTo(Page::class, '', 'es', null)->isEmpty());
|
||||
$this->assertTrue(DuplicateTitles::similarTo(Page::class, ' ', 'es', null)->isEmpty());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function only_the_closest_three_are_returned(): void
|
||||
{
|
||||
foreach (range(1, 5) as $n) {
|
||||
Page::factory()->create(['title' => ['es' => "Gestor de socios {$n}"]]);
|
||||
}
|
||||
|
||||
$matches = DuplicateTitles::similarTo(Page::class, 'Gestor de socios', 'es', null);
|
||||
|
||||
$this->assertCount(3, $matches);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function typing_a_near_duplicate_title_warns_while_creating(): void
|
||||
{
|
||||
Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
|
||||
|
||||
Livewire::test(CreatePage::class)
|
||||
->fillForm(['title' => ['es' => 'Quienes somos']])
|
||||
->assertNotified(__('panel.duplicates.title'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing an existing page must not warn about itself the moment the
|
||||
* form is touched.
|
||||
*/
|
||||
#[Test]
|
||||
public function editing_a_page_without_changing_its_title_does_not_warn(): void
|
||||
{
|
||||
$page = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
|
||||
|
||||
Livewire::test(EditPage::class, ['record' => $page->getKey()])
|
||||
->fillForm(['excerpt' => ['es' => 'Un resumen nuevo']])
|
||||
->assertNotNotified(__('panel.duplicates.title'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_warning_links_to_the_existing_record(): void
|
||||
{
|
||||
$existing = Page::factory()->create(['title' => ['es' => 'Quiénes somos']]);
|
||||
|
||||
$component = Livewire::test(CreatePage::class)
|
||||
->fillForm(['title' => ['es' => 'Quienes somos']]);
|
||||
|
||||
// Read ahead of `assertNotified()`, which pulls this same key from
|
||||
// the session and would leave nothing behind for a second read.
|
||||
$notified = collect(session('filament.claimed_notifications'))->first();
|
||||
|
||||
$component->assertNotified(__('panel.duplicates.title'));
|
||||
|
||||
$this->assertSame(
|
||||
PageResource::getUrl('edit', ['record' => $existing]),
|
||||
$notified['actions'][0]['url'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_projects(): void
|
||||
{
|
||||
Project::factory()->create(['title' => ['es' => 'Web del colectivo']]);
|
||||
|
||||
Livewire::test(CreateProject::class)
|
||||
->fillForm(['title' => ['es' => 'Web del colectivo v2']])
|
||||
->assertNotified(__('panel.duplicates.title'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_services(): void
|
||||
{
|
||||
Service::factory()->create(['title' => ['es' => 'Auditoría técnica']]);
|
||||
|
||||
Livewire::test(CreateService::class)
|
||||
->fillForm(['title' => ['es' => 'Auditoria tecnica']])
|
||||
->assertNotified(__('panel.duplicates.title'));
|
||||
}
|
||||
}
|
||||
68
tests/Feature/Panel/EnvironmentBannerTest.php
Normal file
68
tests/Feature/Panel/EnvironmentBannerTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The one banner that has to be right in the one direction that matters.
|
||||
*
|
||||
* Nobody edits a local clone carefully because they mistake it for
|
||||
* production — the expensive mistake runs the other way. So the only
|
||||
* environment that stays silent is `local`; every other one says so, and
|
||||
* production says so the loudest.
|
||||
*/
|
||||
class EnvironmentBannerTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
}
|
||||
|
||||
protected function setEnvironment(string $environment): void
|
||||
{
|
||||
$this->app['env'] = $environment;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nothing_is_shown_in_a_local_install(): void
|
||||
{
|
||||
$this->setEnvironment('local');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertDontSee(__('panel.environment.production'))
|
||||
->assertDontSeeText('bg-danger-600', escape: false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function production_says_so_in_the_loudest_colour(): void
|
||||
{
|
||||
$this->setEnvironment('production');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.production'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function anything_else_names_itself(): void
|
||||
{
|
||||
$this->setEnvironment('staging');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.other', ['name' => 'staging']));
|
||||
}
|
||||
}
|
||||
77
tests/Feature/Panel/EveryPanelPageRendersTest.php
Normal file
77
tests/Feature/Panel/EveryPanelPageRendersTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Resource;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Every screen in the panel opens.
|
||||
*
|
||||
* This exists because of how the settings page broke: nothing referenced a
|
||||
* method that had moved in Filament, nothing failed to compile, and the suite
|
||||
* stayed green — the page simply threw the first time a human opened it,
|
||||
* months after it was written. A screen nobody has visited is a screen nobody
|
||||
* has tested, and there is no cheaper way to visit all of them than this.
|
||||
*/
|
||||
class EveryPanelPageRendersTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function every_resource_listing_opens(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$failures = [];
|
||||
|
||||
foreach (Filament::getPanel('admin')->getResources() as $resource) {
|
||||
/** @var class-string<resource> $resource */
|
||||
if (! array_key_exists('index', $resource::getPages())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$failures = [...$failures, ...$this->visit($resource::getUrl('index'), $resource)];
|
||||
}
|
||||
|
||||
$this->assertSame([], $failures, implode("\n", $failures));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_custom_page_opens(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$failures = [];
|
||||
|
||||
foreach (Filament::getPanel('admin')->getPages() as $page) {
|
||||
$failures = [...$failures, ...$this->visit($page::getUrl(), $page)];
|
||||
}
|
||||
|
||||
$this->assertSame([], $failures, implode("\n", $failures));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function visit(string $url, string $subject): array
|
||||
{
|
||||
try {
|
||||
$status = $this->withoutExceptionHandling()->get($url)->status();
|
||||
} catch (Throwable $e) {
|
||||
return ["{$subject} — {$url}\n ".$e::class.': '.$e->getMessage()];
|
||||
}
|
||||
|
||||
return $status === 200 ? [] : ["{$subject} — {$url} answered {$status}"];
|
||||
}
|
||||
}
|
||||
148
tests/Feature/Panel/MediaLibraryTest.php
Normal file
148
tests/Feature/Panel/MediaLibraryTest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Media\MediaResource;
|
||||
use App\Filament\Resources\Technologies\TechnologyResource;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Models\Technology;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The catalogue of everything uploaded, and logos on technologies.
|
||||
*/
|
||||
class MediaLibraryTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
Storage::fake(config('media.disk'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_library_lists_what_has_been_uploaded(): void
|
||||
{
|
||||
Project::factory()->create(['cover_image' => 'projects/una.jpg']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(MediaResource::getUrl('index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('projects/una.jpg');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nobody_uploads_from_the_library(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
// Files arrive by being uploaded on a record, not from here.
|
||||
$this->assertFalse(MediaResource::canCreate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_file_something_points_at_cannot_be_deleted(): void
|
||||
{
|
||||
Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']);
|
||||
$media = Media::query()->where('path', 'projects/en-uso.jpg')->sole();
|
||||
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
// Deleting it would put a broken image on the public site, which is
|
||||
// exactly what the health check exists to complain about.
|
||||
$this->assertTrue($media->isReferenced());
|
||||
$this->assertFalse($admin->can('delete', $media));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_file_nothing_points_at_can(): void
|
||||
{
|
||||
$media = Media::factory()->create(['path' => 'projects/huerfana.jpg']);
|
||||
|
||||
$this->assertFalse($media->isReferenced());
|
||||
$this->assertTrue($this->userWithRoles('admin')->can('delete', $media));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_weight_counts_the_derived_versions_too(): void
|
||||
{
|
||||
$media = Media::factory()->create([
|
||||
'size' => 1000,
|
||||
'variants' => [
|
||||
['format' => 'webp', 'width' => 400, 'path' => 'a-400.webp', 'size' => 300],
|
||||
['format' => 'webp', 'width' => 800, 'path' => 'a-800.webp', 'size' => 700],
|
||||
],
|
||||
]);
|
||||
|
||||
// The number that explains where the disk went.
|
||||
$this->assertSame(2000, $media->totalSize());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_may_look_but_not_tidy(): void
|
||||
{
|
||||
$media = Media::factory()->create();
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
|
||||
$this->assertTrue($viewer->can('media.view_any'));
|
||||
$this->assertFalse($viewer->can('delete', $media));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_technology_can_carry_a_logo(): void
|
||||
{
|
||||
$technology = Technology::factory()->create([
|
||||
'name' => 'Laravel',
|
||||
'logo' => 'technologies/laravel.svg',
|
||||
]);
|
||||
|
||||
// Registered like any other upload: same media row, same processing.
|
||||
$this->assertSame(1, Media::query()->where('path', 'technologies/laravel.svg')->count());
|
||||
$this->assertSame('technologies/laravel.svg', $technology->logo);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_logo_reaches_the_frontend_with_the_name_as_its_alt(): void
|
||||
{
|
||||
Technology::factory()->create([
|
||||
'name' => 'Rust',
|
||||
'slug' => 'rust',
|
||||
'radar_ring' => 'adopt',
|
||||
'logo' => 'technologies/rust.svg',
|
||||
]);
|
||||
|
||||
$data = $this->withHeaders(['Accept-Language' => 'es'])
|
||||
->getJson('/api/public/v1/radar')
|
||||
->assertOk()
|
||||
->json('data.0');
|
||||
|
||||
// A logo's description is the name of the thing, so there is nothing
|
||||
// to type and nothing to get wrong.
|
||||
$this->assertSame('Rust', $data['logo']['alt']);
|
||||
$this->assertStringContainsString('technologies/rust.svg', $data['logo']['url']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_technology_editor_still_opens(): void
|
||||
{
|
||||
$technology = Technology::factory()->create(['logo' => 'technologies/x.svg']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(TechnologyResource::getUrl('edit', ['record' => $technology]))->assertSuccessful();
|
||||
}
|
||||
}
|
||||
249
tests/Feature/Panel/MissingUrlsTest.php
Normal file
249
tests/Feature/Panel/MissingUrlsTest.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use App\Filament\Resources\MissingUrls\MissingUrlResource;
|
||||
use App\Filament\Resources\MissingUrls\Pages\ListMissingUrls;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\Health\Severity;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What the panel does with a reported 404.
|
||||
*
|
||||
* The screen is a to-do list with one real action on it, so what is worth
|
||||
* testing is that the action closes the loop — the redirect is written, and
|
||||
* the entry stops asking to be dealt with — and that seeing the list is gated
|
||||
* by the same permission as fixing what is on it.
|
||||
*/
|
||||
class MissingUrlsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_action_writes_the_redirect_and_closes_the_report(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$missing = MissingUrl::factory()->create([
|
||||
'path' => '/proyectos/nombre-viejo',
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
Livewire::test(ListMissingUrls::class)
|
||||
->callTableAction('redirect', $missing, data: [
|
||||
'to' => '/proyectos/nombre-nuevo',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$this->assertDatabaseHas('redirects', [
|
||||
'from' => '/proyectos/nombre-viejo',
|
||||
'to' => '/proyectos/nombre-nuevo',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination goes through the same normalisation as a hand-written
|
||||
* rule, so an address typed with a trailing slash does not become a
|
||||
* redirect that never matches.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_destination_is_normalised(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$missing = MissingUrl::factory()->create(['path' => '/algo', 'locale' => 'es']);
|
||||
|
||||
Livewire::test(ListMissingUrls::class)
|
||||
->callTableAction('redirect', $missing, data: [
|
||||
'to' => '/servicios/algo/',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('redirects', ['to' => '/servicios/algo']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignoring_hides_an_entry_without_stopping_the_count(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$missing = MissingUrl::factory()->create([
|
||||
'path' => '/ruido',
|
||||
'locale' => 'es',
|
||||
'hits' => 1,
|
||||
]);
|
||||
|
||||
Livewire::test(ListMissingUrls::class)
|
||||
->callTableAction('ignore', $missing);
|
||||
|
||||
$this->assertSame(MissingUrlStatus::Ignored, $missing->refresh()->status);
|
||||
|
||||
// Still counted: ignoring is a decision about the list, not about
|
||||
// whether the address is being asked for.
|
||||
MissingUrl::report('/ruido', 'es');
|
||||
|
||||
$missing->refresh();
|
||||
|
||||
$this->assertSame(2, (int) $missing->hits);
|
||||
|
||||
// And it stays ignored. These addresses are requested constantly — if
|
||||
// the next request revived them, the button would never work twice.
|
||||
$this->assertSame(MissingUrlStatus::Ignored, $missing->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gated by the redirect permissions rather than a set of its own, so a
|
||||
* role that cannot do anything about a broken link does not see the list
|
||||
* of broken links either.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_viewer_cannot_open_the_list(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
$this->get(MissingUrlResource::getUrl('index'))->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_can_open_it(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('editor'));
|
||||
|
||||
$this->get(MissingUrlResource::getUrl('index'))->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nobody_creates_one_by_hand(): void
|
||||
{
|
||||
$this->assertFalse(MissingUrlResource::canCreate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_health_check_counts_what_is_still_broken(): void
|
||||
{
|
||||
MissingUrl::factory()->create(['path' => '/roto', 'locale' => 'es', 'hits' => 12]);
|
||||
|
||||
// A single request is as likely to be a scanner as a person.
|
||||
MissingUrl::factory()->create(['path' => '/una-vez', 'locale' => 'es', 'hits' => 1]);
|
||||
|
||||
// Already dealt with.
|
||||
MissingUrl::factory()->resolved()->create(['path' => '/arreglado', 'locale' => 'es', 'hits' => 30]);
|
||||
MissingUrl::factory()->ignored()->create(['path' => '/ruido', 'locale' => 'es', 'hits' => 30]);
|
||||
|
||||
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
|
||||
|
||||
$this->assertInstanceOf(Finding::class, $finding);
|
||||
$this->assertSame(1, $finding->count);
|
||||
$this->assertSame(Severity::Warning, $finding->severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* An address our own pages link to is not somebody else's stale bookmark:
|
||||
* it is a fault in what we publish, and it will keep producing 404s.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_dead_link_on_our_own_site_is_an_error_not_a_warning(): void
|
||||
{
|
||||
MissingUrl::factory()->internal()->create([
|
||||
'path' => '/roto',
|
||||
'locale' => 'es',
|
||||
'hits' => 4,
|
||||
]);
|
||||
|
||||
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'missing-urls');
|
||||
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pruning_forgets_one_off_scans_and_keeps_the_rest(): void
|
||||
{
|
||||
MissingUrl::factory()->create([
|
||||
'path' => '/una-vez', 'locale' => 'es',
|
||||
'hits' => 1, 'last_seen_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
MissingUrl::factory()->create([
|
||||
'path' => '/repetida', 'locale' => 'es',
|
||||
'hits' => 9, 'last_seen_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
MissingUrl::factory()->create([
|
||||
'path' => '/reciente', 'locale' => 'es',
|
||||
'hits' => 1, 'last_seen_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// A decision already made must not come back next week as though it
|
||||
// had never been made.
|
||||
MissingUrl::factory()->ignored()->create([
|
||||
'path' => '/ignorada', 'locale' => 'es',
|
||||
'hits' => 1, 'last_seen_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('content:prune')->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('missing_urls', ['path' => '/una-vez']);
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/repetida']);
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/reciente']);
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/ignorada']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_dry_run_deletes_nothing(): void
|
||||
{
|
||||
MissingUrl::factory()->create([
|
||||
'path' => '/una-vez', 'locale' => 'es',
|
||||
'hits' => 1, 'last_seen_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('content:prune --dry-run')->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('missing_urls', ['path' => '/una-vez']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule typed straight into the redirects screen closes the report too:
|
||||
* the two lists must never disagree about whether an address is answered.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_hand_written_redirect_also_closes_the_report(): void
|
||||
{
|
||||
$missing = MissingUrl::factory()->create(['path' => '/a-mano', 'locale' => 'es']);
|
||||
|
||||
Redirect::create([
|
||||
'from' => '/a-mano',
|
||||
'to' => '/destino',
|
||||
'status' => 301,
|
||||
'locale' => 'es',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(MissingUrlStatus::Resolved, $missing->refresh()->status);
|
||||
}
|
||||
}
|
||||
140
tests/Feature/Panel/PanelAccessTest.php
Normal file
140
tests/Feature/Panel/PanelAccessTest.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Categories\CategoryResource;
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Resources\Roles\RoleResource;
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use App\Models\Category;
|
||||
use App\Models\Page;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PanelAccessTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_guest_is_sent_to_the_login_page(): void
|
||||
{
|
||||
$this->get('/admin')->assertRedirect();
|
||||
$this->get('/admin/login')->assertOk()->assertSee(__('pireon.sign_in'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_login_page_asks_for_credentials_and_nothing_else(): void
|
||||
{
|
||||
$response = $this->get('/admin/login')->assertOk();
|
||||
|
||||
// The identity server is meant to be invisible: whoever signs in sees
|
||||
// a form, not a hand-off to somewhere else.
|
||||
$response->assertSee('data.email', escape: false);
|
||||
$response->assertSee('data.password', escape: false);
|
||||
$response->assertDontSee(route('auth.redirect'), escape: false);
|
||||
$response->assertDontSee((string) config('pireon.server'), escape: false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_user_without_the_panel_access_permission_is_refused(): void
|
||||
{
|
||||
// A real identity, but no local role has been granted yet.
|
||||
$user = $this->userWithRoles([]);
|
||||
|
||||
$this->actingAs($user)->get('/admin')->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_can_open_the_panel(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer');
|
||||
|
||||
$this->actingAs($user)->get('/admin')->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_deactivated_user_cannot_open_the_panel(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer', ['is_active' => false]);
|
||||
|
||||
$this->actingAs($user)->get('/admin')->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resource_visibility_follows_local_permissions(): void
|
||||
{
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
$this->actingAs($viewer);
|
||||
$this->assertTrue(PageResource::canViewAny());
|
||||
$this->assertTrue(CategoryResource::canViewAny());
|
||||
$this->assertFalse(UserResource::canViewAny());
|
||||
$this->assertFalse(RoleResource::canViewAny());
|
||||
|
||||
$this->actingAs($admin);
|
||||
$this->assertTrue(UserResource::canViewAny());
|
||||
$this->assertTrue(RoleResource::canViewAny());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resource_index_pages_render(): void
|
||||
{
|
||||
Page::factory()->count(3)->create();
|
||||
Category::factory()->count(2)->create();
|
||||
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get(PageResource::getUrl('index'))->assertSuccessful();
|
||||
$this->get(CategoryResource::getUrl('index'))->assertSuccessful();
|
||||
$this->get(UserResource::getUrl('index'))->assertSuccessful();
|
||||
$this->get(RoleResource::getUrl('index'))->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_panel_renders_in_the_users_language(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer', ['locale' => 'ca']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(PageResource::getUrl('index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Pàgines');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_language_switcher_persists_the_choice(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer', ['locale' => 'es']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(PageResource::getUrl('index'))
|
||||
->get(route('locale.switch', 'en'))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame('en', $user->fresh()->locale);
|
||||
$this->assertSame('en', session(config('locales.session_key')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_unsupported_locale_is_rejected(): void
|
||||
{
|
||||
$user = $this->userWithRoles('viewer');
|
||||
|
||||
$this->actingAs($user)->get(route('locale.switch', 'de'))->assertNotFound();
|
||||
}
|
||||
}
|
||||
166
tests/Feature/Panel/PanelToolsTest.php
Normal file
166
tests/Feature/Panel/PanelToolsTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Pages\Trash;
|
||||
use App\Models\Application;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Support\PreviewLink;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The everyday tools: the bin and the shareable preview link.
|
||||
*/
|
||||
class PanelToolsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_bin_gathers_everything_deleted(): void
|
||||
{
|
||||
Page::factory()->create(['title' => ['es' => 'Borrada']])->delete();
|
||||
Project::factory()->create(['title' => ['es' => 'Borrado']])->delete();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
// Recovering something used to mean remembering which resource it had
|
||||
// been in and then turning on a filter.
|
||||
$this->get(Trash::getUrl())
|
||||
->assertSuccessful()
|
||||
->assertSee('Borrada')
|
||||
->assertSee('Borrado');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function something_can_be_put_back_from_the_bin(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->delete();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(Trash::class)
|
||||
->call('restoreRecord', Page::class, $page->id);
|
||||
|
||||
$this->assertNull($page->fresh()->deleted_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_bin_refuses_a_model_it_does_not_list(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
// An id and a class name arriving from the browser must not be able to
|
||||
// reach anything the page did not declare.
|
||||
Livewire::test(Trash::class)
|
||||
->call('restoreRecord', User::class, 1)
|
||||
->assertOk();
|
||||
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_sees_only_what_they_may_see(): void
|
||||
{
|
||||
Application::factory()->create()->delete();
|
||||
Page::factory()->create(['title' => ['es' => 'Una página']])->delete();
|
||||
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
// A viewer has no business reading a stranger's message, in the bin or
|
||||
// anywhere else.
|
||||
$this->get(Trash::getUrl())
|
||||
->assertSuccessful()
|
||||
->assertSee('Una página')
|
||||
->assertDontSee(__('panel.applications.plural'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_preview_link_points_at_the_record_it_was_made_for(): void
|
||||
{
|
||||
$project = Project::factory()->draft()->create([
|
||||
'title' => ['es' => 'Borrador'],
|
||||
'slug' => ['es' => 'borrador'],
|
||||
]);
|
||||
|
||||
$url = PreviewLink::for($project, 'project', 'es');
|
||||
|
||||
$this->assertStringContainsString('/proyectos/borrador', $url);
|
||||
$this->assertStringContainsString('preview=', $url);
|
||||
|
||||
$token = urldecode((string) parse_url($url, PHP_URL_QUERY));
|
||||
$token = str_replace('preview=', '', $token);
|
||||
|
||||
$payload = PreviewLink::open($token);
|
||||
|
||||
$this->assertSame('project', $payload['type']);
|
||||
$this->assertSame($project->id, $payload['id']);
|
||||
$this->assertSame('es', $payload['locale']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_preview_link_opens_a_draft_on_the_api(): void
|
||||
{
|
||||
$project = Project::factory()->draft()->create([
|
||||
'title' => ['es' => 'Borrador'],
|
||||
'slug' => ['es' => 'borrador'],
|
||||
]);
|
||||
|
||||
$url = PreviewLink::for($project, 'project', 'es');
|
||||
$token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY)));
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es'])
|
||||
->getJson('/api/public/v1/projects/borrador')
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es'])
|
||||
->getJson('/api/public/v1/projects/borrador?preview='.urlencode($token))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_forged_or_expired_link_opens_nothing(): void
|
||||
{
|
||||
$this->assertNull(PreviewLink::open('no-es-un-token'));
|
||||
|
||||
$project = Project::factory()->draft()->create([
|
||||
'title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador'],
|
||||
]);
|
||||
|
||||
$url = PreviewLink::for($project, 'project', 'es');
|
||||
$token = str_replace('preview=', '', urldecode((string) parse_url($url, PHP_URL_QUERY)));
|
||||
|
||||
$this->travel(PreviewLink::HOURS + 1)->hours();
|
||||
|
||||
// Short-lived on purpose: one forwarded in an e-mail thread stops
|
||||
// working rather than living for ever.
|
||||
$this->assertNull(PreviewLink::open($token));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_record_with_no_slug_in_a_language_has_no_link_for_it(): void
|
||||
{
|
||||
$project = Project::factory()->draft()->create(['slug' => ['es' => 'solo-es']]);
|
||||
|
||||
$this->assertNotNull(PreviewLink::for($project, 'project', 'es'));
|
||||
$this->assertNull(PreviewLink::for($project, 'project', 'ca'));
|
||||
}
|
||||
}
|
||||
330
tests/Feature/Panel/ReviewsTest.php
Normal file
330
tests/Feature/Panel/ReviewsTest.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Enums\ReviewEvent;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReviewRequested;
|
||||
use App\Notifications\ReviewReturned;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Handing work to somebody who is allowed to publish it.
|
||||
*
|
||||
* The feature exists because `publish` is a permission of its own: there are
|
||||
* people who can finish a piece of work and not put it on the site. What these
|
||||
* tests hold still is the handover — that asking needs no new permission, that
|
||||
* answering needs the one that made the handover necessary, and that nothing
|
||||
* can leave a record live and still marked as waiting.
|
||||
*/
|
||||
class ReviewsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Notification::fake();
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody who can write and not publish — the shipped `writer` role,
|
||||
* which is the whole reason this feature has anywhere to happen.
|
||||
*/
|
||||
protected function writer(): User
|
||||
{
|
||||
return $this->userWithRoles('writer');
|
||||
}
|
||||
|
||||
protected function draft(?User $author = null): Page
|
||||
{
|
||||
return Page::factory()->create([
|
||||
'status' => PageStatus::Draft,
|
||||
'published_at' => null,
|
||||
'author_id' => $author?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function asking_for_a_review_records_who_asked_and_what_they_said(): void
|
||||
{
|
||||
$editor = $this->userWithRoles('editor');
|
||||
$page = $this->draft($editor);
|
||||
|
||||
$page->requestReview(null, 'Falta la foto de portada', $editor);
|
||||
|
||||
$page->refresh();
|
||||
|
||||
$this->assertTrue($page->isAwaitingReview());
|
||||
$this->assertSame($editor->id, $page->review_requested_by);
|
||||
|
||||
$comment = $page->reviewComments()->first();
|
||||
|
||||
$this->assertSame(ReviewEvent::Requested, $comment->event);
|
||||
$this->assertSame('Falta la foto de portada', $comment->body);
|
||||
$this->assertSame($editor->name, $comment->author());
|
||||
}
|
||||
|
||||
/**
|
||||
* The status enum already had a `Review` value, shown in the table and
|
||||
* offered as a filter. It has to keep telling the truth.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_page_in_review_says_so_in_its_status(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
|
||||
$page->requestReview();
|
||||
|
||||
$this->assertSame(PageStatus::Review, $page->refresh()->status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing about asking is a new permission: being allowed to edit
|
||||
* something is already the right to say you have finished with it.
|
||||
*/
|
||||
#[Test]
|
||||
public function somebody_who_cannot_publish_can_still_ask(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
|
||||
$this->assertTrue($writer->cannot('pages.publish'));
|
||||
$this->assertTrue($writer->can('pages.update'));
|
||||
|
||||
$page = $this->draft($writer);
|
||||
$page->requestReview(null, null, $writer);
|
||||
|
||||
$this->assertTrue($page->refresh()->isAwaitingReview());
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinction the catalogue always made and no role exercised: a
|
||||
* separate `publish` permission is only meaningful if somebody holds
|
||||
* `update` without it.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_shipped_writer_role_is_the_one_this_exists_for(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
|
||||
$this->assertTrue($writer->can('pages.update'));
|
||||
$this->assertTrue($writer->can('projects.update'));
|
||||
$this->assertTrue($writer->can('services.update'));
|
||||
|
||||
$this->assertTrue($writer->cannot('pages.publish'));
|
||||
$this->assertTrue($writer->cannot('projects.publish'));
|
||||
$this->assertTrue($writer->cannot('services.publish'));
|
||||
|
||||
// Same reason the viewer does not have it: writing for the site and
|
||||
// reading strangers' contact details are different jobs.
|
||||
$this->assertTrue($writer->cannot('applications.view_any'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function everybody_who_could_publish_it_is_told(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
$admin = $this->userWithRoles('admin');
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
|
||||
$page = $this->draft($writer);
|
||||
$page->requestReview(null, 'Listo', $writer);
|
||||
|
||||
Notification::assertSentTo($admin, ReviewRequested::class);
|
||||
|
||||
// Being told about work you could never act on is noise.
|
||||
Notification::assertNotSentTo($viewer, ReviewRequested::class);
|
||||
Notification::assertNotSentTo($writer, ReviewRequested::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Naming somebody addresses the request, and then it is theirs alone —
|
||||
* otherwise "assigned to Ana" would mean nothing.
|
||||
*/
|
||||
#[Test]
|
||||
public function naming_a_reviewer_tells_only_them(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
$ana = $this->userWithRoles('admin');
|
||||
$other = $this->userWithRoles('admin');
|
||||
|
||||
$page = $this->draft($writer);
|
||||
$page->requestReview($ana, null, $writer);
|
||||
|
||||
Notification::assertSentTo($ana, ReviewRequested::class);
|
||||
Notification::assertNotSentTo($other, ReviewRequested::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sending_it_back_tells_the_person_who_asked(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
$page = $this->draft($writer);
|
||||
$page->requestReview(null, null, $writer);
|
||||
|
||||
$page->returnForChanges('Cambia el título, es el de otra página', $admin);
|
||||
|
||||
$page->refresh();
|
||||
|
||||
$this->assertFalse($page->isAwaitingReview());
|
||||
$this->assertSame(PageStatus::Draft, $page->status);
|
||||
|
||||
Notification::assertSentTo($writer, ReviewReturned::class);
|
||||
|
||||
$comment = $page->reviewComments()->first();
|
||||
$this->assertSame(ReviewEvent::Returned, $comment->event);
|
||||
$this->assertSame('Cambia el título, es el de otra página', $comment->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop closes on the state, not on a button, so no route can leave
|
||||
* something live and still marked as waiting for somebody.
|
||||
*/
|
||||
#[Test]
|
||||
public function publishing_closes_the_review_however_it_was_published(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
$page->requestReview();
|
||||
|
||||
// Straight to the model, as the form and the table action both do.
|
||||
$page->update(['status' => PageStatus::Published, 'published_at' => now()]);
|
||||
|
||||
$page->refresh();
|
||||
|
||||
$this->assertFalse($page->isAwaitingReview());
|
||||
$this->assertNull($page->reviewer_id);
|
||||
$this->assertSame(
|
||||
ReviewEvent::Published,
|
||||
$page->reviewComments()->first()->event,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduling is not publishing: a page whose date is next week is not on
|
||||
* the site yet, so nobody's review is over.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_future_date_does_not_close_the_review(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
$page->requestReview();
|
||||
|
||||
$page->update([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->addWeek(),
|
||||
]);
|
||||
|
||||
$this->assertTrue($page->refresh()->isAwaitingReview());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_review_columns_stay_out_of_the_change_history(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
|
||||
$page->requestReview(null, 'Nota');
|
||||
|
||||
// The thread already says this in words. Repeating it as a diff would
|
||||
// bury the edits somebody actually came looking for.
|
||||
$fields = $page->revisions()->get()->flatMap(fn ($revision) => $revision->fields());
|
||||
|
||||
$this->assertNotContains('review_requested_at', $fields);
|
||||
$this->assertNotContains('reviewer_id', $fields);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_unaddressed_review_is_in_everybodys_queue(): void
|
||||
{
|
||||
$admin = $this->userWithRoles('admin');
|
||||
$ana = $this->userWithRoles('admin');
|
||||
|
||||
$this->draft()->requestReview();
|
||||
$this->draft()->requestReview($ana);
|
||||
|
||||
// The unaddressed one, plus nothing addressed to somebody else.
|
||||
$this->assertSame(1, Page::query()->awaitingReviewBy($admin)->count());
|
||||
|
||||
// Ana sees hers and the unaddressed one.
|
||||
$this->assertSame(2, Page::query()->awaitingReviewBy($ana)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_the_same_for_projects_and_services(): void
|
||||
{
|
||||
$project = Project::factory()->create(['published_at' => null]);
|
||||
$service = Service::factory()->create(['is_published' => false, 'published_at' => null]);
|
||||
|
||||
$project->requestReview();
|
||||
$service->requestReview();
|
||||
|
||||
$this->assertTrue($project->refresh()->isAwaitingReview());
|
||||
$this->assertTrue($service->refresh()->isAwaitingReview());
|
||||
|
||||
$project->update(['published_at' => now()]);
|
||||
$service->update(['is_published' => true, 'published_at' => now()]);
|
||||
|
||||
$this->assertFalse($project->refresh()->isAwaitingReview());
|
||||
$this->assertFalse($service->refresh()->isAwaitingReview());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_comment_changes_nothing_but_is_recorded(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
$page->requestReview();
|
||||
|
||||
$page->comment('¿La foto la tienes tú?');
|
||||
|
||||
$this->assertTrue($page->refresh()->isAwaitingReview());
|
||||
$this->assertSame(
|
||||
ReviewEvent::Commented,
|
||||
$page->reviewComments()->first()->event,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_health_check_finds_a_handover_nobody_answered(): void
|
||||
{
|
||||
$fresh = $this->draft();
|
||||
$fresh->requestReview();
|
||||
|
||||
$stale = $this->draft();
|
||||
$stale->requestReview();
|
||||
$stale->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
|
||||
|
||||
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-reviews');
|
||||
|
||||
$this->assertInstanceOf(Finding::class, $finding);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_check_is_hidden_from_somebody_who_could_not_act_on_it(): void
|
||||
{
|
||||
$page = $this->draft();
|
||||
$page->requestReview();
|
||||
$page->forceFill(['review_requested_at' => now()->subDays(10)])->saveQuietly();
|
||||
|
||||
$keys = fn (User $user) => app(HealthReport::class)->runFor($user)->pluck('key');
|
||||
|
||||
$this->assertContains('stale-reviews', $keys($this->userWithRoles('admin')));
|
||||
$this->assertNotContains('stale-reviews', $keys($this->userWithRoles('viewer')));
|
||||
}
|
||||
}
|
||||
198
tests/Feature/Panel/RevisionsTest.php
Normal file
198
tests/Feature/Panel/RevisionsTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Revision;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Who changed what.
|
||||
*
|
||||
* The gap this fills is publishing above all: `pages.publish` is a separate
|
||||
* permission precisely because putting something on the public site is a
|
||||
* bigger act than writing it, and nothing recorded who did it.
|
||||
*/
|
||||
class RevisionsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creating_something_is_recorded(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->assertSame('created', $page->revisions()->sole()->event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_who_made_the_change_by_name(): void
|
||||
{
|
||||
$user = $this->userWithRoles('admin', ['name' => 'María']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$page = Page::factory()->create();
|
||||
$page->update(['title' => ['es' => 'Nuevo título']]);
|
||||
|
||||
$revision = $page->revisions()->where('event', 'updated')->sole();
|
||||
|
||||
$this->assertSame($user->id, $revision->user_id);
|
||||
|
||||
// The name is kept beside the id because people leave and accounts get
|
||||
// deleted, and "somebody published this" is worth less than a name.
|
||||
$this->assertSame('María', $revision->user_name);
|
||||
$this->assertSame('María', $revision->author());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_change_made_by_no_one_says_so(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->update(['title' => ['es' => 'Cambiado por un comando']]);
|
||||
|
||||
// The scheduler and the console have no user, and blaming the last
|
||||
// person who logged in would be a lie.
|
||||
$this->assertNull($page->revisions()->first()->user_id);
|
||||
$this->assertSame(__('panel.revisions.system'), $page->revisions()->first()->author());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_reads_differently_from_editing(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$page = Page::factory()->create([
|
||||
'status' => PageStatus::Draft,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
$page->update(['status' => PageStatus::Published, 'published_at' => now()->subMinute()]);
|
||||
|
||||
$this->assertSame('published', $page->revisions()->first()->event);
|
||||
|
||||
$page->update(['status' => PageStatus::Draft]);
|
||||
|
||||
// A history where publishing looks like any other save hides exactly
|
||||
// the thing somebody came looking for.
|
||||
$this->assertSame('unpublished', $page->revisions()->first()->event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_keeps_the_old_value_beside_the_new_one(): void
|
||||
{
|
||||
$page = Page::factory()->create(['title' => ['es' => 'Antes']]);
|
||||
|
||||
$page->update(['title' => ['es' => 'Después']]);
|
||||
|
||||
$revision = $page->revisions()->where('event', 'updated')->sole();
|
||||
$diff = $revision->diff('title');
|
||||
|
||||
$this->assertSame(['es' => 'Antes'], $diff['old']);
|
||||
$this->assertSame(['es' => 'Después'], $diff['new']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_records_only_what_changed(): void
|
||||
{
|
||||
$page = Page::factory()->create(['title' => ['es' => 'Uno'], 'is_featured' => false]);
|
||||
|
||||
$page->update(['is_featured' => true]);
|
||||
|
||||
$revision = $page->revisions()->where('event', 'updated')->sole();
|
||||
|
||||
// Storing whole records would make the history larger than the content
|
||||
// it describes.
|
||||
$this->assertSame(['is_featured'], $revision->fields());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saving_without_changing_anything_records_nothing(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$before = $page->revisions()->count();
|
||||
|
||||
$page->update(['title' => $page->getTranslations('title')]);
|
||||
|
||||
$this->assertSame($before, $page->revisions()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_previous_state_can_be_put_back(): void
|
||||
{
|
||||
$page = Page::factory()->create(['title' => ['es' => 'Original']]);
|
||||
$page->update(['title' => ['es' => 'Equivocado']]);
|
||||
|
||||
$revision = $page->revisions()->where('event', 'updated')->sole();
|
||||
|
||||
$page->forceFill($revision->previousValues())->save();
|
||||
|
||||
$this->assertSame('Original', $page->fresh()->getTranslation('title', 'es'));
|
||||
|
||||
// Restoring is itself a change, so the way back is never lost.
|
||||
$this->assertSame('updated', $page->fresh()->revisions()->first()->event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleting_and_erasing_are_told_apart(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$page->delete();
|
||||
$this->assertSame('deleted', $page->revisions()->first()->event);
|
||||
|
||||
$page->restore();
|
||||
$this->assertSame('restored', $page->revisions()->first()->event);
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
// The record is gone; its history says what happened to it.
|
||||
$this->assertSame('erased', Revision::query()
|
||||
->where('revisionable_type', $page->getMorphClass())
|
||||
->where('revisionable_id', $page->id)
|
||||
// By id, not by timestamp: these all land in the same second.
|
||||
->orderByDesc('id')
|
||||
->first()
|
||||
->event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_history_shows_on_the_record(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$page = Page::factory()->create();
|
||||
$page->update(['title' => ['es' => 'Cambiado']]);
|
||||
|
||||
$this->get(PageResource::getUrl('edit', ['record' => $page]))->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_content_type_keeps_one(): void
|
||||
{
|
||||
// Pinned: the factory randomises this one, and updating a value to
|
||||
// what it already was is correctly not a change.
|
||||
$project = Project::factory()->create(['is_featured' => false]);
|
||||
|
||||
$project->update(['is_featured' => true]);
|
||||
|
||||
$this->assertSame(2, $project->revisions()->count());
|
||||
}
|
||||
}
|
||||
116
tests/Feature/Panel/SettingsResourceTest.php
Normal file
116
tests/Feature/Panel/SettingsResourceTest.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\SettingsResource;
|
||||
use App\Filament\Resources\SettingsResource\Pages\EditSettings;
|
||||
use App\Models\Setting;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The generic settings editor.
|
||||
*
|
||||
* A setting holds arbitrary JSON — `site.maintenance` is a map containing a
|
||||
* map of translations — so the thing this screen must never do is flatten it.
|
||||
*/
|
||||
class SettingsResourceTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_list_renders_whatever_shape_a_setting_has(): void
|
||||
{
|
||||
Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Hola']]);
|
||||
Setting::set('un.escalar', 'texto');
|
||||
Setting::set('una.lista', [1, 2, 3]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
$this->get(SettingsResource::getUrl('index'))->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_nested_setting_survives_a_round_trip(): void
|
||||
{
|
||||
$value = [
|
||||
'enabled' => true,
|
||||
'message' => ['es' => 'Volvemos en un rato', 'ca' => 'Tornem de seguida'],
|
||||
'until' => null,
|
||||
];
|
||||
|
||||
Setting::set('site.maintenance', $value);
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
// Opening and saving without touching anything must leave every
|
||||
// translation exactly as it was. A key/value grid would have written
|
||||
// back the word "Array" here.
|
||||
// assertEquals, not assertSame: Postgres stores these as jsonb,
|
||||
// which does not preserve object key order. Every value here is a map
|
||||
// whose order carries no meaning.
|
||||
$this->assertEquals($value, Setting::get('site.maintenance'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_nested_value_can_be_edited(): void
|
||||
{
|
||||
Setting::set('site.maintenance', ['enabled' => false, 'message' => ['es' => 'Antiguo']]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
|
||||
->fillForm(['value' => json_encode(['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']])])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$this->assertEquals(
|
||||
['enabled' => true, 'message' => ['es' => 'Nuevo', 'ca' => 'Nou']],
|
||||
Setting::get('site.maintenance'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_typo_is_refused_rather_than_stored(): void
|
||||
{
|
||||
Setting::set('site.maintenance', ['enabled' => false]);
|
||||
|
||||
$this->actingAs($this->userWithRoles('super_admin'));
|
||||
|
||||
Livewire::test(EditSettings::class, ['record' => 'site.maintenance'])
|
||||
->fillForm(['value' => '{"enabled": tru}'])
|
||||
->call('save')
|
||||
->assertHasFormErrors(['value']);
|
||||
|
||||
// Invalid JSON must not reach a column the whole application reads.
|
||||
$this->assertSame(['enabled' => false], Setting::get('site.maintenance'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_editor_may_look_but_not_touch(): void
|
||||
{
|
||||
Setting::set('site.maintenance', ['enabled' => false]);
|
||||
|
||||
$editor = $this->userWithRoles('editor');
|
||||
|
||||
$this->assertTrue($editor->can('settings.view'));
|
||||
$this->assertFalse($editor->can('settings.update'));
|
||||
}
|
||||
}
|
||||
127
tests/Feature/Panel/ShowcaseResourcesTest.php
Normal file
127
tests/Feature/Panel/ShowcaseResourcesTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Clients\ClientResource;
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
use App\Filament\Resources\Talks\TalkResource;
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Testimonial;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The four content types a landing page is built from.
|
||||
*/
|
||||
class ShowcaseResourcesTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_editor_screen_opens(): void
|
||||
{
|
||||
$client = Client::factory()->create();
|
||||
$service = Service::factory()->create();
|
||||
$testimonial = Testimonial::factory()->create(['client_id' => $client->id]);
|
||||
$talk = Talk::factory()->create();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
foreach ([
|
||||
[ServiceResource::class, $service],
|
||||
[ClientResource::class, $client],
|
||||
[TestimonialResource::class, $testimonial],
|
||||
[TalkResource::class, $talk],
|
||||
] as [$resource, $record]) {
|
||||
$this->get($resource::getUrl('create'))->assertSuccessful();
|
||||
$this->get($resource::getUrl('edit', ['record' => $record]))->assertSuccessful();
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_a_service_is_a_separate_permission_from_writing_one(): void
|
||||
{
|
||||
$editor = $this->userWithRoles('editor');
|
||||
|
||||
// Same reasoning as projects and pages: somebody may write the offer
|
||||
// without deciding it goes on the website.
|
||||
$this->assertTrue($editor->can('services.create'));
|
||||
$this->assertTrue($editor->can('services.publish'));
|
||||
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
$this->assertTrue($viewer->can('services.view_any'));
|
||||
$this->assertFalse($viewer->can('services.publish'));
|
||||
$this->assertFalse($viewer->can('services.create'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_viewer_can_look_at_the_showcase_but_not_change_it(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
foreach ([ServiceResource::class, ClientResource::class, TestimonialResource::class, TalkResource::class] as $resource) {
|
||||
$this->assertTrue($resource::canViewAny());
|
||||
$this->assertFalse($resource::canCreate());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleting_a_client_leaves_its_projects_standing(): void
|
||||
{
|
||||
$client = Client::factory()->create();
|
||||
$project = Project::factory()->create(['client_id' => $client->id]);
|
||||
|
||||
$client->forceDelete();
|
||||
|
||||
// The work happened whether or not the organisation is still on file.
|
||||
$this->assertNotNull($project->fresh());
|
||||
$this->assertNull($project->fresh()->client_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_talk_shows_on_the_profile_of_whoever_gave_it(): void
|
||||
{
|
||||
$talk = Talk::factory()->create();
|
||||
$member = Member::factory()->create();
|
||||
|
||||
$talk->update(['member_id' => $member->id]);
|
||||
|
||||
// Written once, it appears in both places instead of being typed
|
||||
// twice and drifting.
|
||||
$this->assertSame(1, $member->talks()->count());
|
||||
$this->assertSame($talk->id, $member->talks()->sole()->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_service_knows_how_much_work_backs_it(): void
|
||||
{
|
||||
$service = Service::factory()->create();
|
||||
|
||||
$this->assertSame(0, $service->projects()->count());
|
||||
|
||||
$service->projects()->attach(Project::factory()->published()->create()->id);
|
||||
|
||||
// The number the listing shows in orange when it is zero: a service
|
||||
// with nothing behind it is a promise.
|
||||
$this->assertSame(1, $service->projects()->count());
|
||||
}
|
||||
}
|
||||
244
tests/Feature/Panel/SiteHealthTest.php
Normal file
244
tests/Feature/Panel/SiteHealthTest.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Filament\Pages\SiteHealth;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\Health\Severity;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The checks exist because the public site is somewhere else: none of these
|
||||
* faults is visible where the content is edited.
|
||||
*/
|
||||
class SiteHealthTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
Queue::fake();
|
||||
Storage::fake(config('media.disk'));
|
||||
}
|
||||
|
||||
protected function finding(string $key): Finding
|
||||
{
|
||||
return app(HealthReport::class)->runAll()->firstWhere('key', $key)
|
||||
?? throw new \RuntimeException("No finding for {$key}");
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_clean_site_reports_nothing(): void
|
||||
{
|
||||
$problems = app(HealthReport::class)->runAll()
|
||||
->filter(fn (Finding $f) => $f->severity->isProblem());
|
||||
|
||||
$this->assertTrue($problems->isEmpty(), 'Expected an empty database to be healthy');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_a_record_translated_without_a_slug(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
]);
|
||||
|
||||
$finding = $this->finding('missing-slugs');
|
||||
|
||||
// Already broken on the public site: the Catalan alternate either
|
||||
// vanishes or points at a 404.
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_two_records_answering_at_the_same_url(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'repetido'],
|
||||
]);
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Dos'], 'slug' => ['es' => 'repetido'],
|
||||
]);
|
||||
|
||||
$finding = $this->finding('duplicate-slugs');
|
||||
|
||||
// The write rule prevents this, but a slug lives inside JSON so it can
|
||||
// never be a database constraint — imports and seeders get through.
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_same_slug_in_two_languages_is_not_a_duplicate(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno', 'en' => 'One'],
|
||||
'slug' => ['es' => 'igual', 'en' => 'igual'],
|
||||
]);
|
||||
|
||||
$this->assertSame(Severity::Ok, $this->finding('duplicate-slugs')->severity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_an_image_whose_file_is_gone(): void
|
||||
{
|
||||
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
|
||||
|
||||
$finding = $this->finding('broken-images');
|
||||
|
||||
// The API keeps serving the URL — it has no reason to doubt it — so
|
||||
// the only symptom is a broken image, on the other site.
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_still_in_the_queue_is_not_broken(): void
|
||||
{
|
||||
Media::factory()->create(['path' => 'projects/pending.jpg']);
|
||||
|
||||
// It is early, not broken. That is a different check.
|
||||
$this->assertSame(Severity::Ok, $this->finding('broken-images')->severity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_notices_when_nothing_is_processing_images(): void
|
||||
{
|
||||
Media::factory()->create([
|
||||
'path' => 'projects/stuck.jpg',
|
||||
'created_at' => now()->subHours(3),
|
||||
]);
|
||||
|
||||
$finding = $this->finding('stalled-media');
|
||||
|
||||
// The site keeps working, images just lose their dimensions and
|
||||
// srcset — which is exactly why nobody would notice.
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_a_published_image_nobody_described(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Con portada'],
|
||||
'slug' => ['es' => 'con-portada'],
|
||||
'cover_image' => 'projects/cover.jpg',
|
||||
'cover_image_alt' => null,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $this->finding('images-without-alt')->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_a_call_left_open_past_its_closing_date(): void
|
||||
{
|
||||
Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => now()->subWeek(),
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $this->finding('expired-calls')->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_counts_people_who_have_been_waiting_too_long(): void
|
||||
{
|
||||
Application::factory()->create(['created_at' => now()->subDays(10)]);
|
||||
Application::factory()->create(['created_at' => now()->subHours(2)]);
|
||||
|
||||
$finding = $this->finding('unanswered-submissions');
|
||||
|
||||
// Somebody offered to help and heard nothing, which costs more than
|
||||
// any other fault on the page — hence an error, not a warning.
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_check_is_hidden_from_anyone_who_may_not_see_what_it_counts(): void
|
||||
{
|
||||
Application::factory()->create(['created_at' => now()->subDays(10)]);
|
||||
|
||||
$editor = $this->userWithRoles('editor');
|
||||
$viewer = $this->userWithRoles('viewer');
|
||||
|
||||
$report = app(HealthReport::class);
|
||||
|
||||
// Counting submissions is knowing they exist, and who may know that is
|
||||
// the same question as who may read the inbox.
|
||||
$this->assertNotNull($report->runFor($editor)->firstWhere('key', 'unanswered-submissions'));
|
||||
$this->assertNull($report->runFor($viewer)->firstWhere('key', 'unanswered-submissions'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findings_come_back_worst_first(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Sin portada'],
|
||||
'slug' => ['es' => 'sin-portada'],
|
||||
]);
|
||||
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
|
||||
|
||||
$severities = app(HealthReport::class)->runAll()
|
||||
->map(fn (Finding $f) => $f->severity->weight())
|
||||
->all();
|
||||
|
||||
$sorted = $severities;
|
||||
sort($sorted);
|
||||
|
||||
$this->assertSame($sorted, $severities);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_page_renders_for_anyone_who_can_reach_the_panel(): void
|
||||
{
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
$this->get(SiteHealth::getUrl())->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_fails_a_deploy_when_something_is_broken(): void
|
||||
{
|
||||
Media::factory()->processed()->create(['path' => 'projects/vanished.jpg']);
|
||||
|
||||
$this->artisan('site:check')->assertFailed();
|
||||
|
||||
// A warning-only site can still deploy when the threshold says so.
|
||||
$this->artisan('site:check', ['--fail-on' => 'never'])->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_can_ignore_warnings_and_fail_only_on_faults(): void
|
||||
{
|
||||
// A warning, not a fault: nothing a visitor hits.
|
||||
Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => now()->subWeek(),
|
||||
]);
|
||||
|
||||
$this->artisan('site:check', ['--fail-on' => 'error'])->assertSuccessful();
|
||||
$this->artisan('site:check')->assertFailed();
|
||||
}
|
||||
}
|
||||
195
tests/Feature/Panel/StaleTranslationsTest.php
Normal file
195
tests/Feature/Panel/StaleTranslationsTest.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\TranslationFreshness;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Translations the original has moved on without.
|
||||
*
|
||||
* The fault this catches is the one nothing else can see: the text is there in
|
||||
* every language, so every screen calls the record finished, and one of those
|
||||
* languages has been saying something the source stopped saying. It is worked
|
||||
* out from the change history, so what these tests really hold still is that
|
||||
* the history is being read the way it is written.
|
||||
*/
|
||||
class StaleTranslationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function translated(): Page
|
||||
{
|
||||
return Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'],
|
||||
'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'],
|
||||
'body' => ['es' => 'Texto', 'en' => 'Text', 'ca' => 'Text'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_record_nobody_has_edited_is_not_out_of_date(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
// Written in three languages at once: whatever else is true of it,
|
||||
// none of them is behind another.
|
||||
$this->assertSame([], TranslationFreshness::for($page));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function editing_the_original_leaves_the_other_languages_behind(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('title', 'es', 'Uno corregido')->save();
|
||||
|
||||
$stale = TranslationFreshness::for($page->fresh());
|
||||
|
||||
$this->assertSame(['en', 'ca'], array_keys($stale));
|
||||
|
||||
// And it says since when, which is what decides how urgent it is.
|
||||
$this->assertTrue($stale['ca']->isToday());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function catching_a_translation_up_clears_it(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('body', 'es', 'Texto nuevo')->save();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->fresh()->setTranslation('body', 'ca', 'Text nou')->save();
|
||||
|
||||
$stale = TranslationFreshness::for($page->fresh());
|
||||
|
||||
// Catalan caught up; English never did.
|
||||
$this->assertArrayNotHasKey('ca', $stale);
|
||||
$this->assertArrayHasKey('en', $stale);
|
||||
}
|
||||
|
||||
/**
|
||||
* A language with nothing in it is not out of date, it is untranslated —
|
||||
* and there is already a check that says so. Reporting both about one
|
||||
* record would just make two lists that never empty.
|
||||
*/
|
||||
#[Test]
|
||||
public function a_missing_translation_is_not_a_stale_one(): void
|
||||
{
|
||||
$page = Page::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno'],
|
||||
'slug' => ['es' => 'uno'],
|
||||
]);
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('title', 'es', 'Uno corregido')->save();
|
||||
|
||||
$this->assertSame([], TranslationFreshness::for($page->fresh()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renaming the Spanish address does not make the Catalan text less true,
|
||||
* and slugs are the one translatable field that routinely changes on their
|
||||
* own — counting them would mark half the site out of date the first time
|
||||
* somebody tidied a URL.
|
||||
*/
|
||||
#[Test]
|
||||
public function renaming_a_slug_does_not_make_a_translation_stale(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('slug', 'es', 'uno-mejor')->save();
|
||||
|
||||
$this->assertSame([], TranslationFreshness::for($page->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function editing_a_translation_does_not_make_the_original_stale(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('title', 'ca', 'U corregit')->save();
|
||||
|
||||
// The source language is never reported against itself, and touching
|
||||
// Catalan says nothing about English.
|
||||
$this->assertSame([], TranslationFreshness::for($page->fresh()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_health_check_reports_it(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('body', 'es', 'Texto corregido')->save();
|
||||
|
||||
$finding = app(HealthReport::class)->runAll()->firstWhere('key', 'stale-translations');
|
||||
|
||||
$this->assertInstanceOf(Finding::class, $finding);
|
||||
$this->assertSame(1, $finding->count);
|
||||
$this->assertStringContainsString('ca', $finding->samples[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_every_translated_type(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Proyecto', 'ca' => 'Projecte'],
|
||||
'slug' => ['es' => 'proyecto', 'ca' => 'projecte'],
|
||||
]);
|
||||
|
||||
$this->travel(1)->days();
|
||||
$project->setTranslation('title', 'es', 'Proyecto nuevo')->save();
|
||||
|
||||
$this->assertArrayHasKey('ca', TranslationFreshness::for($project->fresh()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter's half: the answer cannot be a SQL condition, so it is worked
|
||||
* out per record and handed to the query as a list.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_panel_can_ask_for_the_ones_that_fell_behind(): void
|
||||
{
|
||||
$behind = $this->translated();
|
||||
$fine = $this->translated();
|
||||
|
||||
$this->travel(1)->days();
|
||||
$behind->setTranslation('title', 'es', 'Uno corregido')->save();
|
||||
|
||||
$ids = TranslationFreshness::staleIds(Page::query());
|
||||
|
||||
$this->assertContains($behind->id, $ids);
|
||||
$this->assertNotContains($fine->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer is cached against the record's `updated_at`, so it has to be
|
||||
* recomputed the moment anything about that record changes.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_cached_answer_does_not_survive_the_next_edit(): void
|
||||
{
|
||||
$page = $this->translated();
|
||||
|
||||
$this->assertSame([], TranslationFreshness::for($page));
|
||||
|
||||
$this->travel(1)->days();
|
||||
$page->setTranslation('title', 'es', 'Uno corregido')->save();
|
||||
|
||||
$this->assertNotSame([], TranslationFreshness::for($page->fresh()));
|
||||
}
|
||||
}
|
||||
135
tests/Feature/Panel/UserSessionsTest.php
Normal file
135
tests/Feature/Panel/UserSessionsTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use App\Filament\Resources\Users\Pages\EditUser;
|
||||
use App\Filament\Resources\Users\RelationManagers\SessionsRelationManager;
|
||||
use App\Models\User;
|
||||
use App\Models\UserSession;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Where an account is signed in from, read straight from the table Laravel's
|
||||
* own database session driver already writes to.
|
||||
*
|
||||
* Deactivating somebody here has always cut them off on their *next*
|
||||
* request; what was missing was any way to see a session already in
|
||||
* progress, or end one before that next request happened on its own. Nothing
|
||||
* here writes to `sessions` except the revoke action deleting a row — the
|
||||
* table itself is entirely Laravel's.
|
||||
*/
|
||||
class UserSessionsTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function plantSession(User $user, array $attributes = []): UserSession
|
||||
{
|
||||
return UserSession::create(array_merge([
|
||||
'id' => Str::random(40),
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '203.0.113.10',
|
||||
'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15',
|
||||
'payload' => base64_encode(serialize([])),
|
||||
'last_activity' => now()->subMinutes(5)->timestamp,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_admin_sees_the_accounts_open_sessions(): void
|
||||
{
|
||||
$target = $this->userWithRoles('editor');
|
||||
$this->plantSession($target);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(SessionsRelationManager::class, [
|
||||
'ownerRecord' => $target,
|
||||
'pageClass' => EditUser::class,
|
||||
])
|
||||
->assertSee('203.0.113.10')
|
||||
->assertSee('Safari · macOS');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revoking_a_session_deletes_the_row(): void
|
||||
{
|
||||
$target = $this->userWithRoles('editor');
|
||||
$session = $this->plantSession($target);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(SessionsRelationManager::class, [
|
||||
'ownerRecord' => $target,
|
||||
'pageClass' => EditUser::class,
|
||||
])->callTableAction('revoke', $session);
|
||||
|
||||
$this->assertDatabaseMissing('sessions', ['id' => $session->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one row this screen must never offer to end: doing so would sign
|
||||
* out whoever is looking at it, mid-click.
|
||||
*/
|
||||
#[Test]
|
||||
public function the_viewers_own_current_session_cannot_be_revoked_from_here(): void
|
||||
{
|
||||
$admin = $this->userWithRoles('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
// Force a request so Laravel actually starts a session and this test
|
||||
// knows the real session id, then plant a row using that same id —
|
||||
// exactly what the real session driver would have written.
|
||||
$this->get(EditUser::getUrl(['record' => $admin]));
|
||||
$currentId = session()->getId();
|
||||
|
||||
$currentSession = $this->plantSession($admin, ['id' => $currentId]);
|
||||
$other = $this->plantSession($admin);
|
||||
|
||||
$manager = Livewire::test(SessionsRelationManager::class, [
|
||||
'ownerRecord' => $admin,
|
||||
'pageClass' => EditUser::class,
|
||||
]);
|
||||
|
||||
$manager->assertTableActionHidden('revoke', $currentSession);
|
||||
$manager->assertTableActionVisible('revoke', $other);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function somebody_without_users_update_cannot_see_the_tab(): void
|
||||
{
|
||||
$target = $this->userWithRoles('editor');
|
||||
|
||||
$this->actingAs($this->userWithRoles('viewer'));
|
||||
|
||||
$this->assertFalse(SessionsRelationManager::canViewForRecord($target, EditUser::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_device_with_no_recognisable_browser_or_platform_says_so(): void
|
||||
{
|
||||
$target = $this->userWithRoles('editor');
|
||||
$this->plantSession($target, ['user_agent' => 'curl/8.4.0']);
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
Livewire::test(SessionsRelationManager::class, [
|
||||
'ownerRecord' => $target,
|
||||
'pageClass' => EditUser::class,
|
||||
])->assertSee(__('panel.sessions.unknown_device'));
|
||||
}
|
||||
}
|
||||
96
tests/Feature/Site/ContentVersionTest.php
Normal file
96
tests/Feature/Site/ContentVersionTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Site;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\ContentVersion;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The number that tells the frontend its cache is stale.
|
||||
*
|
||||
* The public API is cached with a long `stale-while-revalidate`, which is
|
||||
* right for an SSR frontend and wrong for an urgent correction. This is how a
|
||||
* fix stops sitting behind a stale copy without a purge webhook or anything to
|
||||
* deploy.
|
||||
*/
|
||||
class ContentVersionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_frontend_is_given_a_version(): void
|
||||
{
|
||||
$this->getJson('/api/public/v1/site')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.content_version', ContentVersion::current());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function publishing_something_moves_it(): void
|
||||
{
|
||||
$before = ContentVersion::current();
|
||||
|
||||
Project::factory()->published()->create();
|
||||
|
||||
$this->assertGreaterThan($before, ContentVersion::current());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_draft_nobody_can_see_does_not(): void
|
||||
{
|
||||
Project::factory()->draft()->create();
|
||||
|
||||
$before = ContentVersion::current();
|
||||
|
||||
Project::query()->first()->update(['title' => ['es' => 'Otro borrador']]);
|
||||
|
||||
// Editing something invisible invalidates nobody's cache, and bumping
|
||||
// on every keystroke of a half-written page would make the number
|
||||
// meaningless.
|
||||
$this->assertSame($before, ContentVersion::current());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function taking_something_down_moves_it_too(): void
|
||||
{
|
||||
$page = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$before = ContentVersion::current();
|
||||
|
||||
$page->update(['status' => PageStatus::Draft]);
|
||||
|
||||
// Going away is exactly as visible to a reader as arriving.
|
||||
$this->assertGreaterThan($before, ContentVersion::current());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleting_published_work_moves_it(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create();
|
||||
|
||||
$before = ContentVersion::current();
|
||||
|
||||
$project->delete();
|
||||
|
||||
$this->assertGreaterThan($before, ContentVersion::current());
|
||||
}
|
||||
}
|
||||
207
tests/Feature/Site/MaintenanceModeTest.php
Normal file
207
tests/Feature/Site/MaintenanceModeTest.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Site;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Support\Maintenance;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MaintenanceModeTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_public_api_answers_503_with_the_notice(): void
|
||||
{
|
||||
Project::factory()->published()->create();
|
||||
|
||||
Maintenance::enable(message: ['es' => 'Volvemos en una hora.']);
|
||||
|
||||
$response = $this->getJson('/api/public/v1/projects');
|
||||
|
||||
$response->assertStatus(503)
|
||||
->assertJsonPath('maintenance', true)
|
||||
->assertJsonPath('message', 'Volvemos en una hora.');
|
||||
|
||||
$this->assertNotNull($response->headers->get('Retry-After'));
|
||||
|
||||
// A cached 503 would outlive the maintenance itself.
|
||||
$this->assertSame('no-store, private', $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_notice_is_returned_in_the_requested_language(): void
|
||||
{
|
||||
Maintenance::enable(message: [
|
||||
'es' => 'Estamos de obras.',
|
||||
'ca' => 'Estem d\'obres.',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/site?lang=ca')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', 'Estem d\'obres.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_language_without_a_written_notice_falls_back(): void
|
||||
{
|
||||
Maintenance::enable(message: ['es' => 'Solo en castellano.']);
|
||||
|
||||
// Falls back to the default locale's wording rather than showing
|
||||
// nothing to an English visitor.
|
||||
$this->getJson('/api/public/v1/site?lang=en')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', 'Solo en castellano.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function with_no_notice_written_a_translated_default_is_used(): void
|
||||
{
|
||||
Maintenance::enable();
|
||||
|
||||
$this->getJson('/api/public/v1/site?lang=en')
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', __('maintenance.default_message', [], 'en'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_panel_keeps_working_while_the_site_is_down(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
Maintenance::enable();
|
||||
|
||||
// The whole point: the collective must be able to fix whatever caused
|
||||
// the maintenance.
|
||||
$this->actingAs($this->userWithRoles('admin'))
|
||||
->get('/admin')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_admin_api_keeps_working_while_the_site_is_down(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
Maintenance::enable();
|
||||
|
||||
$user = $this->userWithRoles('editor');
|
||||
|
||||
$this->getJson('/api/v1/me', $this->apiHeadersFor($user))->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_preview_secret_lets_the_team_see_the_real_site(): void
|
||||
{
|
||||
config()->set('site.preview_token', 'the-secret');
|
||||
|
||||
Project::factory()->published()->create();
|
||||
|
||||
Maintenance::enable();
|
||||
|
||||
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_trusted_ip_also_bypasses_it(): void
|
||||
{
|
||||
config()->set('site.trusted_ips', ['127.0.0.1']);
|
||||
|
||||
Project::factory()->published()->create();
|
||||
|
||||
Maintenance::enable();
|
||||
|
||||
$this->getJson('/api/public/v1/projects')->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_lifts_itself_once_the_window_has_passed(): void
|
||||
{
|
||||
Maintenance::enable(until: now()->addHour()->toImmutable());
|
||||
$this->assertTrue(Maintenance::isEnabled());
|
||||
|
||||
$this->travel(2)->hours();
|
||||
|
||||
// A forgotten flag must not keep the site down forever.
|
||||
$this->assertFalse(Maintenance::isEnabled());
|
||||
$this->getJson('/api/public/v1/site')->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function retry_after_reflects_the_remaining_window(): void
|
||||
{
|
||||
Maintenance::enable(until: now()->addMinutes(10)->toImmutable());
|
||||
|
||||
$retryAfter = (int) $this->getJson('/api/public/v1/site')
|
||||
->assertStatus(503)
|
||||
->headers->get('Retry-After');
|
||||
|
||||
$this->assertGreaterThan(500, $retryAfter);
|
||||
$this->assertLessThanOrEqual(600, $retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabling_brings_the_site_back(): void
|
||||
{
|
||||
Maintenance::enable();
|
||||
$this->getJson('/api/public/v1/site')->assertStatus(503);
|
||||
|
||||
Maintenance::disable();
|
||||
$this->getJson('/api/public/v1/site')->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_setting_is_cached_but_a_change_takes_effect_at_once(): void
|
||||
{
|
||||
$this->getJson('/api/public/v1/site')->assertOk();
|
||||
|
||||
Maintenance::enable();
|
||||
|
||||
// Writes clear the cache entry, so there is no stale window in which
|
||||
// the site stays up after someone flipped the switch.
|
||||
$this->getJson('/api/public/v1/site')->assertStatus(503);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toggling_it_needs_its_own_permission(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$editor = $this->userWithRoles('editor');
|
||||
$admin = $this->userWithRoles('admin');
|
||||
|
||||
// Editors can see the state but not take the site down.
|
||||
$this->assertTrue($editor->can('settings.view'));
|
||||
$this->assertFalse($editor->can('settings.maintenance'));
|
||||
$this->assertTrue($admin->can('settings.maintenance'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_reports_and_toggles(): void
|
||||
{
|
||||
$this->artisan('site:maintenance', ['--message' => 'Obras'])
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertTrue(Maintenance::isEnabled());
|
||||
$this->assertSame('Obras', Maintenance::message('es'));
|
||||
|
||||
$this->artisan('site:maintenance', ['--up' => true])->assertSuccessful();
|
||||
|
||||
$this->assertFalse(Maintenance::isEnabled());
|
||||
}
|
||||
}
|
||||
193
tests/Feature/Site/PruneAndRedirectHealthTest.php
Normal file
193
tests/Feature/Site/PruneAndRedirectHealthTest.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Site;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Revision;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\HealthReport;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Keeping the system from rotting: redirects that make things worse, and the
|
||||
* three kinds of debris that otherwise accumulate for ever.
|
||||
*/
|
||||
class PruneAndRedirectHealthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
Storage::fake(config('media.disk'));
|
||||
}
|
||||
|
||||
protected function finding(string $key): Finding
|
||||
{
|
||||
return app(HealthReport::class)->runAll()->firstWhere('key', $key)
|
||||
?? throw new \RuntimeException("No finding for {$key}");
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_a_redirect_that_chains_into_another(): void
|
||||
{
|
||||
Redirect::factory()->create(['from' => '/a', 'to' => '/b', 'locale' => 'es']);
|
||||
Redirect::factory()->create(['from' => '/b', 'to' => '/c', 'locale' => 'es']);
|
||||
|
||||
$finding = $this->finding('broken-redirects');
|
||||
|
||||
// The visitor is sent somewhere that sends them somewhere else: a
|
||||
// round trip per hop, and eventually a loop.
|
||||
$this->assertSame(Severity::Error, $finding->severity);
|
||||
$this->assertSame(1, $finding->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_finds_a_redirect_that_lands_on_nothing(): void
|
||||
{
|
||||
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/no-existe']);
|
||||
|
||||
// Worse than the 404 it was meant to prevent: the visitor is bounced
|
||||
// and lands on the same nothing.
|
||||
$this->assertSame(1, $this->finding('redirects-to-nowhere')->count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_redirect_to_a_real_page_is_fine(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Vivo'], 'slug' => ['es' => 'vivo'],
|
||||
]);
|
||||
|
||||
Redirect::factory()->create(['from' => '/viejo', 'to' => '/proyectos/vivo']);
|
||||
|
||||
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_redirect_off_this_site_is_not_ours_to_verify(): void
|
||||
{
|
||||
Redirect::factory()->create(['from' => '/viejo', 'to' => 'https://otra-web.example/algo']);
|
||||
|
||||
$this->assertSame(Severity::Ok, $this->finding('redirects-to-nowhere')->severity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function pruning_keeps_the_first_and_last_of_a_history(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
|
||||
foreach (range(1, 4) as $n) {
|
||||
$page->update(['title' => ['es' => "Versión {$n}"]]);
|
||||
}
|
||||
|
||||
Revision::query()->update(['created_at' => now()->subYears(2)]);
|
||||
|
||||
$this->artisan('content:prune')->assertSuccessful();
|
||||
|
||||
// Two facts survive whatever happens: when it appeared, and who
|
||||
// touched it last.
|
||||
$this->assertSame(2, $page->revisions()->count());
|
||||
// `reorder`, because the relation sorts newest-first by default and
|
||||
// an added orderBy would only be a tie-breaker behind it.
|
||||
$this->assertSame('created', $page->revisions()->reorder('id')->first()->event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recent_history_is_left_alone(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->update(['title' => ['es' => 'Reciente']]);
|
||||
|
||||
$this->artisan('content:prune')->assertSuccessful();
|
||||
|
||||
$this->assertSame(2, $page->revisions()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_erases_what_has_been_in_the_bin_long_enough(): void
|
||||
{
|
||||
$old = Page::factory()->create();
|
||||
$old->delete();
|
||||
$old->forceFill(['deleted_at' => now()->subYear()])->save();
|
||||
|
||||
$recent = Page::factory()->create();
|
||||
$recent->delete();
|
||||
|
||||
$this->artisan('content:prune')->assertSuccessful();
|
||||
|
||||
$this->assertNull(Page::withTrashed()->find($old->id));
|
||||
$this->assertNotNull(Page::withTrashed()->find($recent->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_dry_run_changes_nothing(): void
|
||||
{
|
||||
$page = Page::factory()->create();
|
||||
$page->delete();
|
||||
$page->forceFill(['deleted_at' => now()->subYear()])->save();
|
||||
|
||||
$this->artisan('content:prune', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
$this->assertNotNull(Page::withTrashed()->find($page->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_deletes_image_files_nothing_points_at(): void
|
||||
{
|
||||
$disk = Storage::disk(config('media.disk'));
|
||||
|
||||
$disk->put('projects/en-uso.jpg', 'x');
|
||||
$disk->put('projects/huerfana.jpg', 'x');
|
||||
|
||||
Project::factory()->create(['cover_image' => 'projects/en-uso.jpg']);
|
||||
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
|
||||
|
||||
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
|
||||
|
||||
$this->assertTrue($disk->exists('projects/en-uso.jpg'));
|
||||
$this->assertFalse($disk->exists('projects/huerfana.jpg'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_belonging_to_something_in_the_bin_is_kept(): void
|
||||
{
|
||||
$disk = Storage::disk(config('media.disk'));
|
||||
$disk->put('projects/borrado.jpg', 'x');
|
||||
|
||||
$project = Project::factory()->create(['cover_image' => 'projects/borrado.jpg']);
|
||||
$project->delete();
|
||||
|
||||
$this->artisan('content:prune', ['--media' => true])->assertSuccessful();
|
||||
|
||||
// It can still be restored, and restoring it to a missing image would
|
||||
// be worse than keeping a file.
|
||||
$this->assertTrue($disk->exists('projects/borrado.jpg'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function files_are_left_alone_unless_asked_for(): void
|
||||
{
|
||||
$disk = Storage::disk(config('media.disk'));
|
||||
$disk->put('projects/huerfana.jpg', 'x');
|
||||
Media::factory()->create(['path' => 'projects/huerfana.jpg']);
|
||||
|
||||
$this->artisan('content:prune')->assertSuccessful();
|
||||
|
||||
// Deleting a file is the one thing here that restoring a row cannot
|
||||
// undo, so it never happens by routine.
|
||||
$this->assertTrue($disk->exists('projects/huerfana.jpg'));
|
||||
}
|
||||
}
|
||||
225
tests/Feature/Site/PublicationWindowTest.php
Normal file
225
tests/Feature/Site/PublicationWindowTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Site;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Content that is only true between two dates.
|
||||
*
|
||||
* The window is enforced by the scopes rather than by a scheduled job, so it
|
||||
* holds even when nothing is running — a queue worker being down must not put
|
||||
* last month's notice back on the site.
|
||||
*/
|
||||
class PublicationWindowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_page_stays_off_the_site_until_its_date(): void
|
||||
{
|
||||
Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'title' => ['es' => 'El anuncio'],
|
||||
'slug' => ['es' => 'el-anuncio'],
|
||||
'published_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/pages/el-anuncio')->assertNotFound();
|
||||
|
||||
$this->travel(2)->days();
|
||||
|
||||
$this->getJson('/api/public/v1/pages/el-anuncio')->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_page_drops_off_the_site_on_its_end_date(): void
|
||||
{
|
||||
Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'title' => ['es' => 'Taller de octubre'],
|
||||
'slug' => ['es' => 'taller'],
|
||||
'published_at' => now()->subWeek(),
|
||||
'unpublish_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/public/v1/pages/taller')->assertOk();
|
||||
|
||||
$this->travel(2)->days();
|
||||
|
||||
// Nobody has to remember to take it down, which is the whole point:
|
||||
// nobody ever does.
|
||||
$this->getJson('/api/public/v1/pages/taller')->assertNotFound();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_window_holds_without_anything_running(): void
|
||||
{
|
||||
Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'title' => ['es' => 'Caducada'],
|
||||
'slug' => ['es' => 'caducada'],
|
||||
'published_at' => now()->subMonth(),
|
||||
'unpublish_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// No command, no queue, no scheduler — the scope is what decides.
|
||||
$this->getJson('/api/public/v1/pages')
|
||||
->assertOk()
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_expired_project_leaves_the_url_list_too(): void
|
||||
{
|
||||
Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Terminado'],
|
||||
'slug' => ['es' => 'terminado'],
|
||||
'unpublish_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$slugs = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
|
||||
|
||||
// Otherwise the frontend would keep it in the sitemap and point search
|
||||
// engines at a 404.
|
||||
$this->assertEmpty($slugs['projects'] ?? []);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preview_still_reaches_content_outside_its_window(): void
|
||||
{
|
||||
config()->set('site.preview_token', 'secreto');
|
||||
|
||||
Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'title' => ['es' => 'Aún no'],
|
||||
'slug' => ['es' => 'aun-no'],
|
||||
'published_at' => now()->addWeek(),
|
||||
]);
|
||||
|
||||
// Checking a scheduled page before its date is exactly what preview is
|
||||
// for.
|
||||
$this->withHeaders(['X-Preview-Token' => 'secreto'])
|
||||
->getJson('/api/public/v1/pages/aun-no')
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_closes_a_call_whose_date_has_passed(): void
|
||||
{
|
||||
$call = Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => now()->subWeek(),
|
||||
]);
|
||||
|
||||
$this->artisan('content:schedule')->assertSuccessful();
|
||||
|
||||
// The site already stopped offering it; this is about the panel not
|
||||
// claiming otherwise, and a counter that stays worth reading.
|
||||
$this->assertSame(CallStatus::Closed, $call->refresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_command_leaves_a_call_that_is_still_open(): void
|
||||
{
|
||||
$open = Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => now()->addWeek(),
|
||||
]);
|
||||
|
||||
$undated = Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('content:schedule')->assertSuccessful();
|
||||
|
||||
$this->assertSame(CallStatus::Open, $open->refresh()->status);
|
||||
$this->assertSame(CallStatus::Open, $undated->refresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_dry_run_changes_nothing(): void
|
||||
{
|
||||
$call = Call::factory()->create([
|
||||
'project_id' => null,
|
||||
'status' => CallStatus::Open,
|
||||
'closes_at' => now()->subWeek(),
|
||||
]);
|
||||
|
||||
$this->artisan('content:schedule', ['--dry-run' => true])->assertSuccessful();
|
||||
|
||||
$this->assertSame(CallStatus::Open, $call->refresh()->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_panel_can_tell_the_three_states_apart(): void
|
||||
{
|
||||
$scheduled = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->addDays(3),
|
||||
]);
|
||||
|
||||
$expired = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->subMonth(),
|
||||
'unpublish_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$live = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// A panel that reports "Published" for something the site is not
|
||||
// showing is lying to whoever wrote it.
|
||||
$this->assertTrue($scheduled->isScheduled());
|
||||
$this->assertFalse($scheduled->hasExpired());
|
||||
|
||||
$this->assertTrue($expired->hasExpired());
|
||||
$this->assertFalse($expired->isScheduled());
|
||||
|
||||
$this->assertFalse($live->isScheduled());
|
||||
$this->assertFalse($live->hasExpired());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function content_about_to_go_can_be_spotted_in_advance(): void
|
||||
{
|
||||
$soon = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->subMonth(),
|
||||
'unpublish_at' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
$later = Page::factory()->create([
|
||||
'status' => PageStatus::Published,
|
||||
'published_at' => now()->subMonth(),
|
||||
'unpublish_at' => now()->addMonths(2),
|
||||
]);
|
||||
|
||||
$this->assertTrue($soon->expiresSoon());
|
||||
$this->assertFalse($later->expiresSoon());
|
||||
}
|
||||
}
|
||||
168
tests/Feature/Site/RedirectsTest.php
Normal file
168
tests/Feature/Site/RedirectsTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Site;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The net under a renamed slug.
|
||||
*
|
||||
* Renaming one breaks every link anybody shared, silently: nothing here
|
||||
* notices and the frontend only finds out when a visitor gets a 404. The
|
||||
* moment the slug changes is the only moment the old address is still known.
|
||||
*/
|
||||
class RedirectsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function renaming_a_slug_leaves_a_redirect_behind(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'nombre-viejo'],
|
||||
]);
|
||||
|
||||
$project->update(['slug' => ['es' => 'nombre-nuevo']]);
|
||||
|
||||
$redirect = Redirect::query()->sole();
|
||||
|
||||
$this->assertSame('/proyectos/nombre-viejo', $redirect->from);
|
||||
$this->assertSame('/proyectos/nombre-nuevo', $redirect->to);
|
||||
$this->assertSame(301, $redirect->status);
|
||||
$this->assertSame('es', $redirect->locale);
|
||||
$this->assertTrue($redirect->wasAutomatic());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function each_language_gets_its_own_rule(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno', 'ca' => 'U'],
|
||||
'slug' => ['es' => 'viejo-es', 'ca' => 'vell-ca'],
|
||||
]);
|
||||
|
||||
$project->update(['slug' => ['es' => 'nuevo-es', 'ca' => 'nou-ca']]);
|
||||
|
||||
// The Catalan URL carries its own prefix and its own path segment, so
|
||||
// one rule could never cover both.
|
||||
$this->assertSame(
|
||||
['/proyectos/viejo-es' => '/proyectos/nuevo-es', '/ca/projectes/vell-ca' => '/ca/projectes/nou-ca'],
|
||||
Redirect::query()->pluck('to', 'from')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function adding_a_translation_is_not_a_rename(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno'],
|
||||
'slug' => ['es' => 'uno'],
|
||||
]);
|
||||
|
||||
$project->update(['slug' => ['es' => 'uno', 'ca' => 'u']]);
|
||||
|
||||
// Nothing broke: there was no Catalan address before.
|
||||
$this->assertSame(0, Redirect::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_second_rename_does_not_leave_a_chain_behind(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'v1'],
|
||||
]);
|
||||
|
||||
$project->update(['slug' => ['es' => 'v2']]);
|
||||
$project->update(['slug' => ['es' => 'v3']]);
|
||||
|
||||
// Both old addresses point at the current one. A frontend following a
|
||||
// chain would cost an extra round trip per hop.
|
||||
$this->assertSame(
|
||||
['/proyectos/v1' => '/proyectos/v3', '/proyectos/v2' => '/proyectos/v3'],
|
||||
Redirect::query()->orderBy('from')->pluck('to', 'from')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function renaming_back_does_not_create_a_loop(): void
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Uno'], 'slug' => ['es' => 'original'],
|
||||
]);
|
||||
|
||||
$project->update(['slug' => ['es' => 'cambiado']]);
|
||||
$project->update(['slug' => ['es' => 'original']]);
|
||||
|
||||
// A rule from a path to itself would send a browser round for ever.
|
||||
foreach (Redirect::query()->get() as $redirect) {
|
||||
$this->assertFalse($redirect->isCircular(), "{$redirect->from} → {$redirect->to}");
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_works_for_every_kind_of_record_with_a_public_url(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => ['es' => 'Servicio'], 'slug' => ['es' => 'antiguo'],
|
||||
]);
|
||||
|
||||
$service->update(['slug' => ['es' => 'moderno']]);
|
||||
|
||||
$this->assertSame('/servicios/antiguo', Redirect::query()->sole()->from);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_frontend_gets_the_whole_list_in_one_request(): void
|
||||
{
|
||||
Redirect::factory()->create(['from' => '/vieja', 'to' => '/nueva', 'locale' => null]);
|
||||
Redirect::factory()->disabled()->create(['from' => '/apagada', 'to' => '/otra']);
|
||||
|
||||
$data = $this->getJson('/api/public/v1/redirects')->assertOk()->json('data');
|
||||
|
||||
// One request rather than a lookup per 404: there are never many, and
|
||||
// an SSR frontend would otherwise ask on every miss it serves.
|
||||
$this->assertCount(1, $data);
|
||||
$this->assertSame('/vieja', $data[0]['from']);
|
||||
$this->assertNull($data[0]['locale']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paths_are_normalised_however_they_were_typed(): void
|
||||
{
|
||||
$this->assertSame('/proyectos/uno', Redirect::normalise('proyectos/uno'));
|
||||
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno/'));
|
||||
$this->assertSame('/proyectos/uno', Redirect::normalise('/proyectos/uno?utm=x'));
|
||||
|
||||
// So a rule typed by hand matches one written automatically.
|
||||
$this->assertSame('/', Redirect::normalise('/'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function following_one_can_be_counted(): void
|
||||
{
|
||||
$redirect = Redirect::factory()->create(['hits' => 0]);
|
||||
|
||||
$redirect->markUsed();
|
||||
|
||||
// The number that decides whether a rule can ever be deleted.
|
||||
$this->assertSame(1, $redirect->fresh()->hits);
|
||||
$this->assertNotNull($redirect->fresh()->last_used_at);
|
||||
}
|
||||
}
|
||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
87
tests/Unit/PermissionCatalogueTest.php
Normal file
87
tests/Unit/PermissionCatalogueTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\Permissions;
|
||||
use App\Support\TranslatablePayload;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PermissionCatalogueTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function every_role_definition_only_uses_permissions_from_the_catalogue(): void
|
||||
{
|
||||
$catalogue = Permissions::all();
|
||||
|
||||
foreach (Permissions::roleDefinitions() as $role => $permissions) {
|
||||
$unknown = array_diff($permissions, $catalogue);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
array_values($unknown),
|
||||
"Role [{$role}] references permissions that do not exist: ".implode(', ', $unknown)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_grouped_view_covers_the_whole_catalogue(): void
|
||||
{
|
||||
$flattened = array_merge(...array_values(Permissions::grouped()));
|
||||
|
||||
$this->assertEqualsCanonicalizing(Permissions::all(), $flattened);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function super_admin_holds_no_explicit_permissions(): void
|
||||
{
|
||||
// Its power comes from the Gate::before hook, so the definition stays
|
||||
// empty and can never fall behind the catalogue.
|
||||
$this->assertSame([], Permissions::roleDefinitions()['super_admin']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_role_that_can_use_the_panel_can_reach_it(): void
|
||||
{
|
||||
foreach (Permissions::roleDefinitions() as $role => $permissions) {
|
||||
if ($role === 'super_admin' || $permissions === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertContains(
|
||||
Permissions::PANEL_ACCESS,
|
||||
$permissions,
|
||||
"Role [{$role}] has permissions but cannot open the panel."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_default_role_for_new_logins_exists(): void
|
||||
{
|
||||
$this->assertArrayHasKey(
|
||||
(string) config('pireon.default_role'),
|
||||
Permissions::roleDefinitions(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_translatable_value_can_be_submitted_as_a_plain_string(): void
|
||||
{
|
||||
app()->setLocale('ca');
|
||||
|
||||
$this->assertSame(['ca' => 'Hola'], TranslatablePayload::normalise('Hola'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unsupported_locales_are_stripped_from_incoming_payloads(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['es' => 'Hola', 'en' => 'Hi'],
|
||||
TranslatablePayload::normalise(['es' => 'Hola', 'en' => 'Hi', 'de' => 'Hallo']),
|
||||
);
|
||||
}
|
||||
}
|
||||
186
tests/Unit/TranslationCatalogueTest.php
Normal file
186
tests/Unit/TranslationCatalogueTest.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The three languages have to say the same things.
|
||||
*
|
||||
* A key present in one file and absent from another does not fail anywhere: it
|
||||
* renders as the raw key — `panel.widgets.stats.projects` — on a screen nobody
|
||||
* is looking at in that language. It is the kind of fault that only ever gets
|
||||
* found by a user, which is why it is worth a test.
|
||||
*/
|
||||
class TranslationCatalogueTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function locales(): array
|
||||
{
|
||||
return array_keys((array) config('locales.supported'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every leaf of a translation file, as dotted keys.
|
||||
*
|
||||
* @param array<string, mixed> $translations
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function flatten(array $translations, string $prefix = ''): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
foreach ($translations as $key => $value) {
|
||||
$path = $prefix === '' ? (string) $key : "{$prefix}.{$key}";
|
||||
|
||||
if (is_array($value)) {
|
||||
$keys = [...$keys, ...$this->flatten($value, $path)];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = $path;
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array<int, string>>>
|
||||
*/
|
||||
protected function catalogue(): array
|
||||
{
|
||||
$catalogue = [];
|
||||
|
||||
foreach ($this->locales() as $locale) {
|
||||
foreach (glob(lang_path("{$locale}/*.php")) ?: [] as $file) {
|
||||
$catalogue[basename($file)][$locale] = $this->flatten(require $file);
|
||||
}
|
||||
}
|
||||
|
||||
return $catalogue;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_language_carries_the_same_keys(): void
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
foreach ($this->catalogue() as $file => $byLocale) {
|
||||
$everyKey = array_unique(array_merge(...array_values($byLocale)));
|
||||
|
||||
foreach ($this->locales() as $locale) {
|
||||
foreach (array_diff($everyKey, $byLocale[$locale] ?? []) as $key) {
|
||||
$missing[] = "[{$locale}] {$file} :: {$key}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $missing, "Untranslated keys:\n".implode("\n", $missing));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function no_translation_is_left_empty(): void
|
||||
{
|
||||
$empty = [];
|
||||
|
||||
foreach ($this->locales() as $locale) {
|
||||
foreach (glob(lang_path("{$locale}/*.php")) ?: [] as $file) {
|
||||
foreach (require $file as $key => $value) {
|
||||
foreach ($this->leaves($value, (string) $key) as $path => $text) {
|
||||
if (trim((string) $text) === '') {
|
||||
$empty[] = "[{$locale}] ".basename($file)." :: {$path}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $empty, "Empty translations:\n".implode("\n", $empty));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key the application actually asks for exists.
|
||||
*
|
||||
* The mirror image of the test above: that one catches a key translated in
|
||||
* one language and forgotten in another, this one catches a key nobody
|
||||
* translated at all.
|
||||
*/
|
||||
#[Test]
|
||||
public function every_key_the_code_uses_has_a_translation(): void
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
$missing = [];
|
||||
|
||||
foreach ($this->sourceFiles() as $file) {
|
||||
$source = (string) file_get_contents($file->getPathname());
|
||||
|
||||
preg_match_all(
|
||||
'/(?:__|trans_choice)\(\s*[\'"]([a-z0-9_]+\.[a-z0-9_.\-]+)[\'"]/i',
|
||||
$source,
|
||||
$matches,
|
||||
);
|
||||
|
||||
foreach ($matches[1] as $key) {
|
||||
[$group] = explode('.', $key, 2);
|
||||
|
||||
// Keys belonging to a package (`filament-panels::…`) or built
|
||||
// dynamically are not this test's business.
|
||||
if (! file_exists(lang_path("{$default}/{$group}.php"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string(__($key)) || __($key) === $key) {
|
||||
$missing[] = $key.' ('.str_replace(base_path().'/', '', $file->getPathname()).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], array_unique($missing), "Keys with no translation:\n".implode("\n", array_unique($missing)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, SplFileInfo>
|
||||
*/
|
||||
protected function sourceFiles(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach ([app_path(), resource_path('views')] as $directory) {
|
||||
/** @var SplFileInfo $file */
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)) as $file) {
|
||||
if ($file->isFile() && in_array($file->getExtension(), ['php'], true)) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function leaves(mixed $value, string $prefix): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [$prefix => $value];
|
||||
}
|
||||
|
||||
$leaves = [];
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$leaves += $this->leaves($child, "{$prefix}.{$key}");
|
||||
}
|
||||
|
||||
return $leaves;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user