Migration: PM-Datum korrekt übernehmen, UUID stabil, Zwei-Phasen-Runbook

- published_at = Legacy-created_at (Publikationsdatum, Frontend-Sortier-
  schlüssel) statt updated_at, das bei Alt-Daten oft den Massen-/Migrations-
  stempel trägt und ein falsches "frisches" Datum erzeugte.
- created_at/updated_at per forceFill direkt im Import gesetzt (waren nicht
  fillable, wurden still verworfen) – Import allein ist jetzt datumssauber.
- legacy:fix-timestamps korrigiert zusätzlich published_at (status=published).
- PM-UUID bleibt beim Re-Import/Delta-Lauf erhalten (kein Neu-Würfeln).
- MIGRATION-STEPS auf Zwei-Phasen-Strategie umgestellt: migrate, Phase 1 mit
  --force, Phase 2 (Delta) ohne --force, Grandfather-Re-Run, Idempotenz-Tabelle.
- Tests: LegacyPressReleaseDateImportTest (published_at-Quelle, Entwurf,
  Force-Re-Import erhält UUID + Datum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 13:32:48 +00:00
parent 7dbc238bdb
commit 97d69a1a3a
4 changed files with 297 additions and 40 deletions

View file

@ -0,0 +1,180 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\LegacyImportMap;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Import\ImportContext;
use App\Services\Import\PressReleaseImporter;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* Baut das minimale Legacy-Schema (press_release + leere Bild-/Kontakt-Pivots)
* und legt Kategorie + User samt Import-Map-Einträgen an, damit der Importer
* Kategorie und Autor auflösen kann (ohne Autor wird eine PM übersprungen).
*/
function seedLegacyPressReleaseSchema(): Category
{
Config::set('database.connections.mysql_presseecho', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('mysql_presseecho');
Schema::connection('mysql_presseecho')->create('press_release', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('title')->nullable();
$table->string('language')->nullable();
$table->text('text')->nullable();
$table->string('backlink_url')->nullable();
$table->integer('category_id')->nullable();
$table->string('keywords')->nullable();
$table->integer('user_id')->nullable();
$table->integer('company_id')->nullable();
$table->string('status')->nullable();
$table->integer('hits')->nullable();
$table->integer('teaser_begin')->nullable();
$table->integer('teaser_end')->nullable();
$table->boolean('no_export')->nullable();
$table->dateTime('created_at')->nullable();
$table->dateTime('updated_at')->nullable();
$table->string('slug')->nullable();
});
Schema::connection('mysql_presseecho')->create('press_release_image', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->integer('press_release_id');
});
Schema::connection('mysql_presseecho')->create('press_release_contact', function (Blueprint $table): void {
$table->integer('press_release_id');
$table->integer('contact_id');
});
$category = Category::factory()->withTranslations()->create();
LegacyImportMap::query()->create([
'legacy_portal' => 'presseecho',
'legacy_table' => 'category',
'legacy_id' => 500,
'target_table' => 'categories',
'target_id' => $category->id,
]);
$author = User::factory()->create();
LegacyImportMap::query()->create([
'legacy_portal' => 'presseecho',
'legacy_table' => 'sf_guard_user',
'legacy_id' => 700,
'target_table' => 'users',
'target_id' => $author->id,
]);
return $category;
}
test('a published release takes published_at from the legacy created_at, not updated_at', function () {
/** @var TestCase $this */
seedLegacyPressReleaseSchema();
// updated_at trägt im Altsystem den Massen-/Migrationsstempel (2026) – das
// darf NICHT als Veröffentlichungsdatum landen.
DB::connection('mysql_presseecho')->table('press_release')->insert([
'id' => 10,
'title' => 'Alte veröffentlichte Meldung',
'language' => 'de',
'text' => 'Inhalt',
'category_id' => 500,
'user_id' => 700,
'status' => 'published',
'hits' => 5,
'no_export' => false,
'created_at' => '2008-11-18 12:05:42',
'updated_at' => '2026-04-23 01:08:26',
'slug' => 'alte-meldung',
]);
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
$pr = PressRelease::withoutGlobalScopes()
->where('legacy_portal', 'presseecho')
->where('legacy_id', 10)
->firstOrFail();
expect($pr->status)->toBe(PressReleaseStatus::Published);
expect($pr->published_at?->format('Y-m-d H:i:s'))->toBe('2008-11-18 12:05:42');
expect($pr->created_at?->format('Y-m-d H:i:s'))->toBe('2008-11-18 12:05:42');
});
test('a non-published release has no published_at and keeps its legacy created_at', function () {
/** @var TestCase $this */
seedLegacyPressReleaseSchema();
DB::connection('mysql_presseecho')->table('press_release')->insert([
'id' => 11,
'title' => 'Entwurf',
'language' => 'de',
'text' => 'Inhalt',
'category_id' => 500,
'user_id' => 700,
'status' => 'new',
'no_export' => false,
'created_at' => '2010-03-01 09:00:00',
'updated_at' => '2026-04-23 01:08:26',
'slug' => 'entwurf',
]);
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
$pr = PressRelease::withoutGlobalScopes()
->where('legacy_portal', 'presseecho')
->where('legacy_id', 11)
->firstOrFail();
expect($pr->status)->toBe(PressReleaseStatus::Draft);
expect($pr->published_at)->toBeNull();
expect($pr->created_at?->format('Y-m-d H:i:s'))->toBe('2010-03-01 09:00:00');
});
test('a force re-import preserves the uuid and the publication date', function () {
/** @var TestCase $this */
seedLegacyPressReleaseSchema();
DB::connection('mysql_presseecho')->table('press_release')->insert([
'id' => 12,
'title' => 'Stabile Referenz',
'language' => 'de',
'text' => 'Inhalt',
'category_id' => 500,
'user_id' => 700,
'status' => 'published',
'no_export' => false,
'created_at' => '2009-04-08 15:43:42',
'updated_at' => '2026-04-21 22:07:40',
'slug' => 'stabile-referenz',
]);
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
$first = PressRelease::withoutGlobalScopes()
->where('legacy_portal', 'presseecho')->where('legacy_id', 12)->firstOrFail();
$originalUuid = $first->uuid;
// Zweiter --force-Lauf (Delta-Szenario): darf uuid nicht neu würfeln.
app(PressReleaseImporter::class)->run(new ImportContext('presseecho', false, true));
$second = PressRelease::withoutGlobalScopes()
->where('legacy_portal', 'presseecho')->where('legacy_id', 12)->firstOrFail();
expect($second->uuid)->toBe($originalUuid);
expect($second->published_at?->format('Y-m-d H:i:s'))->toBe('2009-04-08 15:43:42');
});