allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]); return $presenter; } /** @return list */ private function attachments(int ...$ids): array { return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids); } /** No edit_others_posts cap mapped: PostGuard::ownPostsOnly() falls back to the raw name. */ private function unrestrictedPostType(): void { Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]); } public function testMissingAltLimitsToImagesWithoutAltText(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => $args['post_type'] === 'attachment' && $args['post_mime_type'] === 'image' && $args['s'] === 'team' && $args['meta_query'] === [ 'relation' => 'OR', ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'], ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='], ] && $args['posts_per_page'] === 21 && $args['offset'] === 0))->andReturn($this->attachments(1, 2)); $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['search' => 'team', 'missing_alt' => true]); self::assertSame([['id' => 1], ['id' => 2]], $result['items']); self::assertNull($result['next_cursor']); self::assertSame([], $result['warnings']); } public function testMissingAltRejectsNonImageMime(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['missing_alt' => true, 'mime' => 'application/pdf']); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testMissingAltAllowsAnImageSubtypeMime(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); Functions\when('get_posts')->justReturn([]); $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['missing_alt' => true, 'mime' => 'image/png']); self::assertSame([], $result['items']); } public function testQueryArgsCoverStatusDateAndMime(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool => $args['post_status'] === 'inherit' && $args['post_mime_type'] === 'application/pdf' && $args['date_query'] === [['after' => '2026-01-01', 'before' => '2026-02-01', 'inclusive' => true]] && $args['posts_per_page'] === 6 && $args['offset'] === 3))->andReturn([]); (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([ 'mime' => 'application/pdf', 'date_after' => '2026-01-01', 'date_before' => '2026-02-01', 'limit' => 5, 'cursor' => Cursor::encode(3), ]); } public function testInvalidCursorIsRejected(): void { $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['cursor' => '???']); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testExcludesAttachmentsTheUserCannotEdit(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2)); $this->unrestrictedPostType(); Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); $result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute([]); self::assertSame([['id' => 1]], $result['items']); } public function testSearchIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void { Functions\when('get_post_type_object')->justReturn( (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] ); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); Functions\when('get_current_user_id')->justReturn(7); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7) )->andReturn([]); (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]); } public function testSearchIsNotRestrictedToAuthorWithEditOthersPosts(): void { Functions\when('get_post_type_object')->justReturn( (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] ); Functions\when('current_user_can')->justReturn(true); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args)) )->andReturn([]); (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]); } public function testUnusedFiltersThroughTheUsageFinder(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2); $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); self::assertSame([['id' => 1], ['id' => 3]], $result['items']); self::assertNull($result['next_cursor']); self::assertSame([], $result['warnings']); } public function testUnusedStopsAtTheLimitWithACursor(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(false); $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); self::assertSame([['id' => 1]], $result['items']); self::assertSame(1, Cursor::decode($result['next_cursor'])); } public function testUnusedHasNoCursorWhenTheLastCandidateWasExaminedAtTheLimit(): void { Functions\when('get_posts')->justReturn($this->attachments(1)); Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(false); $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]); self::assertSame([['id' => 1]], $result['items']); self::assertNull($result['next_cursor']); } public function testUnusedSkipsAttachmentsTheUserCannotEdit(): void { Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3)); $this->unrestrictedPostType(); Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(false); $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); self::assertSame([['id' => 1], ['id' => 3]], $result['items']); self::assertNull($result['next_cursor']); } public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => $args['posts_per_page'] === 21 && $args['offset'] === 0) )->andReturn($this->attachments(...range(1, 21))); $finder = Mockery::mock(MediaUsageFinder::class); $finder->allows('isUsed')->andReturn(true); $result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]); self::assertSame([], $result['items']); self::assertSame(20, Cursor::decode($result['next_cursor'])); self::assertNotSame([], $result['warnings']); } public function testUnusedCursorOffsetIsPassedThrough(): void { Functions\when('current_user_can')->justReturn(true); $this->unrestrictedPostType(); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => $args['offset'] === 5) )->andReturn([]); $finder = Mockery::mock(MediaUsageFinder::class); (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'cursor' => Cursor::encode(5)]); } public function testUnusedIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void { Functions\when('get_post_type_object')->justReturn( (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] ); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); Functions\when('get_current_user_id')->justReturn(7); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7) )->andReturn([]); (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['unused' => true]); } public function testUnusedIsNotRestrictedToAuthorWithEditOthersPosts(): void { Functions\when('get_post_type_object')->justReturn( (object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']] ); Functions\when('current_user_can')->justReturn(true); Functions\expect('get_posts')->once()->with( Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args)) )->andReturn([]); (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class))) ->execute(['unused' => true]); } public function testRequiresUploadRights(): void { Functions\when('current_user_can')->justReturn(false); $ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)); self::assertSame('lit_forbidden', $ability->permission([])->get_error_code()); self::assertTrue($ability->isDirect()); } }