Files
XC_VM/tests/Unit/EpgReservedSourceGuardTest.php
T
obscuremindandClaude Opus 4.8 874e5810eb fix(created): rebuild all items on target shift, harden reserved EPG source
Final whole-branch review fixes for the created-channel VOD-reuse + EPG work:

- CreatedCommand: builds are incremental, but the copy/transcode target is
  computed over the whole playlist. A playlist edit that shifted the dominant
  target rebuilt only the new items, leaving the old .ts at the previous
  target and mixing codecs/resolutions mid-stream. Track the target in a
  _.target sidecar and force a full rebuild when it changes.
- CreatedCommand: persist the freshly built timeline to cc_info before
  regenerating the EPG, so an incremental rebuild's guide is not one build
  behind (regenerate reads cc_info first, _.info only as a fallback).
- EpgService: refuse creating OR renaming any source to 'Created Channels'
  (case/space-insensitive) so the reserved source cannot be duplicated or
  shadowed; the edit/delete of the reserved id were already guarded.
- EpgService: hide the reserved source from getAll() by id (reservedSourceId),
  not by name, so all three gates use one consistent identity.
- ChannelService: write epg_id = NULL, not 0, when the reserved source is
  missing (0 never matches a real id; NULL reads as "no source").
- EpgCronJob: build the external XMLTV after the created-channel regen so it
  carries their programmes in the same run instead of one cron cycle late.
- CreatedChannelEpg: cap programme projection inside the loop; a very short
  loop would otherwise build millions of transient rows before the slice.
- Tests: EpgReservedSourceGuardTest covers the create/rename/edit refusals and
  an ordinary create; AuditDecisionEditKeepsOmittedFields now seeds the
  reserved EPG source and carries the derived epg_id/channel_id in its
  created-channel baseline, matching the save-time linkage this branch added
  (the test predates it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 18:39:01 +00:00

89 lines
3.7 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Config\ConstantsInitializer;
use XcVm\Domain\Epg\CreatedChannelEpg;
use XcVm\Domain\Epg\EpgService;
use XcVm\Infrastructure\Database\DatabaseFactory;
/**
* The reserved created-channel EPG source ('Created Channels') is identified by
* name. EpgService::process must not let the admin create a colliding source or
* rename another one onto it — including case/whitespace variants, since MySQL
* matches the name case- and trailing-space-insensitively.
*/
final class EpgReservedSourceGuardTest extends TestCase {
private TestDb $rDb;
protected function setUp(): void {
foreach (ConstantsInitializer::statuses() as $rName => $rValue) {
defined($rName) || define($rName, $rValue);
}
$this->rDb = new TestDb();
$this->rDb->exec('CREATE TABLE `epg` (`id` INT AUTO_INCREMENT PRIMARY KEY, `epg_name` VARCHAR(255) COLLATE utf8_unicode_ci, `epg_file` VARCHAR(300), `days_keep` INT)');
// id 1 = the reserved sentinel, id 2 = an ordinary source.
$this->rDb->query("INSERT INTO `epg` (`epg_name`, `epg_file`) VALUES ('Created Channels', ''), ('Normal', 'n.xml')");
DatabaseFactory::set($this->rDb);
(new ReflectionProperty(EpgService::class, 'db'))->setValue(null, null);
$GLOBALS['db'] = $this->rDb;
$GLOBALS['rUserInfo'] = ['id' => 1, 'member_group_id' => 1];
$GLOBALS['rPermissions'] = ['is_admin' => 1, 'advanced' => []];
}
protected function tearDown(): void {
(new ReflectionProperty(EpgService::class, 'db'))->setValue(null, null);
DatabaseFactory::reset();
unset($GLOBALS['db'], $GLOBALS['rUserInfo'], $GLOBALS['rPermissions']);
}
private function rowCount(): int {
$this->rDb->query('SELECT COUNT(*) `c` FROM `epg`;');
return (int) $this->rDb->get_row()['c'];
}
private function nameOf(int $rId): ?string {
$this->rDb->query('SELECT `epg_name` FROM `epg` WHERE `id` = ?;', $rId);
return $this->rDb->get_row()['epg_name'] ?? null;
}
public function testCreateWithReservedNameIsRefused(): void {
$rBefore = $this->rowCount();
$rResult = EpgService::process(['epg_name' => 'Created Channels', 'epg_file' => 'x.xml']);
$this->assertSame(STATUS_FAILURE, $rResult['status']);
$this->assertSame($rBefore, $this->rowCount());
}
public function testCreateWithReservedNameDifferentCaseIsRefused(): void {
$rBefore = $this->rowCount();
$rResult = EpgService::process(['epg_name' => 'created CHANNELS', 'epg_file' => 'x.xml']);
$this->assertSame(STATUS_FAILURE, $rResult['status']);
$this->assertSame($rBefore, $this->rowCount());
}
public function testCreateWithReservedNameTrailingSpaceIsRefused(): void {
$rBefore = $this->rowCount();
$rResult = EpgService::process(['epg_name' => ' Created Channels ', 'epg_file' => 'x.xml']);
$this->assertSame(STATUS_FAILURE, $rResult['status']);
$this->assertSame($rBefore, $this->rowCount());
}
public function testEditRenamingAnotherSourceToReservedNameIsRefused(): void {
$rResult = EpgService::process(['edit' => 2, 'epg_name' => 'Created Channels']);
$this->assertSame(STATUS_FAILURE, $rResult['status']);
$this->assertSame('Normal', $this->nameOf(2));
}
public function testEditingTheReservedRowIsRefused(): void {
$rResult = EpgService::process(['edit' => CreatedChannelEpg::reservedSourceId(), 'epg_name' => 'Hijacked']);
$this->assertSame(STATUS_FAILURE, $rResult['status']);
$this->assertSame('Created Channels', $this->nameOf(1));
}
public function testAnOrdinaryCreateStillSucceeds(): void {
$rBefore = $this->rowCount();
$rResult = EpgService::process(['epg_name' => 'My Guide', 'epg_file' => 'http://example/epg.xml']);
$this->assertSame(STATUS_SUCCESS, $rResult['status']);
$this->assertSame($rBefore + 1, $this->rowCount());
}
}