Skip to content
Merged
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
153 changes: 153 additions & 0 deletions tests/Unit/Xion/DataModelBaseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace Nene\Tests\Unit\Xion;

use Nene\Xion\DataModelBase;
use PHPUnit\Framework\TestCase;

/**
* Pin the schema-driven behavior of `DataModelBase` so the magic
* `__get` / `__set` patterns (the five Phan baseline entries flagged
* by the eval report at #401 § 2) cannot regress silently (#407).
*
* `DataModelBase::__construct` reaches for `Log`, `ErrorCode`, and the
* route context — those are framework wiring concerns, not schema
* concerns. The fixture model below skips that wiring so the type-cast
* and schema-gate behavior can be tested in isolation.
*/
final class DataModelBaseTest extends TestCase
{
public function testSetCastsBooleanValues(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->is_completed = '1';
self::assertTrue($model->is_completed);
$model->is_completed = '';
self::assertFalse($model->is_completed);
}

public function testSetCastsIntegerValues(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->id = '42';
self::assertSame(42, $model->id);
$model->id = 7.9;
self::assertSame(7, $model->id);
}

public function testSetCastsDoubleValues(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->ratio = '0.75';
self::assertSame(0.75, $model->ratio);
}

public function testSetCastsStringValues(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->title = 123;
self::assertSame('123', $model->title);
}

public function testSetSkipsCastWhenIncomingTypeMatches(): void
{
// Native type already matches the schema; no cast applied.
$model = new DataModelBaseTestFixtureModel();
$model->title = 'native';
self::assertSame('native', $model->title);
}

public function testSetThrowsForUnknownProperty(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('SET unknown IS DISABLE');
$model = new DataModelBaseTestFixtureModel();
$model->unknown = 'value';
}

public function testGetReturnsNullWhenPropertyDeclaredButUnset(): void
{
$model = new DataModelBaseTestFixtureModel();
self::assertNull($model->title);
}

public function testGetThrowsForUnknownProperty(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('GET unknown IS DISABLE');
$model = new DataModelBaseTestFixtureModel();
$unused = $model->unknown;
}

public function testIssetMatchesSetState(): void
{
$model = new DataModelBaseTestFixtureModel();
self::assertFalse(isset($model->title));
$model->title = 'set';
self::assertTrue(isset($model->title));
}

public function testToArrayReturnsCurrentData(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->id = '7';
$model->title = 'hello';
$model->is_completed = '1';
self::assertSame(
['id' => 7, 'title' => 'hello', 'is_completed' => true],
$model->toArray()
);
}

public function testFromArrayPopulatesData(): void
{
$model = new DataModelBaseTestFixtureModel();
$model->fromArray([
'id' => '5',
'title' => 'imported',
'is_completed' => 1,
]);
self::assertSame(5, $model->id);
self::assertSame('imported', $model->title);
self::assertTrue($model->is_completed);
}

public function testGetSchemaReturnsConfiguredSchema(): void
{
$model = new DataModelBaseTestFixtureModel();
self::assertSame(DataModelBaseTestFixtureModel::expectedSchema(), $model->getSchema());
}
}

/**
* Schema-only fixture model. Bypasses `DataModelBase::__construct` so
* the test does not need a working `Log` / `ErrorCode` / `RouteContext`
* — those are wired by `Initialize::init()` in the real request boot.
*/
final class DataModelBaseTestFixtureModel extends DataModelBase
{
/** @var array<string,string> */
protected static array $schema = [
'id' => 'integer',
'title' => 'string',
'is_completed' => 'boolean',
'ratio' => 'double',
];

public function __construct()
{
// Skip the parent boot intentionally — the fixture only exercises
// schema-driven get/set behavior, not the framework's logging
// or error-code wiring.
}

/**
* @return array<string,string>
*/
public static function expectedSchema(): array
{
return self::$schema;
}
}
69 changes: 69 additions & 0 deletions tests/Unit/Xion/TransactionManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,75 @@ public function testRunDoesNotCommitOuterTransaction(): void
self::assertSame([], $this->pdo->committedWrites());
self::assertFalse($this->pdo->inTransaction());
}

public function testNestedRunCommitsOuterTransactionOnlyOnce(): void
{
// Outer run() opens a transaction; inner run() detects the
// existing transaction and only forwards the callback. Outer's
// commit is the single boundary.
$manager = new TransactionManager($this->pdo);

$result = $manager->run(function () use ($manager): string {
$this->pdo->recordWrite('outer-step');
$inner = $manager->run(function (): string {
$this->pdo->recordWrite('inner-step');
return 'inner-result';
});
return $inner . '+committed';
});

self::assertSame('inner-result+committed', $result);
self::assertSame(['outer-step', 'inner-step'], $this->pdo->committedWrites());
self::assertSame(1, $this->pdo->beginCount);
self::assertSame(1, $this->pdo->commitCount);
self::assertSame(0, $this->pdo->rollbackCount);
}

public function testNestedRunInnerExceptionRollsBackOuter(): void
{
// When the inner run() throws, the exception propagates up to
// the outer run() which is the one with the actual transaction
// boundary. The outer catch triggers rollback exactly once and
// re-throws. Inner does not double-handle the boundary.
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('inner failure');

$manager = new TransactionManager($this->pdo);
try {
$manager->run(function () use ($manager): void {
$this->pdo->recordWrite('outer-step');
$manager->run(function (): void {
$this->pdo->recordWrite('inner-step');
throw new RuntimeException('inner failure');
});
});
} finally {
self::assertSame([], $this->pdo->committedWrites());
self::assertSame(1, $this->pdo->beginCount);
self::assertSame(0, $this->pdo->commitCount);
self::assertSame(1, $this->pdo->rollbackCount);
self::assertFalse($this->pdo->inTransaction());
}
}

public function testRollbackSkipsWhenTransactionAlreadyEnded(): void
{
// If the callback itself commits or rolls back the PDO
// transaction (e.g. an inner library does so), the manager's
// catch must not double-rollback when the callback then throws.
$this->expectException(RuntimeException::class);

try {
(new TransactionManager($this->pdo))->run(function (): void {
$this->pdo->commit(); // ends transaction prematurely
throw new RuntimeException('post-commit failure');
});
} finally {
self::assertSame(1, $this->pdo->beginCount);
self::assertSame(1, $this->pdo->commitCount);
self::assertSame(0, $this->pdo->rollbackCount); // no double rollback
}
}
}

final class TransactionRecordingPdo extends PDO
Expand Down
Loading