Update migrations

This commit is contained in:
DariusIII
2026-08-14 12:43:33 +02:00
parent 28e54ee56c
commit 931fc55d8a
17 changed files with 1223 additions and 56 deletions
@@ -78,10 +78,12 @@ class NzbCreationReliabilityTest extends TestCase
$this->createSchema();
$this->seedSettings();
NzbCreationCandidateQuery::flushCapabilityCache();
}
protected function tearDown(): void
{
NzbCreationCandidateQuery::flushCapabilityCache();
$this->deleteDirectory($this->tempNzbPath);
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
unlink($this->databasePath);
@@ -143,6 +145,8 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertSame(1, DB::table('releases')->count());
$this->assertSame(1, (int) DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('attempts'));
$this->assertSame('Temporary filesystem failure.', DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('last_error'));
$this->assertNotNull(DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('created_at'));
$this->assertNotNull(DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('updated_at'));
$this->assertNull(DB::table('releases')->where('id', 1)->value('nzb_creation_claimed_at'));
$this->assertSame(1, DB::table('collections')->count());
$this->assertSame('NZB creation failed; release will be retried', $nzbCreationLogger->warnings[0]['message']);
@@ -185,6 +189,56 @@ class NzbCreationReliabilityTest extends TestCase
$this->assertSame(3, $nzbCreationLogger->warnings[0]['context']['max_attempts']);
}
public function test_repeated_transient_failure_increments_attempts_and_touches_updated_at(): void
{
Log::partialMock();
Log::shouldReceive('channel')->once()->with('nzb_creation')->andReturn(new RecordingNzbCreationLogger);
$this->insertRelease(1, 'a');
$this->insertCbp(200, 2000, 1);
DB::table('release_nzb_creation_failures')->insert([
'releases_id' => 1,
'attempts' => 1,
'last_error' => 'Earlier failure.',
'created_at' => '2026-01-01 00:00:00',
'updated_at' => '2026-01-01 00:00:00',
]);
$service = $this->releaseProcessingService(
NzbCreationResult::transient('Another filesystem failure.', [200])
);
$this->assertSame(0, $service->createNZBs(null));
$failure = DB::table('release_nzb_creation_failures')->where('releases_id', 1)->first();
$this->assertSame(2, (int) $failure->attempts);
$this->assertSame('Another filesystem failure.', $failure->last_error);
$this->assertSame('2026-01-01 00:00:00', $failure->created_at);
$this->assertNotSame('2026-01-01 00:00:00', $failure->updated_at);
}
public function test_attempt_cap_holds_when_the_failure_relation_was_dropped(): void
{
Log::partialMock();
$nzbCreationLogger = new RecordingNzbCreationLogger;
Log::shouldReceive('channel')->once()->with('nzb_creation')->andReturn($nzbCreationLogger);
$this->insertRelease(1, 'a', attempts: 2);
$this->insertCbp(200, 2000, 1);
$service = (new ReleaseProcessingService(
nzb: new RelationDroppingNzbService(NzbCreationResult::transient('Repeated filesystem failure.', [200])),
releaseManagement: new DatabaseOnlyReleaseManagementService,
collectionCleanupService: app(CollectionCleanupService::class),
))->setEchoCLI(false);
$this->assertSame(0, $service->createNZBs(null));
$this->assertSame(0, DB::table('releases')->count());
$this->assertSame('Deleting release after NZB creation failure', $nzbCreationLogger->warnings[0]['message']);
$this->assertSame(3, $nzbCreationLogger->warnings[0]['context']['attempt']);
}
public function test_writer_classifies_final_rename_failure_as_transient_without_final_file(): void
{
$this->insertRelease(1, 'd');
@@ -458,6 +512,16 @@ class FakeNzbCreationService extends NzbService
}
}
class RelationDroppingNzbService extends FakeNzbCreationService
{
public function createNzbForRelease(Release $release): NzbCreationResult
{
$release->unsetRelation('nzbCreationFailure');
return parent::createNzbForRelease($release);
}
}
class DatabaseOnlyReleaseManagementService extends ReleaseManagementService
{
/**
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Release;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use PDO;
use Tests\TestCase;
final class ReleaseFactoryInvariantsTest extends TestCase
{
private string $databasePath = '';
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-release-factory.sqlite';
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
putenv('APP_ENV=testing');
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE='.$this->databasePath);
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'testing';
$_ENV['DB_CONNECTION'] = $_SERVER['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = $_SERVER['DB_DATABASE'] = $this->databasePath;
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
DB::purge();
DB::reconnect();
DB::statement('CREATE TABLE releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL DEFAULT "",
searchname VARCHAR(255) NOT NULL DEFAULT "",
fromname VARCHAR(255) NULL,
postdate DATETIME NULL,
adddate DATETIME NULL,
guid CHAR(36) NOT NULL UNIQUE,
leftguid CHAR(1) NOT NULL,
categories_id INTEGER NOT NULL DEFAULT 10,
nzbstatus INTEGER NOT NULL DEFAULT 0,
passwordstatus INTEGER NOT NULL DEFAULT -1,
isrenamed INTEGER NOT NULL DEFAULT 0
)');
}
protected function tearDown(): void
{
DB::disconnect();
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
}
public function test_factory_produces_uuid_guid_with_matching_leftguid(): void
{
for ($iteration = 0; $iteration < 5; $iteration++) {
$attributes = Release::factory()->raw();
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD',
(string) $attributes['guid'],
'The factory must produce a UUID guid that fits the char(36) column.',
);
$this->assertSame(36, mb_strlen((string) $attributes['guid']));
$this->assertSame(
mb_strtolower(mb_substr((string) $attributes['guid'], 0, 1)),
mb_strtolower((string) $attributes['leftguid']),
'leftguid must be the first character of guid.',
);
}
}
public function test_factory_rows_satisfy_the_release_table_constraints(): void
{
foreach (Release::factory()->count(3)->raw() as $attributes) {
DB::table('releases')->insert($attributes);
}
$rows = DB::table('releases')->get(['guid', 'leftguid']);
$this->assertCount(3, $rows);
foreach ($rows as $row) {
$this->assertSame(mb_substr((string) $row->guid, 0, 1), (string) $row->leftguid);
}
$this->assertSame(3, $rows->pluck('guid')->unique()->count());
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use PDO;
use ReflectionMethod;
use RuntimeException;
use Tests\Integration\ReleaseSchemaOptimizationMigrationMariaDbTest;
use Tests\TestCase;
/**
* Portable coverage for the hardening added to the releases normalization
* migration: the chunked comment recount, the batched rollback backfill, and
* the preflight opt-out. The full schema rebuild is MariaDB-only and lives in
* {@see ReleaseSchemaOptimizationMigrationMariaDbTest}.
*/
final class ReleaseSchemaOptimizationMigrationTest extends TestCase
{
private string $databasePath = '';
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-releases-migration.sqlite';
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => $this->databasePath,
'nntmux.releases_optimize.chunk_size' => 100,
'nntmux.releases_optimize.skip_preflight' => false,
]);
DB::purge();
DB::reconnect();
$this->createSchema();
}
protected function tearDown(): void
{
DB::disconnect();
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
}
public function test_comment_recount_spans_multiple_chunks_and_skips_correct_rows(): void
{
$releases = [];
$comments = [];
$commentId = 1;
for ($id = 1; $id <= 250; $id++) {
// Every third release already stores the right counter and must not be rewritten.
$alreadyCorrect = $id % 3 === 0;
$releases[] = ['id' => $id, 'guid' => 'g'.$id, 'leftguid' => 'g', 'comments' => $alreadyCorrect ? 1 : 99];
$comments[] = ['id' => $commentId++, 'releases_id' => $id, 'isvisible' => 1];
$comments[] = ['id' => $commentId++, 'releases_id' => $id, 'isvisible' => 0];
}
DB::table('releases')->insert($releases);
DB::table('release_comments')->insert($comments);
$this->invoke('recountVisibleComments');
$this->assertSame(0, DB::table('releases')->where('comments', '<>', 1)->count());
$this->assertSame(250, DB::table('releases')->where('comments', 1)->count());
}
public function test_comment_recount_zeroes_releases_without_visible_comments(): void
{
DB::table('releases')->insert([
['id' => 1, 'guid' => 'a', 'leftguid' => 'a', 'comments' => 7],
['id' => 2, 'guid' => 'b', 'leftguid' => 'b', 'comments' => 0],
]);
DB::table('release_comments')->insert([['id' => 1, 'releases_id' => 1, 'isvisible' => 0]]);
$this->invoke('recountVisibleComments');
$this->assertSame(0, (int) DB::table('releases')->where('id', 1)->value('comments'));
$this->assertSame(0, (int) DB::table('releases')->where('id', 2)->value('comments'));
}
public function test_rollback_backfill_restores_sparse_rows_in_batches(): void
{
$releases = [];
$passwords = [];
$failures = [];
for ($id = 1; $id <= 250; $id++) {
$releases[] = ['id' => $id, 'guid' => 'g'.$id, 'leftguid' => 'g', 'comments' => 0];
$passwords[] = ['releases_id' => $id, 'password' => 'secret-'.$id];
$failures[] = ['releases_id' => $id, 'attempts' => $id % 5, 'last_error' => 'boom-'.$id];
}
DB::table('releases')->insert($releases);
DB::table('release_nzb_passwords')->insert($passwords);
DB::table('release_nzb_creation_failures')->insert($failures);
$this->invoke('restoreSparseDataToReleases');
$restored = DB::table('releases')->orderBy('id')->get();
$this->assertCount(250, $restored);
foreach ($restored as $release) {
$this->assertSame('secret-'.$release->id, $release->nzb_password);
$this->assertSame((int) $release->id % 5, (int) $release->nzb_creation_attempts);
$this->assertSame('boom-'.$release->id, $release->nzb_creation_last_error);
}
}
public function test_preflight_blocks_bad_identifiers_and_the_opt_out_bypasses_it(): void
{
DB::table('releases')->insert([
['id' => 1, 'guid' => 'not-a-uuid', 'leftguid' => 'z', 'comments' => 0],
]);
try {
$this->invoke('assertReleaseIdentifiersAreSafe');
$this->fail('The migration accepted an invalid guid.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('invalid release GUIDs', $e->getMessage());
$this->assertStringContainsString('releases:normalize-guids', $e->getMessage());
}
config(['nntmux.releases_optimize.skip_preflight' => true]);
$this->invoke('assertReleaseIdentifiersAreSafe');
$this->assertSame(1, DB::table('releases')->count());
}
private function invoke(string $method): void
{
/** @var Migration $migration */
$migration = require database_path('migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php');
(new ReflectionMethod($migration, $method))->invoke($migration);
}
private function createSchema(): void
{
foreach (['release_comments', 'release_nzb_passwords', 'release_nzb_creation_failures', 'releases'] as $table) {
DB::statement('DROP TABLE IF EXISTS '.$table);
}
DB::statement('CREATE TABLE releases (
id INTEGER PRIMARY KEY, guid VARCHAR(40) NOT NULL, leftguid CHAR(1) NOT NULL,
comments INTEGER NOT NULL DEFAULT 0, nzb_password VARCHAR(255) NULL,
nzb_creation_attempts INTEGER NOT NULL DEFAULT 0, nzb_creation_last_error TEXT NULL
)');
DB::statement('CREATE TABLE release_comments (
id INTEGER PRIMARY KEY, releases_id INTEGER NOT NULL, isvisible INTEGER NOT NULL DEFAULT 1
)');
DB::statement('CREATE TABLE release_nzb_passwords (
releases_id INTEGER PRIMARY KEY, password VARCHAR(255) NOT NULL
)');
DB::statement('CREATE TABLE release_nzb_creation_failures (
releases_id INTEGER PRIMARY KEY, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT NULL
)');
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv("{$key}={$value}");
$_ENV[$key] = $_SERVER[$key] = $value;
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use PDO;
use Tests\TestCase;
final class ReleasesNormalizeGuidsTest extends TestCase
{
private string $databasePath = '';
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-normalize-guids.sqlite';
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => $this->databasePath,
]);
DB::purge();
DB::reconnect();
$this->createSchema();
}
protected function tearDown(): void
{
DB::disconnect();
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
}
public function test_leftguid_is_resynced_in_place(): void
{
$guid = '01234567-89ab-cdef-0123-456789abcdef';
$this->insertRelease(1, $guid, 'f');
$status = Artisan::call('releases:normalize-guids');
$this->assertSame(0, $status);
$this->assertSame('0', DB::table('releases')->where('id', 1)->value('leftguid'));
$this->assertSame($guid, DB::table('releases')->where('id', 1)->value('guid'));
}
public function test_dry_run_reports_without_writing(): void
{
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', 'f');
$this->insertRelease(2, 'd41d8cd98f00b204e9800998ecf8427e', 'd');
$status = Artisan::call('releases:normalize-guids', ['--dry-run' => true]);
$output = Artisan::output();
$this->assertSame(1, $status);
$this->assertStringContainsString('Dry run: no rows are modified.', $output);
$this->assertSame('f', DB::table('releases')->where('id', 1)->value('leftguid'));
$this->assertSame('d41d8cd98f00b204e9800998ecf8427e', DB::table('releases')->where('id', 2)->value('guid'));
}
public function test_invalid_guid_is_reported_and_never_rewritten(): void
{
$this->insertRelease(1, 'd41d8cd98f00b204e9800998ecf8427e', 'd', ['name' => 'Legacy nZEDb release']);
$status = Artisan::call('releases:normalize-guids');
$output = Artisan::output();
$this->assertSame(1, $status);
$this->assertStringContainsString('1 releases have a guid that blocks the normalization migration.', $output);
$this->assertStringContainsString('d41d8cd98f00b204e9800998ecf8427e', $output);
$this->assertStringContainsString('Legacy nZEDb release', $output);
$this->assertSame('d41d8cd98f00b204e9800998ecf8427e', DB::table('releases')->where('id', 1)->value('guid'));
}
public function test_case_insensitive_duplicates_report_every_id_but_the_lowest(): void
{
$guid = 'abcdef01-2345-6789-abcd-ef0123456789';
$this->insertRelease(1, $guid, 'a');
$this->insertRelease(2, strtoupper($guid), 'A');
$status = Artisan::call('releases:normalize-guids');
$output = Artisan::output();
$this->assertSame(1, $status);
$this->assertStringContainsString('1 releases have a guid that blocks', $output);
$this->assertSame($guid, DB::table('releases')->where('id', 1)->value('guid'));
$this->assertSame(strtoupper($guid), DB::table('releases')->where('id', 2)->value('guid'));
}
public function test_consistent_guids_report_success(): void
{
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0');
$status = Artisan::call('releases:normalize-guids');
$this->assertSame(0, $status);
$this->assertStringContainsString('Release guids are consistent.', Artisan::output());
}
/** @param array<string, mixed> $overrides */
private function insertRelease(int $id, string $guid, string $leftguid, array $overrides = []): void
{
DB::table('releases')->insert([
'id' => $id,
'guid' => $guid,
'leftguid' => $leftguid,
'name' => 'Release '.$id,
...$overrides,
]);
}
private function createSchema(): void
{
DB::statement('DROP TABLE IF EXISTS releases');
DB::statement('CREATE TABLE releases (
id INTEGER PRIMARY KEY,
guid VARCHAR(40) NOT NULL,
leftguid CHAR(1) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT ""
)');
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv("{$key}={$value}");
$_ENV[$key] = $_SERVER[$key] = $value;
}
}
@@ -106,6 +106,22 @@ final class ReleasesOptimizePreflightTest extends TestCase
}
}
public function test_human_report_includes_storage_discarded_data_and_index_sections(): void
{
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0', ['source' => 2]);
$status = Artisan::call('releases:optimize-preflight');
$output = Artisan::output();
$this->assertSame(0, $status);
$this->assertStringContainsString('Storage: not reported for the sqlite driver.', $output);
$this->assertStringContainsString('Dropped column', $output);
$this->assertStringContainsString('releases.source', $output);
$this->assertStringContainsString('permanently destroyed', $output);
$this->assertStringContainsString('Index change', $output);
$this->assertStringContainsString('ux_releases_guid', $output);
}
/** @param array<string, mixed> $overrides */
private function insertRelease(int $id, string $guid, string $leftguid, array $overrides = []): void
{
@@ -113,6 +113,37 @@ final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase
$this->assertDatabaseHas('release_files', ['releases_id' => 1, 'name' => 'kept.nzb']);
}
#[Test]
public function claim_token_and_pp_index_follow_up_narrows_columns_and_adds_size(): void
{
$this->seedLegacyData();
$this->migration()->up();
$followUp = $this->followUpMigration();
$followUp->up();
$this->assertSame(
['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at', 'size'],
$this->indexColumns('ix_releases_add_pp_claim_queue'),
);
foreach (['nzb_creation_claim_token', 'additional_pp_claim_token'] as $column) {
$this->assertSame('char(32)', strtolower($this->columnType($column)));
$this->assertSame('ascii_general_ci', $this->columnCollation($column));
}
$token = bin2hex(random_bytes(16));
DB::table('releases')->where('id', 1)->update(['additional_pp_claim_token' => $token]);
$this->assertSame($token, DB::table('releases')->where('id', 1)->value('additional_pp_claim_token'));
$followUp->down();
$this->assertSame(
['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'],
$this->indexColumns('ix_releases_add_pp_claim_queue'),
);
$this->assertSame('varchar(64)', strtolower($this->columnType('additional_pp_claim_token')));
}
private function createSchema(): void
{
$releases = $this->table('releases');
@@ -260,6 +291,14 @@ final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase
return $migration;
}
private function followUpMigration(): Migration
{
/** @var Migration $migration */
$migration = require database_path('migrations/2026_08_14_121946_optimize_releases_claim_tokens_and_pp_index.php');
return $migration;
}
private function table(string $name): string
{
return $this->tablePrefix.$name;