1
0
Fork 0
mirror of https://github.com/wallabag/wallabag.git synced 2025-06-27 16:36:00 +00:00
This commit is contained in:
Martin Chaine 2025-06-04 10:53:05 +00:00 committed by GitHub
commit 521e1d1fda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 892 additions and 4 deletions

View file

@ -31,6 +31,7 @@ services:
$supportUrl: '@=service(''craue_config'').get(''wallabag_support_url'')'
$fonts: '%wallabag.fonts%'
$defaultIgnoreOriginInstanceRules: '%wallabag.default_ignore_origin_instance_rules%'
$entryDeletionExpirationDays: '%wallabag.entry_deletion_expiration_days%'
Wallabag\:
resource: '../../src/*'
@ -253,6 +254,11 @@ services:
Wallabag\Helper\DownloadImages:
arguments:
$baseFolder: "%kernel.project_dir%/web/assets/images"
Wallabag\Helper\EntryDeletionExpirationConfig:
arguments:
$defaultExpirationDays: '%wallabag.entry_deletion_expiration_days%'
Wallabag\Command\InstallCommand:
arguments:

View file

@ -32,6 +32,7 @@ parameters:
wallabag.action_mark_as_read: 1
wallabag.list_mode: 0
wallabag.display_thumbnails: 1
wallabag.entry_deletion_expiration_days: 90
wallabag.fetching_error_message_title: 'No title found'
wallabag.fetching_error_message: |
wallabag can't retrieve contents for this article. Please <a href="https://doc.wallabag.org/en/user/errors_during_fetching.html#how-can-i-help-to-fix-that">troubleshoot this issue</a>.

View file

@ -156,7 +156,8 @@
"wallabag/rulerz": "dev-master",
"wallabag/rulerz-bundle": "dev-master",
"willdurand/hateoas": "^3.12",
"willdurand/hateoas-bundle": "^2.7"
"willdurand/hateoas-bundle": "^2.7",
"zircote/swagger-php": "^4.11.1 || ^5.0"
},
"require-dev": {
"dama/doctrine-test-bundle": "^8.2.2",

6
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "00b8f95df6ec0572c06ad6f34847405c",
"content-hash": "b47338ba84f717112fd3f36632c65e03",
"packages": [
{
"name": "babdev/pagerfanta-bundle",
@ -19449,6 +19449,6 @@
"ext-tokenizer": "*",
"ext-xml": "*"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View file

@ -0,0 +1,53 @@
<?php
namespace Wallabag\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Entity\User;
class EntryDeletionFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$adminUser = $this->getReference('admin-user', User::class);
$bobUser = $this->getReference('bob-user', User::class);
$deletions = [
[
'user' => $adminUser,
'entry_id' => 1004,
'deleted_at' => new \DateTime('-4 day'),
],
[
'user' => $adminUser,
'entry_id' => 1001,
'deleted_at' => new \DateTime('-1 day'),
],
[
'user' => $bobUser,
'entry_id' => 1003,
'deleted_at' => new \DateTime('-3 days'),
],
];
foreach ($deletions as $deletionData) {
$deletion = new EntryDeletion($deletionData['user'], $deletionData['entry_id']);
$deletion->setDeletedAt($deletionData['deleted_at']);
$manager->persist($deletion);
}
$manager->flush();
}
public function getDependencies(): array
{
return [
UserFixtures::class,
EntryFixtures::class,
];
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Application\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Wallabag\Doctrine\WallabagMigration;
/**
* Add the entry deletion table to keep a history of deleted entries.
*/
final class Version20250530183653 extends WallabagMigration
{
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE "wallabag_entry_deletion" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, entry_id INTEGER NOT NULL, deleted_at DATETIME NOT NULL, CONSTRAINT FK_D91765D5A76ED395 FOREIGN KEY (user_id) REFERENCES "wallabag_user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_D91765D5A76ED395 ON "wallabag_entry_deletion" (user_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_D91765D54AF38FD1 ON "wallabag_entry_deletion" (deleted_at)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_D91765D5A76ED3954AF38FD1 ON "wallabag_entry_deletion" (user_id, deleted_at)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP TABLE "wallabag_entry_deletion"
SQL);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace Wallabag\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Wallabag\Helper\EntryDeletionExpirationConfig;
use Wallabag\Repository\EntryDeletionRepository;
class PurgeEntryDeletionsCommand extends Command
{
protected static $defaultName = 'wallabag:purge-entry-deletions';
protected static $defaultDescription = 'Purge old entry deletion records';
public function __construct(
private readonly EntryDeletionRepository $entryDeletionRepository,
private readonly EntryDeletionExpirationConfig $expirationConfig,
) {
parent::__construct();
}
protected function configure()
{
$this
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Do not actually delete records, just show what would be deleted'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
$cutoff = $this->expirationConfig->getCutoffDate();
$count = $this->entryDeletionRepository->countAllBefore($cutoff);
if ($dryRun) {
$io->text('Dry run mode <info>enabled</info> (no records will be deleted)');
if (0 === $count) {
$io->success('No entry deletion records found.');
} else {
$io->success(\sprintf('Would have deleted %d records.', $count));
}
return 0;
}
if (0 === $count) {
$io->success('No entry deletion records found.');
return 0;
}
$confirmMessage = \sprintf(
'Are you sure you want to delete records older than %s? (count: %d)',
$cutoff->format('Y-m-d'),
$count,
);
if (!$io->confirm($confirmMessage)) {
return 0;
}
$this->entryDeletionRepository->deleteAllBefore($cutoff);
$io->success(\sprintf('Successfully deleted %d records.', $count));
return 0;
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace Wallabag\Controller\Api;
use Hateoas\Configuration\Route as HateoasRoute;
use Hateoas\Representation\Factory\PagerfantaFactory;
use OpenApi\Attributes as OA;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Helper\EntryDeletionExpirationConfig;
use Wallabag\OpenApi\Attribute as WOA;
use Wallabag\Repository\EntryDeletionRepository;
class EntryDeletionRestController extends WallabagRestController
{
/**
* Retrieve all entry deletions for the current user.
*/
#[Route(path: '/api/entry-deletions.{_format}', name: 'api_get_entry_deletions', methods: ['GET'], defaults: ['_format' => 'json'])]
#[OA\Get(
summary: 'Retrieve all entry deletions for the current user.',
tags: ['EntryDeletions'],
parameters: [
new OA\Parameter(
name: 'since',
in: 'query',
description: 'The timestamp (in seconds) since when you want entry deletions.',
required: false,
schema: new OA\Schema(type: 'integer')
),
new WOA\OrderParameter(),
new WOA\PagerFanta\PageParameter(),
new WOA\PagerFanta\PerPageParameter(default: 100),
],
responses: [
new OA\Response(
response: 200,
description: 'Returned when successful.',
content: new WOA\PagerFanta\JsonContent(EntryDeletion::class)
),
new OA\Response(
response: 410,
description: 'Returned when the since date is before the cutoff date.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Property(type: 'string'),
]
),
headers: [
new OA\Header(
header: 'X-Wallabag-Entry-Deletion-Cutoff',
description: 'The furthest cutoff timestamp possible for entry deletions.',
schema: new OA\Schema(type: 'integer')
),
]
),
]
)]
#[IsGranted('LIST_ENTRIES')]
public function getEntryDeletionsAction(
Request $request,
EntryDeletionRepository $entryDeletionRepository,
EntryDeletionExpirationConfig $expirationConfig,
) {
$this->validateAuthentication();
$userId = $this->getUser()->getId();
$page = $request->query->getInt('page', 1);
$perPage = $request->query->getInt('perPage', 100);
$order = strtolower($request->query->get('order', 'desc'));
$since = $request->query->getInt('since', 0);
if (!\in_array($order, ['asc', 'desc'], true)) {
$order = 'desc';
}
if (0 < $since) {
$cutoff = $expirationConfig->getCutoffDate()->getTimestamp();
if ($since < $cutoff) {
// Using a JSON response rather than a GoneHttpException to preserve the error message in production
return $this->json(
[
'message' => "The requested since date ({$since}) is before the data retention cutoff date ({$cutoff}).\n"
. 'You can get the cutoff date programmatically from the X-Wallabag-Entry-Deletion-Cutoff header.',
],
410,
headers: [
'X-Wallabag-Entry-Deletion-Cutoff' => $cutoff,
]
);
}
}
$pager = $entryDeletionRepository->findEntryDeletions($userId, $since, $order);
$pager->setMaxPerPage($perPage);
$pager->setCurrentPage($page);
$pagerfantaFactory = new PagerfantaFactory('page', 'perPage');
$paginatedCollection = $pagerfantaFactory->createRepresentation(
$pager,
new HateoasRoute(
'api_get_entry_deletions',
[
'page' => $page,
'perPage' => $perPage,
'order' => $order,
'since' => $since,
],
true
),
);
return $this->sendResponse($paginatedCollection);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Wallabag\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Wallabag\Repository\EntryDeletionRepository;
/**
* EntryDeletion.
*
* Tracks when entries are deleted for client synchronization purposes.
*/
#[ORM\Table(name: '`entry_deletion`')]
#[ORM\Index(columns: ['deleted_at'])]
#[ORM\Index(columns: ['user_id', 'deleted_at'])]
#[ORM\Entity(repositoryClass: EntryDeletionRepository::class)]
class EntryDeletion
{
/**
* @var int
*/
#[ORM\Column(name: 'id', type: 'integer')]
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private $id;
/**
* @var int
*/
#[ORM\Column(name: 'entry_id', type: 'integer')]
private $entryId;
/**
* @var \DateTimeInterface
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime')]
private $deletedAt;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Serializer\Exclude()]
private $user;
public function __construct(User $user, int $entryId)
{
$this->user = $user;
$this->entryId = $entryId;
$this->deletedAt = new \DateTime();
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Get entryId.
*
* @return int
*/
public function getEntryId()
{
return $this->entryId;
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
/**
* @return \DateTimeInterface
*/
public function getDeletedAt()
{
return $this->deletedAt;
}
/**
* Set deleted_at.
*
* @return EntryDeletion
*/
public function setDeletedAt(\DateTimeInterface $deletedAt)
{
$this->deletedAt = $deletedAt;
return $this;
}
/**
* Create an EntryDeletion from an Entry that's being deleted.
*/
public static function createFromEntry(Entry $entry): self
{
return new self($entry->getUser(), $entry->getId());
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Wallabag\Event\Subscriber;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Event\EntryDeletedEvent;
class EntryDeletionSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntryDeletedEvent::NAME => 'onEntryDeleted',
];
}
/**
* Create a deletion event record for the entry.
*/
public function onEntryDeleted(EntryDeletedEvent $event): void
{
$entry = $event->getEntry();
$deletionEvent = EntryDeletion::createFromEntry($entry);
$this->em->persist($deletionEvent);
$this->em->flush();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Wallabag\Helper;
class EntryDeletionExpirationConfig
{
private int $expirationDays;
public function __construct(int $defaultExpirationDays)
{
$this->expirationDays = $defaultExpirationDays;
}
public function getCutoffDate(): \DateTime
{
return new \DateTime("-{$this->expirationDays} days");
}
public function getExpirationDays(): int
{
return $this->expirationDays;
}
/**
* Override the expiration days parameter.
* This is mostly useful for testing purposes and should not be used in other contexts.
*/
public function setExpirationDays(int $days): self
{
$this->expirationDays = $days;
return $this;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Wallabag\OpenApi\Attribute;
use OpenApi\Attributes as OA;
#[\Attribute(\Attribute::TARGET_METHOD)]
class OrderParameter extends OA\Parameter
{
public function __construct(
public readonly string $defaultName = 'order',
public readonly string $default = 'desc',
) {
parent::__construct(
name: $defaultName,
in: 'query',
description: 'Order of results (asc or desc).',
required: false,
schema: new OA\Schema(type: 'string', enum: ['asc', 'desc'], default: $default)
);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace Wallabag\OpenApi\Attribute\PagerFanta;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[\Attribute(\Attribute::TARGET_CLASS)]
class JsonContent extends OA\JsonContent
{
public function __construct(string|array|null $modelClass = null)
{
parent::__construct(
properties: [
new OA\Property(
property: '_embedded',
type: 'object',
properties: [
new OA\Property(
property: 'items',
type: 'array',
items: new OA\Items(ref: new Model(type: $modelClass))
),
]
),
new OA\Property(property: 'page', type: 'integer'),
new OA\Property(property: 'limit', type: 'integer'),
new OA\Property(property: 'pages', type: 'integer'),
new OA\Property(property: 'total', type: 'integer'),
new OA\Property(
property: '_links',
type: 'object',
properties: [
new OA\Property(
property: 'self',
type: 'object',
properties: [new OA\Property(property: 'href', type: 'string')]
),
new OA\Property(
property: 'first',
type: 'object',
properties: [new OA\Property(property: 'href', type: 'string')]
),
new OA\Property(
property: 'last',
type: 'object',
properties: [new OA\Property(property: 'href', type: 'string')]
),
new OA\Property(
property: 'next',
type: 'object',
properties: [new OA\Property(property: 'href', type: 'string')]
),
]
),
]
);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Wallabag\OpenApi\Attribute\PagerFanta;
use OpenApi\Attributes as OA;
#[\Attribute(\Attribute::TARGET_METHOD)]
class PageParameter extends OA\Parameter
{
public function __construct(
public readonly string $defaultName = 'page',
public readonly int $default = 1,
) {
parent::__construct(
name: $defaultName,
in: 'query',
description: 'Requested page number.',
required: false,
schema: new OA\Schema(type: 'integer', default: $default)
);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Wallabag\OpenApi\Attribute\PagerFanta;
use OpenApi\Attributes as OA;
#[\Attribute(\Attribute::TARGET_METHOD)]
class PerPageParameter extends OA\Parameter
{
public function __construct(
public readonly string $defaultName = 'perPage',
public readonly int $default = 30,
) {
parent::__construct(
name: $defaultName,
in: 'query',
description: 'Number of items per page.',
required: false,
schema: new OA\Schema(type: 'integer', default: $default)
);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Wallabag\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Pagerfanta\Doctrine\ORM\QueryAdapter as DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Wallabag\Entity\EntryDeletion;
class EntryDeletionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EntryDeletion::class);
}
/**
* Find deletions for a specific user since a given date. The result is paginated.
*/
public function findEntryDeletions(int $userId, int $since = 0, string $order = 'asc'): Pagerfanta
{
$qb = $this->createQueryBuilder('de')
->where('de.user = :userId')->setParameter('userId', $userId)
->orderBy('de.deletedAt', $order);
if ($since > 0) {
$qb->andWhere('de.deletedAt >= :since')
->setParameter('since', new \DateTime(date('Y-m-d H:i:s', $since)));
}
$pagerAdapter = new DoctrineORMAdapter($qb, true, false);
$pager = new Pagerfanta($pagerAdapter);
return $pager;
}
public function countAllBefore(\DateTime $date): int
{
return $this->createQueryBuilder('de')
->select('COUNT(de.id)')
->where('de.deletedAt < :date')
->setParameter('date', $date)
->getQuery()
->getSingleScalarResult();
}
public function deleteAllBefore(\DateTime $date)
{
$this->createQueryBuilder('de')
->delete()
->where('de.deletedAt < :date')
->setParameter('date', $date)
->getQuery()
->execute();
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Wallabag\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Entity\User;
class EntryDeletionVoter extends Voter
{
public const VIEW = 'VIEW';
public const LIST = 'LIST';
protected function supports(string $attribute, $subject): bool
{
if (!$subject instanceof EntryDeletion) {
return false;
}
if (!\in_array($attribute, [self::VIEW, self::LIST], true)) {
return false;
}
return true;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
\assert($subject instanceof EntryDeletion);
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW, self::LIST => $user === $subject->getUser(),
default => false,
};
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace Tests\Wallabag\Command;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Helper\EntryDeletionExpirationConfig;
use Wallabag\Repository\EntryDeletionRepository;
/**
* Test the purge entry deletions command.
*
* The fixtures set up the following entry deletions:
* - Admin user: 1 deletion from 4 days ago (entry_id: 1004)
* - Admin user: 1 deletion from 1 day ago (entry_id: 1001)
* - Bob user: 1 deletion from 3 days ago (entry_id: 1003)
*/
class PurgeEntryDeletionsCommandTest extends KernelTestCase
{
private EntryDeletionExpirationConfig $expirationConfig;
private EntryDeletionRepository $entryDeletionRepository;
protected function setUp(): void
{
self::bootKernel();
$this->expirationConfig = self::getContainer()->get(EntryDeletionExpirationConfig::class);
$this->expirationConfig->setExpirationDays(2);
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->entryDeletionRepository = $em->getRepository(EntryDeletion::class);
}
public function testRunPurgeEntryDeletionsCommandWithDryRun()
{
$application = new Application(self::$kernel);
$command = $application->find('wallabag:purge-entry-deletions');
$tester = new CommandTester($command);
$tester->execute([
'--dry-run' => true,
]);
$this->assertStringContainsString('Dry run mode enabled', $tester->getDisplay());
$this->assertSame(0, $tester->getStatusCode());
$count = $this->entryDeletionRepository->countAllBefore($this->expirationConfig->getCutoffDate());
$this->assertSame(2, $count);
}
public function testRunPurgeEntryDeletionsCommand()
{
$application = new Application(self::$kernel);
$command = $application->find('wallabag:purge-entry-deletions');
$tester = new CommandTester($command);
$tester->setInputs(['yes']); // confirm deletion
$tester->execute([]);
$this->assertStringContainsString('Successfully deleted 2 records', $tester->getDisplay());
$this->assertSame(0, $tester->getStatusCode());
$count = $this->entryDeletionRepository->countAllBefore($this->expirationConfig->getCutoffDate());
$this->assertSame(0, $count);
$countAll = $this->entryDeletionRepository->countAllBefore(new \DateTime('now'));
$this->assertSame(1, $countAll);
}
public function testRunPurgeEntryDeletionsCommandWithNoRecords()
{
$this->expirationConfig->setExpirationDays(10);
$application = new Application(self::$kernel);
$command = $application->find('wallabag:purge-entry-deletions');
$tester = new CommandTester($command);
$tester->execute([]);
$this->assertStringContainsString('No entry deletion records found', $tester->getDisplay());
$this->assertSame(0, $tester->getStatusCode());
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Tests\Wallabag\Controller\Api;
/**
* Test the entry deletion REST API endpoints.
*
* The fixtures set up the following entry deletions:
* - Admin user: 1 deletion from 4 days ago (entry_id: 1004)
* - Admin user: 1 deletion from 1 day ago (entry_id: 1001)
* - Bob user: 1 deletion from 3 days ago (entry_id: 1003)
*
* The logged in user is admin.
*/
class EntryDeletionRestControllerTest extends WallabagApiTestCase
{
public function testGetEntryDeletions()
{
$this->client->request('GET', '/api/entry-deletions');
$this->assertSame(200, $this->client->getResponse()->getStatusCode());
$content = json_decode($this->client->getResponse()->getContent(), true);
// check that only the items for the current user are returned
$this->assertCount(2, $content['_embedded']['items']);
// validate the deletion schema on the first item
$deletionData = $content['_embedded']['items'][0];
$this->assertArrayHasKey('id', $deletionData);
$this->assertArrayHasKey('entry_id', $deletionData);
$this->assertArrayHasKey('deleted_at', $deletionData);
$this->assertArrayNotHasKey('user_id', $deletionData);
}
public function testGetEntryDeletionsSince()
{
$since = (new \DateTime('-2 days'))->getTimestamp();
$this->client->request('GET', "/api/entry-deletions?since={$since}");
$this->assertSame(200, $this->client->getResponse()->getStatusCode());
$content = json_decode($this->client->getResponse()->getContent(), true);
$this->assertGreaterThanOrEqual(1, \count($content['_embedded']['items']));
}
public function testGetEntryDeletionsWithSinceBeforeCutoffDate()
{
$sinceBeforeCutoff = (new \DateTime('-410 days'))->getTimestamp();
$this->client->request('GET', "/api/entry-deletions?since={$sinceBeforeCutoff}");
$this->assertSame(410, $this->client->getResponse()->getStatusCode());
$content = $this->client->getResponse()->getContent();
$this->assertStringContainsString('The requested since date', $content);
$this->assertStringContainsString('is before the data retention cutoff date', $content);
$this->assertStringContainsString('X-Wallabag-Entry-Deletion-Cutoff', $content);
$response = $this->client->getResponse();
$this->assertTrue($response->headers->has('X-Wallabag-Entry-Deletion-Cutoff'));
$cutoffTimestamp = $response->headers->get('X-Wallabag-Entry-Deletion-Cutoff');
$this->assertIsNumeric($cutoffTimestamp);
$this->assertGreaterThan($sinceBeforeCutoff, (int) $cutoffTimestamp);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Tests\Wallabag\Event\Subscriber;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Wallabag\Entity\Entry;
use Wallabag\Entity\EntryDeletion;
use Wallabag\Entity\User;
use Wallabag\Event\EntryDeletedEvent;
use Wallabag\Event\Subscriber\EntryDeletionSubscriber;
class EntryDeletionSubscriberTest extends TestCase
{
public function testEntryDeletionCreatedWhenEntryDeleted(): void
{
$user = new User();
$entry = new Entry($user);
// the subscriber expects a previously persisted Entry to work
$reflectedEntry = new \ReflectionClass($entry);
$property = $reflectedEntry->getProperty('id');
$property->setAccessible(true);
$property->setValue($entry, 123);
$em = $this->createMock(EntityManagerInterface::class);
// when the event is triggered, an EntryDeletion should be persisted and flushed
$em->expects($this->once())
->method('persist')
->with($this->callback(function ($deletion) use ($entry) {
return $deletion instanceof EntryDeletion
&& $deletion->getEntryId() === $entry->getId()
&& $deletion->getUser() === $entry->getUser();
}));
$em->expects($this->atLeastOnce())
->method('flush');
// trigger the event to run the mocked up persist and flush
/** @var EntityManagerInterface $em */
$subscriber = new EntryDeletionSubscriber($em);
$event = new EntryDeletedEvent($entry);
$subscriber->onEntryDeleted($event);
}
}