Files
Jürgen MummertandClaude Fable 5.1 3aad6aa559 Store the display fields of events as virtual fields
- Move 29 display-only event fields (subtitles, audience, tags, prices,
  price types, organizer) from columns into the jsonData storage; fields
  used for filtering, joins and calculations stay real columns
- Add EventFieldsToVirtualMigration, which copies the values and drops
  the columns on contao:migrate
- Read and write the fields in the import, the preview and the booking
  through EventRowCodec; other bundles' keys in the storage are kept
- Validate prices in a save callback instead of the percent check that
  rejected every price above 100; accept a decimal comma
- Do not report events as changed after a backend save: ignore the
  spelling of HTML entities in teaser and topics and the order of the
  speakers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:42:47 +02:00

165 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Mummert\ContaoWkSyncBundle\Tests\Import;
use Doctrine\DBAL\Connection;
use Mummert\ContaoWkSyncBundle\Category\Category;
use Mummert\ContaoWkSyncBundle\Category\CategoryKind;
use Mummert\ContaoWkSyncBundle\Category\CategoryRepository;
use Mummert\ContaoWkSyncBundle\Category\CategoryResolver;
use Mummert\ContaoWkSyncBundle\Import\CsvTable;
use Mummert\ContaoWkSyncBundle\Import\ImportPlanner;
use Mummert\ContaoWkSyncBundle\Import\MemberResolver;
use Mummert\ContaoWkSyncBundle\Import\PlannedEvent;
use Mummert\ContaoWkSyncBundle\Import\RowMapper;
use Mummert\ContaoWkSyncBundle\Sync\CalendarConfig;
use Mummert\ContaoWkSyncBundle\Sync\EventRowCodec;
use PHPUnit\Framework\TestCase;
class ImportPlannerTest extends TestCase
{
private string $sql = '';
public function testReimportWithoutMemberGroupsIsUnchanged(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
self::assertSame([], $plan->events[0]->changes);
}
public function testResolvedMembersAreComparedById(): void
{
$plan = $this->plan(existingInstructor: 11, existingSpeakers: [12], memberIds: ['Anna Muster' => 11, 'Bernd Beispiel' => 12], instructorGroup: 0, speakerGroup: 0);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testMemberGroupsPlanMemberCreation(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 4, speakerGroup: 5);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['wkInstructor', 'wkSpeakers'], array_keys($plan->events[0]->changes));
self::assertSame(['', 'Anna Muster'], $plan->events[0]->changes['wkInstructor']);
}
public function testChangedTitleIsAnUpdate(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0, existingOverrides: ['title' => 'Alter Titel']);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['Alter Titel', 'Testseminar'], $plan->events[0]->changes['title']);
}
public function testVirtualFieldsAreComparedFromTheJsonStorage(): void
{
$plan = $this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0, existingOverrides: ['wkSubTitle' => 'Alter Untertitel', 'wkPrice1' => '99.00']);
self::assertSame(PlannedEvent::UPDATE, $plan->events[0]->action);
self::assertSame(['wkSubTitle', 'wkPrice1'], array_keys($plan->events[0]->changes));
self::assertSame(['Alter Untertitel', 'Modul 1'], $plan->events[0]->changes['wkSubTitle']);
self::assertSame(['99.00', 216.0], $plan->events[0]->changes['wkPrice1']);
}
public function testEntitySpellingAfterABackendSaveIsNoChange(): void
{
$plan = $this->plan(
existingInstructor: 0,
existingSpeakers: [],
memberIds: [],
instructorGroup: 0,
speakerGroup: 0,
existingOverrides: ['teaser' => '<p>Er sagte &#34;Hallo&#34; &amp; ging</p>'],
rowOverrides: ['kbpmemo' => 'Er sagte "Hallo" & ging'],
);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testSpeakerOrderDoesNotMatter(): void
{
$plan = $this->plan(
existingInstructor: 11,
existingSpeakers: [13, 12],
memberIds: ['Anna Muster' => 11, 'Bernd Beispiel' => 12, 'Clara Chor' => 13],
instructorGroup: 0,
speakerGroup: 0,
rowOverrides: ['kbmref' => "Bernd Beispiel\nClara Chor"],
);
self::assertSame(PlannedEvent::UNCHANGED, $plan->events[0]->action);
}
public function testExistingRowsAreReadFromRealColumnsAndTheJsonStorage(): void
{
$this->plan(existingInstructor: 0, existingSpeakers: [], memberIds: [], instructorGroup: 0, speakerGroup: 0);
self::assertStringContainsString('`jsonData`', $this->sql);
self::assertStringContainsString('`wkState`', $this->sql);
self::assertStringNotContainsString('wkSubTitle', $this->sql);
self::assertStringNotContainsString('wkPrice1', $this->sql);
self::assertStringNotContainsString('wkOrganizer', $this->sql);
}
/**
* @param array<string, int> $memberIds Name => Mitglieds-ID (nur diese Namen sind Mitglieder)
* @param list<int> $existingSpeakers
* @param array<string, mixed> $existingOverrides
* @param array<string, string> $rowOverrides abweichende Werte der W&K-Zeile
*/
private function plan(int $existingInstructor, array $existingSpeakers, array $memberIds, int $instructorGroup, int $speakerGroup, array $existingOverrides = [], array $rowOverrides = []): \Mummert\ContaoWkSyncBundle\Import\ImportPlan
{
$row = [
'kbnr' => '0001234',
'kbvsnr' => 'ABC-1',
'kbtitel' => 'Testseminar',
'kbanreise' => '01.03.2027',
'kbabreise' => '02.03.2027',
'kbstatus1' => '2',
'kbsgebiet' => 'Grundschule',
'kbkln1' => 'Anna Muster',
'kbmref' => "\nBernd Beispiel",
'kbutitel' => 'Modul 1',
'kbkp1' => '216,00',
'kbktxp1' => 'Kursgebühr',
...$rowOverrides,
];
$mapped = (new RowMapper())->map($row, 2);
// Bestand wie in der Datenbank: echte Spalten plus JSON-Feld mit den virtuellen Feldern
$existing = (new EventRowCodec())->combine([
...$mapped->data,
...$existingOverrides,
'id' => 7,
'published' => 1,
'wkCategories' => serialize(['3']),
'wkCategoryPrimary' => 3,
'wkInstructor' => $existingInstructor,
'wkSpeakers' => serialize(array_map(strval(...), $existingSpeakers)),
]);
$connection = $this->createStub(Connection::class);
$connection->method('fetchAllAssociative')->willReturnCallback(function (string $sql) use ($existing): array {
$this->sql = $sql;
return [$existing];
});
$categories = $this->createStub(CategoryRepository::class);
$categories->method('findAll')->willReturn([new Category(3, 'Grundschule', 'grundschule', CategoryKind::School, ['Grundschule'], 16)]);
$members = $this->createStub(MemberResolver::class);
$members->method('findId')->willReturnCallback(static fn (string $name): int|null => $memberIds[$name] ?? null);
$members->method('namesByIds')->willReturnCallback(static fn (array $ids): array => array_filter(array_flip($memberIds), static fn (int $id): bool => \in_array($id, $ids, true)));
$planner = new ImportPlanner($connection, new RowMapper(), $categories, new CategoryResolver($categories), $members);
$calendar = new CalendarConfig(1, 1, 'Kalender', 'Einstellungen', 0, true, 2, 8, 9, 0, 1, 31, ['TPI Moritzburg'], 1, $instructorGroup, $speakerGroup, [], 0, '', 0, []);
return $planner->plan(new CsvTable(array_keys($row), [$row + ['__record' => '2']], 'UTF-8'), $calendar);
}
}