Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .docker/app/composer.snapshots.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"_comment": "Snapshots variant of app/composer.json — keep in sync; only difference is silverstripe/versioned-snapshots in require",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"silverstripe/recipe-cms": "^5.0",
"silverstripe/versioned-snapshots": "^2",
"wedevelopnl/silverstripe-sweeper": "*"
},
"repositories": [
{
"type": "path",
"url": "/module",
"options": {
"symlink": true
}
}
],
"autoload": {
"classmap": [
"src/"
]
},
"require-dev": {
"cambis/silverstan": "^2.1",
"phpunit/phpunit": "^9.6",
"silverstripe/standards": "^1",
"phpstan/extension-installer": "^1.3",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload-dev": {
"psr-4": {
"Sweeper\\Tests\\": "vendor/wedevelopnl/silverstripe-sweeper/tests/"
}
},
"config": {
"allow-plugins": {
"composer/installers": true,
"phpstan/extension-installer": true,
"silverstripe/recipe-plugin": true,
"silverstripe/vendor-plugin": true
}
},
"extra": {
"project-files-installed": [
".htaccess",
"app/.htaccess",
"app/_config/mimevalidator.yml",
"app/_config/mysite.yml",
"app/src/Page.php",
"app/src/PageController.php"
],
"public-files-installed": [
".htaccess",
"index.php",
"web.config"
]
}
}
4 changes: 4 additions & 0 deletions .docker/app/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
</testsuite>
<testsuite name="integration">
<directory>vendor/wedevelopnl/silverstripe-sweeper/tests/Integration</directory>
<exclude>vendor/wedevelopnl/silverstripe-sweeper/tests/Integration/Snapshots</exclude>
</testsuite>
<testsuite name="snapshots">
<directory>vendor/wedevelopnl/silverstripe-sweeper/tests/Integration/Snapshots</directory>
</testsuite>
</testsuites>
<coverage>
Expand Down
34 changes: 34 additions & 0 deletions .docker/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,39 @@ services:
SS_ENVIRONMENT_TYPE: dev
SS_PHPUNIT_FLUSH: 1

app-snapshots:
profiles: [snapshots]
build:
context: .
dockerfile: Dockerfile
args:
PHP_VERSION: ${PHP_VERSION:-8.3}
COMPOSER_FILE: app/composer.snapshots.json
volumes:
- ../composer.json:/module/composer.json:ro
- ../src:/module/src
- ../tests:/module/tests:ro
- ../_config:/module/_config:ro
- ../coverage:/app/coverage
- vendor-snapshots:/app/vendor
healthcheck:
test: test -f /tmp/.app-ready
interval: 3s
start_period: 60s
retries: 20
depends_on:
db:
condition: service_healthy
environment:
SS_DATABASE_SERVER: db
SS_DATABASE_NAME: silverstripe_snapshots
SS_DATABASE_USERNAME: silverstripe
SS_DATABASE_PASSWORD: silverstripe
SS_DEFAULT_ADMIN_USERNAME: admin
SS_DEFAULT_ADMIN_PASSWORD: admin
SS_ENVIRONMENT_TYPE: dev
SS_PHPUNIT_FLUSH: 1

db:
image: mysql:8
ports:
Expand All @@ -53,4 +86,5 @@ services:

volumes:
vendor:
vendor-snapshots:
db-data:
1 change: 1 addition & 0 deletions .docker/db-init/create-snapshots-db.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS `silverstripe_snapshots`;
17 changes: 17 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version: '3'

vars:
COMPOSE: docker compose -f .docker/compose.yml
COMPOSE_SNAPSHOTS: docker compose -f .docker/compose.yml --profile snapshots

tasks:
docker-env:
Expand Down Expand Up @@ -52,18 +53,34 @@ tasks:
desc: Run PHP unit tests
deps: [ensure-up]
cmds:
- "{{.COMPOSE}} exec -T app sh -c 'rm -rf /tmp/silverstripe-cache-*'"
- "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite unit"

test-integration:
desc: Run PHP integration tests
deps: [ensure-up]
cmds:
- "{{.COMPOSE}} exec -T app sh -c 'rm -rf /tmp/silverstripe-cache-*'"
- "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite integration"

ensure-up-snapshots:
desc: Ensure the snapshots-profile services are running and ready
deps: [docker-env]
cmds:
- "{{.COMPOSE_SNAPSHOTS}} exec app-snapshots true 2>/dev/null || {{.COMPOSE_SNAPSHOTS}} up -d --build --wait"

test-snapshots:
desc: Run PHP tests for the versioned-snapshots optional dependency
deps: [ensure-up-snapshots]
cmds:
- "{{.COMPOSE_SNAPSHOTS}} exec -T app-snapshots sh -c 'rm -rf /tmp/silverstripe-cache-*'"
- "{{.COMPOSE_SNAPSHOTS}} exec app-snapshots vendor/bin/phpunit --testsuite snapshots"

coverage:
desc: Run tests with coverage (HTML + Clover)
deps: [ensure-up]
cmds:
- "{{.COMPOSE}} exec -T app sh -c 'rm -rf /tmp/silverstripe-cache-*'"
- >-
{{.COMPOSE}} exec app vendor/bin/phpunit
--coverage-html coverage/html
Expand Down
2 changes: 1 addition & 1 deletion src/Tasks/SweeperClearArchiveTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public function flushSnapshots(string $class): void

$list = $object->getRelevantSnapshots();
$list = $list->sort('"LastEdited"', 'DESC');
$objectHash = (SweeperClearArchiveTask::SNAPSHOT_CLASS)::hashObjectForSnapshot($object);
$objectHash = (SweeperClearArchiveTask::SNAPSHOT_CLASS)::singleton()->hashObjectForSnapshot($object);

$fullVersions = 0;
foreach ($list as $snapshot) {
Expand Down
Empty file removed tests/Integration/.gitkeep
Empty file.
21 changes: 21 additions & 0 deletions tests/Integration/Other/NonMatchingNamespaceObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Sweeper\Tests\Integration\Other;

use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;

/**
* Lives outside the Support namespace so the namespace-filter test has a control
* whose FQCN does not contain "Support".
*/
class NonMatchingNamespaceObject extends DataObject implements TestOnly
{
private static string $table_name = 'SweeperTest_OtherNs';

private static array $db = [
'Title' => 'Varchar',
];
}
158 changes: 158 additions & 0 deletions tests/Integration/Snapshots/SweeperClearArchiveSnapshotsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

declare(strict_types=1);

namespace Sweeper\Tests\Integration\Snapshots;

use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\DataObject;
use SilverStripe\Snapshots\ActivityEntry;
use SilverStripe\Snapshots\Snapshot;
use SilverStripe\Snapshots\SnapshotEvent;
use SilverStripe\Snapshots\SnapshotItem;
use SilverStripe\Versioned\Versioned;
use Sweeper\Tasks\SweeperClearArchiveTask;
use Sweeper\Tests\Integration\Support\ExposedClearArchiveTask;
use Sweeper\Tests\Integration\Support\ThrowingSnapshotRecord;
use Sweeper\Tests\Integration\Support\VersionedRecord;

/**
* Runs only under the `snapshots` profile (silverstripe/versioned-snapshots installed).
*
* @covers \Sweeper\Tasks\SweeperClearArchiveTask
*/
final class SweeperClearArchiveSnapshotsTest extends SapphireTest
{
protected static $extra_dataobjects = [
VersionedRecord::class,
ThrowingSnapshotRecord::class,
];

public function testBaseVersionedClassesExcludesSnapshotEventWhenInstalled(): void
{
// SnapshotEvent must actually be a candidate (direct DataObject subclass + Versioned),
// otherwise its absence below wouldn't prove the exclusion guard did anything.
self::assertSame(DataObject::class, get_parent_class(SnapshotEvent::class));
self::assertTrue(DataObject::has_extension(SnapshotEvent::class, Versioned::class));

$task = ExposedClearArchiveTask::create();
$classes = iterator_to_array($task->exposedGetBaseVersionedClasses());

self::assertNotContains(SnapshotEvent::class, $classes);
}

public function testFlushSnapshotsDryReportsCountsAndDeletesNothing(): void
{
Config::modify()->set(SweeperClearArchiveTask::class, 'keep', 2);
$record = $this->makeSnapshots(5);
$before = $this->fullVersionCount($record);
self::assertGreaterThan(2, $before);

$task = SweeperClearArchiveTask::create();
$task->setDry(true);

$output = $this->discardOutput(static fn () => $task->flushSnapshots(VersionedRecord::class));

self::assertStringContainsString('(dry-run)', $output);
self::assertStringContainsString('Cleared', $output);
self::assertSame($before, $this->fullVersionCount($record));
}

public function testFlushSnapshotsRealKeepsExactlyKeptCount(): void
{
Config::modify()->set(SweeperClearArchiveTask::class, 'keep', 2);
$record = $this->makeSnapshots(5);
self::assertGreaterThan(2, $this->fullVersionCount($record));

$task = SweeperClearArchiveTask::create();
$task->setDry(false);

$this->discardOutput(static fn () => $task->flushSnapshots(VersionedRecord::class));

self::assertSame(2, $this->fullVersionCount($record));
}

public function testFlushSnapshotsSwallowsPerObjectExceptions(): void
{
$record = ThrowingSnapshotRecord::create();
$record->Title = 'boom';
$record->write();

$task = SweeperClearArchiveTask::create();
$task->setDry(false);

// getRelevantSnapshots() throws; flushSnapshots() must catch, log, and continue.
$output = $this->discardOutput(
static fn () => $task->flushSnapshots(ThrowingSnapshotRecord::class),
);

self::assertStringContainsString('Exception during parsing of object', $output);
}

/**
* Create a VersionedRecord carrying $n full-version snapshots.
*
* Discovery (Step 1): bare write() and publishSingle() produce 0 full-version
* snapshots in the SapphireTest context because versioned-snapshots creates
* Snapshot records only via its event-handler system (not via ORM hooks).
* The reliable recipe is to write the record once to obtain an ID, then
* directly insert Snapshot + SnapshotItem rows using the ORM.
*/
private function makeSnapshots(int $n): VersionedRecord
{
$record = VersionedRecord::create();
$record->Title = 'initial';
$record->write();

$objectHash = Snapshot::singleton()->hashObjectForSnapshot($record);

for ($i = 1; $i <= $n; $i++) {
$snapshot = Snapshot::create();
$snapshot->OriginClass = $record->baseClass();
$snapshot->OriginID = $record->ID;
$snapshot->OriginHash = $objectHash;
$snapshot->write();

$item = SnapshotItem::create();
$item->ObjectClass = $record->baseClass();
$item->ObjectID = $record->ID;
$item->ObjectVersion = $record->Version;
$item->ObjectHash = $objectHash;
$item->SnapshotID = $snapshot->ID;
$item->WasDraft = true;
$item->WasCreated = ($i === 1);
$item->write();
}

return $record;
}

private function fullVersionCount(VersionedRecord $record): int
{
$objectHash = Snapshot::singleton()->hashObjectForSnapshot($record);
$count = 0;
foreach ($record->getRelevantSnapshots() as $snapshot) {
if (
$snapshot->OriginHash === $objectHash
&& $snapshot->getActivityType() !== ActivityEntry::DELETED
) {
$count++;
}
}

return $count;
}

private function discardOutput(callable $fn): string
{
ob_start();
try {
$fn();
} finally {
$output = ob_get_clean();
}

return (string) $output;
}
}
15 changes: 15 additions & 0 deletions tests/Integration/Support/AppliedExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Sweeper\Tests\Integration\Support;

use SilverStripe\ORM\DataExtension;

/**
* A DataExtension applied to ReportPopulatedObject — the report must treat it
* as "applied" and exclude it from the never-applied list.
*/
class AppliedExtension extends DataExtension
{
}
19 changes: 19 additions & 0 deletions tests/Integration/Support/ExposedClearArchiveTask.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Sweeper\Tests\Integration\Support;

use Generator;
use Sweeper\Tasks\SweeperClearArchiveTask;

/**
* Exposes protected methods of SweeperClearArchiveTask for direct testing.
*/
class ExposedClearArchiveTask extends SweeperClearArchiveTask
{
public function exposedGetBaseVersionedClasses(): Generator
{
return $this->getBaseVersionedClasses();
}
}
Loading