mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
The blob prune follows APP_STORAGE_DIR and clears orphaned thumbnails (#234)
app:prune:blobs looked under a hardcoded var/, so installs with another APP_STORAGE_DIR reclaimed nothing; it and the thumbnail cache now use APP_STORAGE_DIR. A third pass deletes attachment thumbnails whose part no longer exists. Closes #232, closes #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdd0cd4fd8
commit
9c76bbc636
10 files changed
+343
-33
No files matched your search
@@ -53,6 +53,7 @@ final class PruneBlobsCommandTest extends TestCase
|
||||
'var/uploads/avatars/3/3f9c1e.png',
|
||||
'var/uploads/signatures/3/logo-a1b2c3.png',
|
||||
'var/uploads/backgrounds/7d2e.jpg',
|
||||
// A thumbnail of a part that exists: the thumbnail pass's, and kept.
|
||||
'var/attachments/thumbs/42.webp',
|
||||
// A store nobody has written yet, in the shape the sealed messages
|
||||
// of v0.4.0 take: it is left alone without anyone listing it here.
|
||||
@@ -68,7 +69,7 @@ final class PruneBlobsCommandTest extends TestCase
|
||||
$this->file($path, self::OLD);
|
||||
}
|
||||
|
||||
$exit = $this->command()->execute([]);
|
||||
$exit = $this->command(existingParts: [42])->execute([]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $exit);
|
||||
|
||||
@@ -147,6 +148,69 @@ final class PruneBlobsCommandTest extends TestCase
|
||||
self::assertStringContainsString('Orphans in var/raw: 1', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* #233: the stores are under APP_STORAGE_DIR, not a hardcoded var/. On an
|
||||
* install that moved them, the sweep looked where nothing was written and
|
||||
* reclaimed nothing.
|
||||
*/
|
||||
public function testTheStoresAreUnderTheStorageDirectory(): void
|
||||
{
|
||||
$orphan = 'var/data/raw/3/1/1042.eml';
|
||||
$referenced = 'var/data/attachments/3/12/4711/kept.pdf';
|
||||
$elsewhere = 'var/raw/3/1/1042.eml';
|
||||
|
||||
foreach ([$orphan, $referenced, $elsewhere] as $path) {
|
||||
$this->file($path, self::OLD);
|
||||
}
|
||||
|
||||
$this->command(parts: [$referenced => true], storageDir: 'var/data')->execute([]);
|
||||
|
||||
self::assertFileDoesNotExist($this->projectDir.'/'.$orphan, 'swept where the writers write');
|
||||
self::assertFileExists($this->projectDir.'/'.$referenced, 'the keep-list is asked with the storage dir\'s prefix');
|
||||
self::assertFileExists($this->projectDir.'/'.$elsewhere, 'and var/ is not this install\'s store');
|
||||
}
|
||||
|
||||
/**
|
||||
* #232: a thumbnail is named by its part id, and nothing deletes it with
|
||||
* the part. It goes once the part is gone, and not before.
|
||||
*/
|
||||
public function testThumbnailsOfPartsThatAreGoneAreDeleted(): void
|
||||
{
|
||||
$gone = 'var/attachments/thumbs/41.webp';
|
||||
$existing = 'var/attachments/thumbs/42.webp';
|
||||
$recent = 'var/attachments/thumbs/43.webp';
|
||||
$foreign = 'var/attachments/thumbs/notes.txt';
|
||||
// Larger than any message_part.id can be: not ours, and it must not
|
||||
// turn the existence query into an error that stops the run.
|
||||
$tooLarge = 'var/attachments/thumbs/99999999999.webp';
|
||||
|
||||
foreach ([$gone, $existing, $foreign, $tooLarge] as $path) {
|
||||
$this->file($path, self::OLD);
|
||||
}
|
||||
|
||||
$this->file($recent, '-1 day');
|
||||
|
||||
$tester = $this->command(existingParts: [42]);
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertFileDoesNotExist($this->projectDir.'/'.$gone);
|
||||
self::assertFileExists($this->projectDir.'/'.$existing, 'its part still exists');
|
||||
self::assertFileExists($this->projectDir.'/'.$recent, 'inside the window');
|
||||
self::assertFileExists($this->projectDir.'/'.$foreign, 'not a thumbnail');
|
||||
self::assertFileExists($this->projectDir.'/'.$tooLarge, 'not a part id');
|
||||
self::assertStringContainsString('Orphaned thumbnails: 1', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testADryRunKeepsThumbnailsToo(): void
|
||||
{
|
||||
$gone = 'var/attachments/thumbs/41.webp';
|
||||
$this->file($gone, self::OLD);
|
||||
|
||||
$this->command()->execute(['--dry-run' => true]);
|
||||
|
||||
self::assertFileExists($this->projectDir.'/'.$gone);
|
||||
}
|
||||
|
||||
private function file(string $relativePath, string $age): void
|
||||
{
|
||||
$path = $this->projectDir.'/'.$relativePath;
|
||||
@@ -163,15 +227,24 @@ final class PruneBlobsCommandTest extends TestCase
|
||||
* @param array<string, true> $uploads
|
||||
* @param array<string, true> $parts
|
||||
* @param array<string, true> $raw
|
||||
* @param list<int> $existingParts part ids that still exist
|
||||
*/
|
||||
private function command(array $uploads = [], array $parts = [], array $raw = []): CommandTester
|
||||
{
|
||||
private function command(
|
||||
array $uploads = [],
|
||||
array $parts = [],
|
||||
array $raw = [],
|
||||
array $existingParts = [],
|
||||
string $storageDir = 'var',
|
||||
): CommandTester {
|
||||
$blobs = $this->createStub(UploadedBlobRepository::class);
|
||||
$blobs->method('findOlderThan')->willReturn([]);
|
||||
$blobs->method('findReferencedPaths')->willReturn($uploads);
|
||||
|
||||
$messageParts = $this->createStub(MessagePartRepository::class);
|
||||
$messageParts->method('findReferencedStoragePaths')->willReturn($parts);
|
||||
$messageParts->method('findExistingIds')->willReturnCallback(
|
||||
static fn (array $ids): array => array_fill_keys(array_values(array_intersect($ids, $existingParts)), true),
|
||||
);
|
||||
|
||||
$messages = $this->createStub(MessageRepository::class);
|
||||
$messages->method('findReferencedRawPaths')->willReturn($raw);
|
||||
@@ -180,9 +253,10 @@ final class PruneBlobsCommandTest extends TestCase
|
||||
$blobs,
|
||||
$messageParts,
|
||||
$messages,
|
||||
new UploadStorage($this->projectDir, 'var'),
|
||||
new UploadStorage($this->projectDir, $storageDir),
|
||||
$this->createStub(EntityManagerInterface::class),
|
||||
$this->projectDir,
|
||||
$storageDir,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessagePart;
|
||||
use App\Repository\Mail\MessagePartRepository;
|
||||
use App\Tests\Support\Mail\SeedsMarkerFixtures;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Which part ids still exist: the thumbnail sweep's keep-list (#232).
|
||||
*
|
||||
* Against the real database, because the answer decides which files the
|
||||
* weekly prune deletes: an id it wrongly calls gone costs a thumbnail, and a
|
||||
* query that comes back empty would cost all of them.
|
||||
*/
|
||||
final class MessagePartExistingIdsTest extends KernelTestCase
|
||||
{
|
||||
use SeedsMarkerFixtures;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser();
|
||||
$this->account = $this->seedAccount();
|
||||
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->em->getConnection()->isTransactionActive()) {
|
||||
$this->em->getConnection()->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testOnlyTheIdsOfPartsThatExistComeBack(): void
|
||||
{
|
||||
$first = $this->part();
|
||||
$second = $this->part();
|
||||
$gone = $this->part();
|
||||
|
||||
$goneId = (int) $gone->id;
|
||||
$this->em->remove($gone);
|
||||
$this->em->flush();
|
||||
|
||||
$existing = static::getContainer()->get(MessagePartRepository::class)
|
||||
->findExistingIds([(int) $first->id, (int) $second->id, $goneId, 2_147_483_647]);
|
||||
|
||||
self::assertSame([(int) $first->id => true, (int) $second->id => true], $this->sorted($existing));
|
||||
}
|
||||
|
||||
public function testNoIdsAskNothing(): void
|
||||
{
|
||||
self::assertSame([], static::getContainer()->get(MessagePartRepository::class)->findExistingIds([]));
|
||||
}
|
||||
|
||||
private function part(): MessagePart
|
||||
{
|
||||
/** @var Message $message */
|
||||
$message = $this->thread('Teile')->messages->first();
|
||||
|
||||
$part = new MessagePart();
|
||||
$part->contentType = 'image/png';
|
||||
$part->filename = 'p.png';
|
||||
$part->disposition = 'attachment';
|
||||
$part->size = 1;
|
||||
$part->storagePath = 'var/attachments/1/1/1/p.png';
|
||||
|
||||
$message->addMessagePart($part);
|
||||
$this->em->persist($part);
|
||||
$this->em->flush();
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, true> $set
|
||||
*
|
||||
* @return array<int, true>
|
||||
*/
|
||||
private function sorted(array $set): array
|
||||
{
|
||||
ksort($set);
|
||||
|
||||
return $set;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user