Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)

Add test suite

+19 -15
phpunit.xml
··· 1 1 <?xml version="1.0" encoding="UTF-8"?> 2 - <phpunit bootstrap="vendor/autoload.php" 3 - backupGlobals="false" 4 - backupStaticAttributes="false" 2 + <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" 4 + bootstrap="vendor/autoload.php" 5 5 colors="true" 6 - verbose="true" 7 - convertErrorsToExceptions="true" 8 - convertNoticesToExceptions="true" 9 - convertWarningsToExceptions="true" 10 - processIsolation="false" 11 - stopOnFailure="false"> 6 + cacheDirectory=".phpunit.cache"> 12 7 <testsuites> 13 - <testsuite name="Package"> 14 - <directory suffix=".php">./tests/</directory> 8 + <testsuite name="Parity Test Suite"> 9 + <directory suffix="Test.php">./tests/</directory> 10 + <exclude>./tests/Fixtures/</exclude> 15 11 </testsuite> 16 12 </testsuites> 17 - <filter> 18 - <whitelist> 13 + <source> 14 + <include> 19 15 <directory>src/</directory> 20 - </whitelist> 21 - </filter> 16 + </include> 17 + </source> 18 + <php> 19 + <env name="APP_ENV" value="testing"/> 20 + <env name="CACHE_DRIVER" value="array"/> 21 + <env name="SESSION_DRIVER" value="array"/> 22 + <env name="QUEUE_CONNECTION" value="sync"/> 23 + <env name="DB_CONNECTION" value="sqlite"/> 24 + <env name="DB_DATABASE" value=":memory:"/> 25 + </php> 22 26 </phpunit>
+14
tests/Fixtures/SyncableMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + /** 6 + * Mapper for SyncableModel (extends TestMapper with different model class). 7 + */ 8 + class SyncableMapper extends TestMapper 9 + { 10 + public function modelClass(): string 11 + { 12 + return SyncableModel::class; 13 + } 14 + }
+19
tests/Fixtures/SyncableModel.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 6 + 7 + /** 8 + * Test model with SyncsWithAtp trait for unit testing. 9 + * 10 + * Extends TestModel so it gets the same mapper from the registry. 11 + */ 12 + class SyncableModel extends TestModel 13 + { 14 + use SyncsWithAtp; 15 + 16 + protected $casts = [ 17 + 'atp_synced_at' => 'datetime', 18 + ]; 19 + }
+38
tests/Fixtures/TestMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\RecordMapper; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Test mapper for unit testing. 11 + */ 12 + class TestMapper extends RecordMapper 13 + { 14 + public function recordClass(): string 15 + { 16 + return TestRecord::class; 17 + } 18 + 19 + public function modelClass(): string 20 + { 21 + return TestModel::class; 22 + } 23 + 24 + protected function recordToAttributes(Data $record): array 25 + { 26 + return [ 27 + 'content' => $record->text, 28 + ]; 29 + } 30 + 31 + protected function modelToRecordData(Model $model): array 32 + { 33 + return [ 34 + 'text' => $model->content, 35 + 'createdAt' => $model->created_at?->toIso8601String(), 36 + ]; 37 + } 38 + }
+22
tests/Fixtures/TestModel.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Concerns\HasAtpRecord; 7 + 8 + /** 9 + * Test model for unit testing. 10 + */ 11 + class TestModel extends Model 12 + { 13 + use HasAtpRecord; 14 + 15 + protected $table = 'test_models'; 16 + 17 + protected $guarded = []; 18 + 19 + protected $casts = [ 20 + 'atp_synced_at' => 'datetime', 21 + ]; 22 + }
+37
tests/Fixtures/TestRecord.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use SocialDept\AtpSchema\Data\Data; 6 + 7 + /** 8 + * Test record for unit testing. 9 + */ 10 + class TestRecord extends Data 11 + { 12 + public function __construct( 13 + public readonly string $text, 14 + public readonly ?string $createdAt = null, 15 + ) {} 16 + 17 + public static function getLexicon(): string 18 + { 19 + return 'app.test.record'; 20 + } 21 + 22 + public static function fromArray(array $data): static 23 + { 24 + return new static( 25 + text: $data['text'] ?? '', 26 + createdAt: $data['createdAt'] ?? null, 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return array_filter([ 33 + 'text' => $this->text, 34 + 'createdAt' => $this->createdAt, 35 + ], fn ($v) => $v !== null); 36 + } 37 + }
+85
tests/TestCase.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests; 4 + 5 + use Illuminate\Database\Schema\Blueprint; 6 + use Illuminate\Support\Facades\Schema; 7 + use Orchestra\Testbench\TestCase as Orchestra; 8 + use SocialDept\AtpClient\AtpClientServiceProvider; 9 + use SocialDept\AtpParity\ParityServiceProvider; 10 + use SocialDept\AtpResolver\ResolverServiceProvider; 11 + use SocialDept\AtpSignals\SignalServiceProvider; 12 + 13 + abstract class TestCase extends Orchestra 14 + { 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->setUpDatabase(); 20 + } 21 + 22 + protected function getPackageProviders($app): array 23 + { 24 + return [ 25 + ResolverServiceProvider::class, 26 + AtpClientServiceProvider::class, 27 + SignalServiceProvider::class, 28 + ParityServiceProvider::class, 29 + ]; 30 + } 31 + 32 + protected function getEnvironmentSetUp($app): void 33 + { 34 + $app['config']->set('database.default', 'testing'); 35 + $app['config']->set('database.connections.testing', [ 36 + 'driver' => 'sqlite', 37 + 'database' => ':memory:', 38 + 'prefix' => '', 39 + ]); 40 + 41 + $app['config']->set('parity.columns.uri', 'atp_uri'); 42 + $app['config']->set('parity.columns.cid', 'atp_cid'); 43 + } 44 + 45 + protected function setUpDatabase(): void 46 + { 47 + Schema::create('test_models', function (Blueprint $table) { 48 + $table->id(); 49 + $table->string('content')->nullable(); 50 + $table->string('did')->nullable(); 51 + $table->string('atp_uri')->nullable()->unique(); 52 + $table->string('atp_cid')->nullable(); 53 + $table->timestamp('atp_synced_at')->nullable(); 54 + $table->timestamps(); 55 + }); 56 + 57 + Schema::create('parity_import_states', function (Blueprint $table) { 58 + $table->id(); 59 + $table->string('did'); 60 + $table->string('collection'); 61 + $table->string('status')->default('pending'); 62 + $table->integer('records_synced')->default(0); 63 + $table->integer('records_skipped')->default(0); 64 + $table->integer('records_failed')->default(0); 65 + $table->string('cursor')->nullable(); 66 + $table->text('error')->nullable(); 67 + $table->timestamp('started_at')->nullable(); 68 + $table->timestamp('completed_at')->nullable(); 69 + $table->timestamps(); 70 + 71 + $table->unique(['did', 'collection']); 72 + }); 73 + 74 + Schema::create('parity_conflicts', function (Blueprint $table) { 75 + $table->id(); 76 + $table->morphs('model'); 77 + $table->string('uri'); 78 + $table->string('remote_cid'); 79 + $table->json('remote_data'); 80 + $table->string('status')->default('pending'); 81 + $table->timestamp('resolved_at')->nullable(); 82 + $table->timestamps(); 83 + }); 84 + } 85 + }
+191
tests/Unit/Concerns/HasAtpRecordTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Concerns; 4 + 5 + use SocialDept\AtpParity\MapperRegistry; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 9 + use SocialDept\AtpParity\Tests\TestCase; 10 + 11 + class HasAtpRecordTest extends TestCase 12 + { 13 + public function test_get_atp_uri_returns_uri_from_column(): void 14 + { 15 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.test.record/abc123']); 16 + 17 + $this->assertSame('at://did:plc:test/app.test.record/abc123', $model->getAtpUri()); 18 + } 19 + 20 + public function test_get_atp_uri_returns_null_when_not_set(): void 21 + { 22 + $model = new TestModel(); 23 + 24 + $this->assertNull($model->getAtpUri()); 25 + } 26 + 27 + public function test_get_atp_cid_returns_cid_from_column(): void 28 + { 29 + $model = new TestModel(['atp_cid' => 'bafyreiabc123']); 30 + 31 + $this->assertSame('bafyreiabc123', $model->getAtpCid()); 32 + } 33 + 34 + public function test_get_atp_cid_returns_null_when_not_set(): void 35 + { 36 + $model = new TestModel(); 37 + 38 + $this->assertNull($model->getAtpCid()); 39 + } 40 + 41 + public function test_get_atp_did_extracts_did_from_uri(): void 42 + { 43 + $model = new TestModel(['atp_uri' => 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/abc123']); 44 + 45 + $this->assertSame('did:plc:z72i7hdynmk6r22z27h6tvur', $model->getAtpDid()); 46 + } 47 + 48 + public function test_get_atp_did_returns_null_when_no_uri(): void 49 + { 50 + $model = new TestModel(); 51 + 52 + $this->assertNull($model->getAtpDid()); 53 + } 54 + 55 + public function test_get_atp_did_returns_null_for_malformed_uri(): void 56 + { 57 + $model = new TestModel(['atp_uri' => 'invalid-uri']); 58 + 59 + $this->assertNull($model->getAtpDid()); 60 + } 61 + 62 + public function test_get_atp_collection_extracts_collection_from_uri(): void 63 + { 64 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/abc123']); 65 + 66 + $this->assertSame('app.bsky.feed.post', $model->getAtpCollection()); 67 + } 68 + 69 + public function test_get_atp_collection_returns_null_when_no_uri(): void 70 + { 71 + $model = new TestModel(); 72 + 73 + $this->assertNull($model->getAtpCollection()); 74 + } 75 + 76 + public function test_get_atp_rkey_extracts_rkey_from_uri(): void 77 + { 78 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/3kj2h4k5j']); 79 + 80 + $this->assertSame('3kj2h4k5j', $model->getAtpRkey()); 81 + } 82 + 83 + public function test_get_atp_rkey_returns_null_when_no_uri(): void 84 + { 85 + $model = new TestModel(); 86 + 87 + $this->assertNull($model->getAtpRkey()); 88 + } 89 + 90 + public function test_has_atp_record_returns_true_when_uri_set(): void 91 + { 92 + $model = new TestModel(['atp_uri' => 'at://did/col/rkey']); 93 + 94 + $this->assertTrue($model->hasAtpRecord()); 95 + } 96 + 97 + public function test_has_atp_record_returns_false_when_no_uri(): void 98 + { 99 + $model = new TestModel(); 100 + 101 + $this->assertFalse($model->hasAtpRecord()); 102 + } 103 + 104 + public function test_get_atp_mapper_returns_mapper_when_registered(): void 105 + { 106 + $registry = app(MapperRegistry::class); 107 + $registry->register(new TestMapper()); 108 + 109 + $model = new TestModel(); 110 + $mapper = $model->getAtpMapper(); 111 + 112 + $this->assertInstanceOf(TestMapper::class, $mapper); 113 + } 114 + 115 + public function test_get_atp_mapper_returns_null_when_not_registered(): void 116 + { 117 + // Fresh registry without any mappers 118 + $this->app->forgetInstance(MapperRegistry::class); 119 + $this->app->singleton(MapperRegistry::class); 120 + 121 + $model = new TestModel(); 122 + 123 + $this->assertNull($model->getAtpMapper()); 124 + } 125 + 126 + public function test_to_atp_record_converts_model_to_record(): void 127 + { 128 + $registry = app(MapperRegistry::class); 129 + $registry->register(new TestMapper()); 130 + 131 + $model = new TestModel(['content' => 'Hello world']); 132 + $record = $model->toAtpRecord(); 133 + 134 + $this->assertInstanceOf(TestRecord::class, $record); 135 + $this->assertSame('Hello world', $record->text); 136 + } 137 + 138 + public function test_to_atp_record_returns_null_when_no_mapper(): void 139 + { 140 + $this->app->forgetInstance(MapperRegistry::class); 141 + $this->app->singleton(MapperRegistry::class); 142 + 143 + $model = new TestModel(['content' => 'Hello']); 144 + 145 + $this->assertNull($model->toAtpRecord()); 146 + } 147 + 148 + public function test_scope_with_atp_record_filters_synced_models(): void 149 + { 150 + TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey1']); 151 + TestModel::create(['content' => 'Not synced']); 152 + TestModel::create(['content' => 'Also synced', 'atp_uri' => 'at://did/col/rkey2']); 153 + 154 + $synced = TestModel::withAtpRecord()->get(); 155 + 156 + $this->assertCount(2, $synced); 157 + $this->assertTrue($synced->every(fn ($m) => $m->atp_uri !== null)); 158 + } 159 + 160 + public function test_scope_without_atp_record_filters_unsynced_models(): void 161 + { 162 + TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey']); 163 + TestModel::create(['content' => 'Not synced 1']); 164 + TestModel::create(['content' => 'Not synced 2']); 165 + 166 + $unsynced = TestModel::withoutAtpRecord()->get(); 167 + 168 + $this->assertCount(2, $unsynced); 169 + $this->assertTrue($unsynced->every(fn ($m) => $m->atp_uri === null)); 170 + } 171 + 172 + public function test_scope_where_atp_uri_finds_by_uri(): void 173 + { 174 + TestModel::create(['content' => 'Target', 'atp_uri' => 'at://did/col/target']); 175 + TestModel::create(['content' => 'Other', 'atp_uri' => 'at://did/col/other']); 176 + 177 + $found = TestModel::whereAtpUri('at://did/col/target')->first(); 178 + 179 + $this->assertNotNull($found); 180 + $this->assertSame('Target', $found->content); 181 + } 182 + 183 + public function test_scope_where_atp_uri_returns_null_when_not_found(): void 184 + { 185 + TestModel::create(['content' => 'Some', 'atp_uri' => 'at://did/col/some']); 186 + 187 + $found = TestModel::whereAtpUri('at://did/col/nonexistent')->first(); 188 + 189 + $this->assertNull($found); 190 + } 191 + }
+128
tests/Unit/Concerns/SyncsWithAtpTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Concerns; 4 + 5 + use Carbon\Carbon; 6 + use SocialDept\AtpParity\MapperRegistry; 7 + use SocialDept\AtpParity\Tests\Fixtures\SyncableMapper; 8 + use SocialDept\AtpParity\Tests\Fixtures\SyncableModel; 9 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 10 + use SocialDept\AtpParity\Tests\TestCase; 11 + 12 + class SyncsWithAtpTest extends TestCase 13 + { 14 + public function test_get_atp_synced_at_column_returns_default(): void 15 + { 16 + $model = new SyncableModel(); 17 + 18 + $this->assertSame('atp_synced_at', $model->getAtpSyncedAtColumn()); 19 + } 20 + 21 + public function test_get_atp_synced_at_returns_timestamp(): void 22 + { 23 + $now = Carbon::now(); 24 + $model = new SyncableModel(['atp_synced_at' => $now]); 25 + 26 + $this->assertEquals($now->toDateTimeString(), $model->getAtpSyncedAt()->toDateTimeString()); 27 + } 28 + 29 + public function test_get_atp_synced_at_returns_null_when_not_set(): void 30 + { 31 + $model = new SyncableModel(); 32 + 33 + $this->assertNull($model->getAtpSyncedAt()); 34 + } 35 + 36 + public function test_mark_as_synced_sets_all_attributes(): void 37 + { 38 + Carbon::setTestNow('2024-01-15 12:00:00'); 39 + 40 + $model = new SyncableModel(); 41 + $model->markAsSynced('at://did/col/rkey', 'cid123'); 42 + 43 + $this->assertSame('at://did/col/rkey', $model->atp_uri); 44 + $this->assertSame('cid123', $model->atp_cid); 45 + $this->assertSame('2024-01-15 12:00:00', $model->atp_synced_at->toDateTimeString()); 46 + 47 + Carbon::setTestNow(); 48 + } 49 + 50 + public function test_has_local_changes_returns_true_when_never_synced(): void 51 + { 52 + $model = new SyncableModel(); 53 + 54 + $this->assertTrue($model->hasLocalChanges()); 55 + } 56 + 57 + public function test_has_local_changes_returns_false_when_no_updated_at(): void 58 + { 59 + $model = new SyncableModel([ 60 + 'atp_synced_at' => Carbon::now(), 61 + ]); 62 + 63 + $this->assertFalse($model->hasLocalChanges()); 64 + } 65 + 66 + public function test_has_local_changes_returns_true_when_updated_after_sync(): void 67 + { 68 + $model = new SyncableModel([ 69 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 70 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), 71 + ]); 72 + 73 + $this->assertTrue($model->hasLocalChanges()); 74 + } 75 + 76 + public function test_has_local_changes_returns_false_when_synced_after_update(): void 77 + { 78 + $model = new SyncableModel([ 79 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), 80 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 81 + ]); 82 + 83 + $this->assertFalse($model->hasLocalChanges()); 84 + } 85 + 86 + public function test_update_from_record_updates_model_and_sync_timestamp(): void 87 + { 88 + Carbon::setTestNow('2024-01-15 14:00:00'); 89 + 90 + $registry = app(MapperRegistry::class); 91 + $registry->register(new SyncableMapper()); 92 + 93 + $model = new SyncableModel(['content' => 'Original']); 94 + $record = new TestRecord(text: 'From remote'); 95 + 96 + $model->updateFromRecord($record, 'at://did/col/rkey', 'newcid'); 97 + 98 + $this->assertSame('From remote', $model->content); 99 + $this->assertSame('at://did/col/rkey', $model->atp_uri); 100 + $this->assertSame('newcid', $model->atp_cid); 101 + $this->assertSame('2024-01-15 14:00:00', $model->atp_synced_at->toDateTimeString()); 102 + 103 + Carbon::setTestNow(); 104 + } 105 + 106 + public function test_update_from_record_does_nothing_without_mapper(): void 107 + { 108 + $this->app->forgetInstance(MapperRegistry::class); 109 + $this->app->singleton(MapperRegistry::class); 110 + 111 + $model = new SyncableModel(['content' => 'Original']); 112 + $record = new TestRecord(text: 'From remote'); 113 + 114 + $model->updateFromRecord($record, 'at://did/col/rkey', 'cid'); 115 + 116 + $this->assertSame('Original', $model->content); 117 + } 118 + 119 + public function test_inherits_has_atp_record_methods(): void 120 + { 121 + $model = new SyncableModel(['atp_uri' => 'at://did:plc:test/app.test.record/rkey']); 122 + 123 + $this->assertTrue($model->hasAtpRecord()); 124 + $this->assertSame('did:plc:test', $model->getAtpDid()); 125 + $this->assertSame('app.test.record', $model->getAtpCollection()); 126 + $this->assertSame('rkey', $model->getAtpRkey()); 127 + } 128 + }
+139
tests/Unit/Import/ImportResultTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Import; 4 + 5 + use SocialDept\AtpParity\Import\ImportResult; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class ImportResultTest extends TestCase 9 + { 10 + public function test_success_creates_completed_result(): void 11 + { 12 + $result = ImportResult::success( 13 + did: 'did:plc:test123', 14 + collection: 'app.bsky.feed.post', 15 + synced: 50, 16 + skipped: 5, 17 + failed: 2 18 + ); 19 + 20 + $this->assertTrue($result->isSuccess()); 21 + $this->assertFalse($result->isPartial()); 22 + $this->assertFalse($result->isFailed()); 23 + $this->assertTrue($result->completed); 24 + $this->assertNull($result->error); 25 + $this->assertSame(50, $result->recordsSynced); 26 + $this->assertSame(5, $result->recordsSkipped); 27 + $this->assertSame(2, $result->recordsFailed); 28 + } 29 + 30 + public function test_partial_creates_incomplete_result(): void 31 + { 32 + $result = ImportResult::partial( 33 + did: 'did:plc:test123', 34 + collection: 'app.bsky.feed.post', 35 + synced: 100, 36 + cursor: 'abc123' 37 + ); 38 + 39 + $this->assertFalse($result->isSuccess()); 40 + $this->assertTrue($result->isPartial()); 41 + $this->assertFalse($result->isFailed()); 42 + $this->assertFalse($result->completed); 43 + $this->assertSame('abc123', $result->cursor); 44 + $this->assertNull($result->error); 45 + } 46 + 47 + public function test_failed_creates_error_result(): void 48 + { 49 + $result = ImportResult::failed( 50 + did: 'did:plc:test123', 51 + collection: 'app.bsky.feed.post', 52 + error: 'Connection failed' 53 + ); 54 + 55 + $this->assertFalse($result->isSuccess()); 56 + $this->assertFalse($result->isPartial()); // no records synced 57 + $this->assertTrue($result->isFailed()); 58 + $this->assertSame('Connection failed', $result->error); 59 + } 60 + 61 + public function test_failed_with_partial_progress(): void 62 + { 63 + $result = ImportResult::failed( 64 + did: 'did:plc:test123', 65 + collection: 'app.bsky.feed.post', 66 + error: 'Connection lost', 67 + synced: 50, 68 + cursor: 'xyz789' 69 + ); 70 + 71 + $this->assertTrue($result->isFailed()); 72 + $this->assertTrue($result->isPartial()); // has synced records 73 + $this->assertSame(50, $result->recordsSynced); 74 + $this->assertSame('xyz789', $result->cursor); 75 + } 76 + 77 + public function test_total_processed_sums_all_records(): void 78 + { 79 + $result = ImportResult::success( 80 + did: 'did:plc:test123', 81 + collection: 'app.bsky.feed.post', 82 + synced: 50, 83 + skipped: 10, 84 + failed: 5 85 + ); 86 + 87 + $this->assertSame(65, $result->totalProcessed()); 88 + } 89 + 90 + public function test_aggregate_combines_multiple_results(): void 91 + { 92 + $results = [ 93 + ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50), 94 + ImportResult::success('did:plc:test', 'app.bsky.feed.like', synced: 100, failed: 5), 95 + ]; 96 + 97 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 98 + 99 + $this->assertTrue($aggregate->isSuccess()); 100 + $this->assertSame('*', $aggregate->collection); 101 + $this->assertSame(150, $aggregate->recordsSynced); 102 + $this->assertSame(5, $aggregate->recordsFailed); 103 + $this->assertNull($aggregate->error); 104 + } 105 + 106 + public function test_aggregate_marks_incomplete_when_any_incomplete(): void 107 + { 108 + $results = [ 109 + ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50), 110 + ImportResult::partial('did:plc:test', 'app.bsky.feed.like', synced: 100, cursor: 'abc'), 111 + ]; 112 + 113 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 114 + 115 + $this->assertFalse($aggregate->completed); 116 + } 117 + 118 + public function test_aggregate_combines_errors(): void 119 + { 120 + $results = [ 121 + ImportResult::failed('did:plc:test', 'app.bsky.feed.post', error: 'Error 1'), 122 + ImportResult::failed('did:plc:test', 'app.bsky.feed.like', error: 'Error 2'), 123 + ]; 124 + 125 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 126 + 127 + $this->assertTrue($aggregate->isFailed()); 128 + $this->assertStringContainsString('app.bsky.feed.post: Error 1', $aggregate->error); 129 + $this->assertStringContainsString('app.bsky.feed.like: Error 2', $aggregate->error); 130 + } 131 + 132 + public function test_aggregate_with_empty_array(): void 133 + { 134 + $aggregate = ImportResult::aggregate('did:plc:test', []); 135 + 136 + $this->assertTrue($aggregate->completed); 137 + $this->assertSame(0, $aggregate->recordsSynced); 138 + } 139 + }
+169
tests/Unit/Import/ImportServiceTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Import; 4 + 5 + use Illuminate\Support\Facades\Event; 6 + use Mockery; 7 + use SocialDept\AtpParity\Events\ImportCompleted; 8 + use SocialDept\AtpParity\Events\ImportFailed; 9 + use SocialDept\AtpParity\Events\ImportProgress; 10 + use SocialDept\AtpParity\Events\ImportStarted; 11 + use SocialDept\AtpParity\Import\ImportService; 12 + use SocialDept\AtpParity\Import\ImportState; 13 + use SocialDept\AtpParity\MapperRegistry; 14 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 15 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 16 + use SocialDept\AtpParity\Tests\TestCase; 17 + use SocialDept\AtpResolver\Facades\Resolver; 18 + 19 + class ImportServiceTest extends TestCase 20 + { 21 + private ImportService $service; 22 + 23 + private MapperRegistry $registry; 24 + 25 + protected function setUp(): void 26 + { 27 + parent::setUp(); 28 + 29 + $this->registry = new MapperRegistry(); 30 + $this->registry->register(new TestMapper()); 31 + 32 + $this->service = new ImportService($this->registry); 33 + 34 + Event::fake(); 35 + } 36 + 37 + public function test_import_user_collection_returns_failed_when_no_mapper(): void 38 + { 39 + $this->app->forgetInstance(MapperRegistry::class); 40 + $emptyRegistry = new MapperRegistry(); 41 + $service = new ImportService($emptyRegistry); 42 + 43 + $result = $service->importUserCollection('did:plc:test', 'unknown.collection'); 44 + 45 + $this->assertTrue($result->isFailed()); 46 + $this->assertStringContainsString('No mapper registered', $result->error); 47 + } 48 + 49 + public function test_import_user_collection_fails_when_pds_not_resolved(): void 50 + { 51 + Resolver::shouldReceive('resolvePds') 52 + ->with('did:plc:test') 53 + ->andReturnNull(); 54 + 55 + $result = $this->service->importUserCollection('did:plc:test', 'app.test.record'); 56 + 57 + $this->assertTrue($result->isFailed()); 58 + $this->assertStringContainsString('Could not resolve PDS', $result->error); 59 + Event::assertDispatched(ImportFailed::class); 60 + } 61 + 62 + /** 63 + * @group integration 64 + */ 65 + public function test_import_user_collection_imports_records(): void 66 + { 67 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 68 + } 69 + 70 + /** 71 + * @group integration 72 + */ 73 + public function test_import_dispatches_events(): void 74 + { 75 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 76 + } 77 + 78 + /** 79 + * @group integration 80 + */ 81 + public function test_import_calls_progress_callback(): void 82 + { 83 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 84 + } 85 + 86 + /** 87 + * @group integration 88 + */ 89 + public function test_import_user_imports_multiple_collections(): void 90 + { 91 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 92 + } 93 + 94 + public function test_get_status_returns_import_state(): void 95 + { 96 + ImportState::create([ 97 + 'did' => 'did:plc:test', 98 + 'collection' => 'app.test.record', 99 + 'status' => 'completed', 100 + 'records_synced' => 50, 101 + ]); 102 + 103 + $state = $this->service->getStatus('did:plc:test', 'app.test.record'); 104 + 105 + $this->assertNotNull($state); 106 + $this->assertSame('completed', $state->status); 107 + $this->assertSame(50, $state->records_synced); 108 + } 109 + 110 + public function test_get_status_returns_null_when_not_found(): void 111 + { 112 + $state = $this->service->getStatus('did:plc:unknown', 'unknown'); 113 + 114 + $this->assertNull($state); 115 + } 116 + 117 + public function test_is_imported_returns_true_when_completed(): void 118 + { 119 + ImportState::create([ 120 + 'did' => 'did:plc:test', 121 + 'collection' => 'app.test.record', 122 + 'status' => 'completed', 123 + ]); 124 + 125 + $this->assertTrue($this->service->isImported('did:plc:test', 'app.test.record')); 126 + } 127 + 128 + public function test_is_imported_returns_false_when_not_started(): void 129 + { 130 + $this->assertFalse($this->service->isImported('did:plc:test', 'app.test.record')); 131 + } 132 + 133 + public function test_reset_deletes_import_state(): void 134 + { 135 + ImportState::create([ 136 + 'did' => 'did:plc:test', 137 + 'collection' => 'app.test.record', 138 + 'status' => 'completed', 139 + ]); 140 + 141 + $this->service->reset('did:plc:test', 'app.test.record'); 142 + 143 + $this->assertNull($this->service->getStatus('did:plc:test', 'app.test.record')); 144 + } 145 + 146 + public function test_import_skips_already_completed(): void 147 + { 148 + ImportState::create([ 149 + 'did' => 'did:plc:test', 150 + 'collection' => 'app.test.record', 151 + 'status' => 'completed', 152 + 'records_synced' => 100, 153 + ]); 154 + 155 + // No mocking needed - should return cached result 156 + $result = $this->service->importUserCollection('did:plc:test', 'app.test.record'); 157 + 158 + $this->assertTrue($result->completed); 159 + $this->assertSame(100, $result->recordsSynced); 160 + } 161 + 162 + /** 163 + * @group integration 164 + */ 165 + public function test_import_handles_record_failures_gracefully(): void 166 + { 167 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 168 + } 169 + }
+107
tests/Unit/MapperRegistryTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\MapperRegistry; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 9 + use SocialDept\AtpParity\Tests\TestCase; 10 + 11 + class MapperRegistryTest extends TestCase 12 + { 13 + private MapperRegistry $registry; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + $this->registry = new MapperRegistry(); 19 + } 20 + 21 + public function test_register_adds_mapper_to_all_indices(): void 22 + { 23 + $mapper = new TestMapper(); 24 + 25 + $this->registry->register($mapper); 26 + 27 + $this->assertSame($mapper, $this->registry->forRecord(TestRecord::class)); 28 + $this->assertSame($mapper, $this->registry->forModel(TestModel::class)); 29 + $this->assertSame($mapper, $this->registry->forLexicon('app.test.record')); 30 + } 31 + 32 + public function test_for_record_returns_null_for_unregistered_class(): void 33 + { 34 + $result = $this->registry->forRecord('NonExistent\\Record'); 35 + 36 + $this->assertNull($result); 37 + } 38 + 39 + public function test_for_model_returns_null_for_unregistered_class(): void 40 + { 41 + $result = $this->registry->forModel('NonExistent\\Model'); 42 + 43 + $this->assertNull($result); 44 + } 45 + 46 + public function test_for_lexicon_returns_null_for_unregistered_nsid(): void 47 + { 48 + $result = $this->registry->forLexicon('app.unknown.record'); 49 + 50 + $this->assertNull($result); 51 + } 52 + 53 + public function test_has_lexicon_returns_true_when_registered(): void 54 + { 55 + $this->registry->register(new TestMapper()); 56 + 57 + $this->assertTrue($this->registry->hasLexicon('app.test.record')); 58 + } 59 + 60 + public function test_has_lexicon_returns_false_when_not_registered(): void 61 + { 62 + $this->assertFalse($this->registry->hasLexicon('app.unknown.record')); 63 + } 64 + 65 + public function test_lexicons_returns_all_registered_nsids(): void 66 + { 67 + $this->registry->register(new TestMapper()); 68 + 69 + $lexicons = $this->registry->lexicons(); 70 + 71 + $this->assertContains('app.test.record', $lexicons); 72 + $this->assertCount(1, $lexicons); 73 + } 74 + 75 + public function test_lexicons_returns_empty_array_when_no_mappers_registered(): void 76 + { 77 + $this->assertEmpty($this->registry->lexicons()); 78 + } 79 + 80 + public function test_all_returns_all_registered_mappers(): void 81 + { 82 + $mapper = new TestMapper(); 83 + $this->registry->register($mapper); 84 + 85 + $all = $this->registry->all(); 86 + 87 + $this->assertCount(1, $all); 88 + $this->assertSame($mapper, $all[0]); 89 + } 90 + 91 + public function test_all_returns_empty_array_when_no_mappers_registered(): void 92 + { 93 + $this->assertEmpty($this->registry->all()); 94 + } 95 + 96 + public function test_registering_same_mapper_twice_overwrites(): void 97 + { 98 + $mapper1 = new TestMapper(); 99 + $mapper2 = new TestMapper(); 100 + 101 + $this->registry->register($mapper1); 102 + $this->registry->register($mapper2); 103 + 104 + $this->assertSame($mapper2, $this->registry->forRecord(TestRecord::class)); 105 + $this->assertCount(1, $this->registry->all()); 106 + } 107 + }
+69
tests/Unit/Publish/PublishResultTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Publish; 4 + 5 + use SocialDept\AtpParity\Publish\PublishResult; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class PublishResultTest extends TestCase 9 + { 10 + public function test_success_creates_successful_result(): void 11 + { 12 + $result = PublishResult::success( 13 + uri: 'at://did:plc:test/app.bsky.feed.post/abc123', 14 + cid: 'bafyreiabc123' 15 + ); 16 + 17 + $this->assertTrue($result->isSuccess()); 18 + $this->assertFalse($result->isFailed()); 19 + $this->assertSame('at://did:plc:test/app.bsky.feed.post/abc123', $result->uri); 20 + $this->assertSame('bafyreiabc123', $result->cid); 21 + $this->assertNull($result->error); 22 + } 23 + 24 + public function test_failed_creates_failed_result(): void 25 + { 26 + $result = PublishResult::failed('Authentication required'); 27 + 28 + $this->assertFalse($result->isSuccess()); 29 + $this->assertTrue($result->isFailed()); 30 + $this->assertNull($result->uri); 31 + $this->assertNull($result->cid); 32 + $this->assertSame('Authentication required', $result->error); 33 + } 34 + 35 + public function test_is_success_returns_correct_boolean(): void 36 + { 37 + $success = new PublishResult(success: true); 38 + $failure = new PublishResult(success: false); 39 + 40 + $this->assertTrue($success->isSuccess()); 41 + $this->assertFalse($failure->isSuccess()); 42 + } 43 + 44 + public function test_is_failed_returns_correct_boolean(): void 45 + { 46 + $success = new PublishResult(success: true); 47 + $failure = new PublishResult(success: false); 48 + 49 + $this->assertFalse($success->isFailed()); 50 + $this->assertTrue($failure->isFailed()); 51 + } 52 + 53 + public function test_success_result_properties_are_accessible(): void 54 + { 55 + $result = PublishResult::success('at://did/col/rkey', 'cid123'); 56 + 57 + $this->assertTrue($result->success); 58 + $this->assertSame('at://did/col/rkey', $result->uri); 59 + $this->assertSame('cid123', $result->cid); 60 + } 61 + 62 + public function test_failed_result_error_is_accessible(): void 63 + { 64 + $result = PublishResult::failed('Something went wrong'); 65 + 66 + $this->assertFalse($result->success); 67 + $this->assertSame('Something went wrong', $result->error); 68 + } 69 + }
+299
tests/Unit/Publish/PublishServiceTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Publish; 4 + 5 + use Illuminate\Support\Facades\Event; 6 + use Mockery; 7 + use SocialDept\AtpParity\Events\RecordPublished; 8 + use SocialDept\AtpParity\Events\RecordUnpublished; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + use SocialDept\AtpParity\Publish\PublishService; 11 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 12 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 13 + use SocialDept\AtpParity\Tests\TestCase; 14 + 15 + class PublishServiceTest extends TestCase 16 + { 17 + private PublishService $service; 18 + 19 + private MapperRegistry $registry; 20 + 21 + protected function setUp(): void 22 + { 23 + parent::setUp(); 24 + 25 + $this->registry = new MapperRegistry(); 26 + $this->registry->register(new TestMapper()); 27 + 28 + $this->service = new PublishService($this->registry); 29 + 30 + Event::fake(); 31 + } 32 + 33 + public function test_publish_fails_when_no_did_available(): void 34 + { 35 + $model = new TestModel(['content' => 'Test']); 36 + 37 + $result = $this->service->publish($model); 38 + 39 + $this->assertTrue($result->isFailed()); 40 + $this->assertStringContainsString('No DID', $result->error); 41 + } 42 + 43 + public function test_publish_uses_did_from_model_column(): void 44 + { 45 + $model = new TestModel([ 46 + 'content' => 'Test', 47 + 'did' => 'did:plc:test123', 48 + ]); 49 + 50 + $this->mockAtpClient('did:plc:test123', 'at://did:plc:test123/app.test.record/abc', 'cid123'); 51 + 52 + $result = $this->service->publish($model); 53 + 54 + $this->assertTrue($result->isSuccess()); 55 + $this->assertSame('at://did:plc:test123/app.test.record/abc', $result->uri); 56 + } 57 + 58 + public function test_publish_as_creates_record_with_specified_did(): void 59 + { 60 + $model = new TestModel(['content' => 'Hello world']); 61 + 62 + $this->mockAtpClient('did:plc:specified', 'at://did:plc:specified/app.test.record/xyz', 'newcid'); 63 + 64 + $result = $this->service->publishAs('did:plc:specified', $model); 65 + 66 + $this->assertTrue($result->isSuccess()); 67 + $this->assertSame('at://did:plc:specified/app.test.record/xyz', $result->uri); 68 + $this->assertSame('newcid', $result->cid); 69 + } 70 + 71 + public function test_publish_as_updates_model_metadata(): void 72 + { 73 + $model = TestModel::create(['content' => 'Test']); 74 + 75 + $this->mockAtpClient('did:plc:test', 'at://did:plc:test/app.test.record/rkey', 'cid'); 76 + 77 + $this->service->publishAs('did:plc:test', $model); 78 + 79 + $model->refresh(); 80 + $this->assertSame('at://did:plc:test/app.test.record/rkey', $model->atp_uri); 81 + $this->assertSame('cid', $model->atp_cid); 82 + } 83 + 84 + public function test_publish_dispatches_record_published_event(): void 85 + { 86 + $model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']); 87 + 88 + $this->mockAtpClient('did:plc:test', 'at://did/col/rkey', 'cid'); 89 + 90 + $this->service->publish($model); 91 + 92 + Event::assertDispatched(RecordPublished::class); 93 + } 94 + 95 + public function test_publish_redirects_to_update_when_already_published(): void 96 + { 97 + $model = TestModel::create([ 98 + 'content' => 'Existing', 99 + 'atp_uri' => 'at://did:plc:test/app.test.record/existing', 100 + 'atp_cid' => 'oldcid', 101 + ]); 102 + 103 + $this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/existing', 'newcid'); 104 + 105 + $result = $this->service->publishAs('did:plc:test', $model); 106 + 107 + $this->assertTrue($result->isSuccess()); 108 + } 109 + 110 + public function test_update_fails_when_not_published(): void 111 + { 112 + $model = new TestModel(['content' => 'Not published']); 113 + 114 + $result = $this->service->update($model); 115 + 116 + $this->assertTrue($result->isFailed()); 117 + $this->assertStringContainsString('not been published', $result->error); 118 + } 119 + 120 + public function test_update_calls_put_record(): void 121 + { 122 + $model = TestModel::create([ 123 + 'content' => 'Updated content', 124 + 'atp_uri' => 'at://did:plc:test/app.test.record/rkey123', 125 + 'atp_cid' => 'oldcid', 126 + ]); 127 + 128 + $this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/rkey123', 'updatedcid'); 129 + 130 + $result = $this->service->update($model); 131 + 132 + $this->assertTrue($result->isSuccess()); 133 + $this->assertSame('updatedcid', $result->cid); 134 + } 135 + 136 + public function test_delete_removes_record_and_clears_metadata(): void 137 + { 138 + $model = TestModel::create([ 139 + 'content' => 'To delete', 140 + 'atp_uri' => 'at://did:plc:test/app.test.record/todelete', 141 + 'atp_cid' => 'cid', 142 + ]); 143 + 144 + $this->mockAtpClientForDelete('did:plc:test'); 145 + 146 + $result = $this->service->delete($model); 147 + 148 + $this->assertTrue($result); 149 + 150 + $model->refresh(); 151 + $this->assertNull($model->atp_uri); 152 + $this->assertNull($model->atp_cid); 153 + } 154 + 155 + public function test_delete_dispatches_record_unpublished_event(): void 156 + { 157 + $model = TestModel::create([ 158 + 'content' => 'To delete', 159 + 'atp_uri' => 'at://did:plc:test/app.test.record/xyz', 160 + 'atp_cid' => 'cid', 161 + ]); 162 + 163 + $this->mockAtpClientForDelete('did:plc:test'); 164 + 165 + $this->service->delete($model); 166 + 167 + Event::assertDispatched(RecordUnpublished::class); 168 + } 169 + 170 + public function test_delete_returns_false_when_not_published(): void 171 + { 172 + $model = new TestModel(['content' => 'Not published']); 173 + 174 + $result = $this->service->delete($model); 175 + 176 + $this->assertFalse($result); 177 + } 178 + 179 + public function test_publish_handles_exception_gracefully(): void 180 + { 181 + $model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']); 182 + 183 + $this->mockAtpClientWithException('did:plc:test', 'API error occurred'); 184 + 185 + $result = $this->service->publish($model); 186 + 187 + $this->assertTrue($result->isFailed()); 188 + $this->assertSame('API error occurred', $result->error); 189 + } 190 + 191 + public function test_update_fails_for_invalid_uri(): void 192 + { 193 + $model = new TestModel([ 194 + 'content' => 'Test', 195 + 'atp_uri' => 'invalid-uri-format', 196 + ]); 197 + 198 + $result = $this->service->update($model); 199 + 200 + $this->assertTrue($result->isFailed()); 201 + $this->assertStringContainsString('Invalid AT Protocol URI', $result->error); 202 + } 203 + 204 + /** 205 + * Mock AtpClient for create operations. 206 + */ 207 + protected function mockAtpClient(string $did, string $returnUri, string $returnCid): void 208 + { 209 + $response = new \stdClass(); 210 + $response->uri = $returnUri; 211 + $response->cid = $returnCid; 212 + 213 + $repoClient = Mockery::mock(); 214 + $repoClient->shouldReceive('createRecord') 215 + ->andReturn($response); 216 + 217 + // Create client mock with property chain (no typed properties) 218 + $atprotoClient = Mockery::mock(); 219 + $atprotoClient->repo = $repoClient; 220 + 221 + $atpClient = Mockery::mock(); 222 + $atpClient->atproto = $atprotoClient; 223 + 224 + // Bind a manager mock to the container 225 + $manager = Mockery::mock(); 226 + $manager->shouldReceive('as') 227 + ->with($did) 228 + ->andReturn($atpClient); 229 + 230 + $this->app->instance('atp-client', $manager); 231 + } 232 + 233 + /** 234 + * Mock AtpClient for update operations. 235 + */ 236 + protected function mockAtpClientForUpdate(string $did, string $returnUri, string $returnCid): void 237 + { 238 + $response = new \stdClass(); 239 + $response->uri = $returnUri; 240 + $response->cid = $returnCid; 241 + 242 + $repoClient = Mockery::mock(); 243 + $repoClient->shouldReceive('putRecord') 244 + ->andReturn($response); 245 + 246 + // Create client mock with property chain (no typed properties) 247 + $atprotoClient = Mockery::mock(); 248 + $atprotoClient->repo = $repoClient; 249 + 250 + $atpClient = Mockery::mock(); 251 + $atpClient->atproto = $atprotoClient; 252 + 253 + // Bind a manager mock to the container 254 + $manager = Mockery::mock(); 255 + $manager->shouldReceive('as') 256 + ->with($did) 257 + ->andReturn($atpClient); 258 + 259 + $this->app->instance('atp-client', $manager); 260 + } 261 + 262 + /** 263 + * Mock AtpClient for delete operations. 264 + */ 265 + protected function mockAtpClientForDelete(string $did): void 266 + { 267 + $repoClient = Mockery::mock(); 268 + $repoClient->shouldReceive('deleteRecord') 269 + ->andReturnNull(); 270 + 271 + // Create client mock with property chain (no typed properties) 272 + $atprotoClient = Mockery::mock(); 273 + $atprotoClient->repo = $repoClient; 274 + 275 + $atpClient = Mockery::mock(); 276 + $atpClient->atproto = $atprotoClient; 277 + 278 + // Bind a manager mock to the container 279 + $manager = Mockery::mock(); 280 + $manager->shouldReceive('as') 281 + ->with($did) 282 + ->andReturn($atpClient); 283 + 284 + $this->app->instance('atp-client', $manager); 285 + } 286 + 287 + /** 288 + * Mock AtpClient to throw exception. 289 + */ 290 + protected function mockAtpClientWithException(string $did, string $message): void 291 + { 292 + $manager = Mockery::mock(); 293 + $manager->shouldReceive('as') 294 + ->with($did) 295 + ->andThrow(new \Exception($message)); 296 + 297 + $this->app->instance('atp-client', $manager); 298 + } 299 + }
+184
tests/Unit/RecordMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class RecordMapperTest extends TestCase 11 + { 12 + private TestMapper $mapper; 13 + 14 + protected function setUp(): void 15 + { 16 + parent::setUp(); 17 + $this->mapper = new TestMapper(); 18 + } 19 + 20 + public function test_record_class_returns_correct_class(): void 21 + { 22 + $this->assertSame(TestRecord::class, $this->mapper->recordClass()); 23 + } 24 + 25 + public function test_model_class_returns_correct_class(): void 26 + { 27 + $this->assertSame(TestModel::class, $this->mapper->modelClass()); 28 + } 29 + 30 + public function test_lexicon_returns_record_lexicon(): void 31 + { 32 + $this->assertSame('app.test.record', $this->mapper->lexicon()); 33 + } 34 + 35 + public function test_to_model_creates_model_with_attributes(): void 36 + { 37 + $record = new TestRecord(text: 'Hello world'); 38 + 39 + $model = $this->mapper->toModel($record); 40 + 41 + $this->assertInstanceOf(TestModel::class, $model); 42 + $this->assertSame('Hello world', $model->content); 43 + $this->assertFalse($model->exists); 44 + } 45 + 46 + public function test_to_model_applies_meta(): void 47 + { 48 + $record = new TestRecord(text: 'Test'); 49 + $meta = [ 50 + 'uri' => 'at://did:plc:test/app.test.record/abc123', 51 + 'cid' => 'bafyreiabc', 52 + ]; 53 + 54 + $model = $this->mapper->toModel($record, $meta); 55 + 56 + $this->assertSame('at://did:plc:test/app.test.record/abc123', $model->atp_uri); 57 + $this->assertSame('bafyreiabc', $model->atp_cid); 58 + } 59 + 60 + public function test_to_record_converts_model_to_record(): void 61 + { 62 + $model = new TestModel(['content' => 'Test content']); 63 + 64 + $record = $this->mapper->toRecord($model); 65 + 66 + $this->assertInstanceOf(TestRecord::class, $record); 67 + $this->assertSame('Test content', $record->text); 68 + } 69 + 70 + public function test_update_model_fills_model_without_saving(): void 71 + { 72 + $model = new TestModel(['content' => 'Original']); 73 + $model->save(); 74 + $originalUpdatedAt = $model->updated_at; 75 + 76 + $record = new TestRecord(text: 'Updated'); 77 + 78 + $result = $this->mapper->updateModel($model, $record); 79 + 80 + $this->assertSame($model, $result); 81 + $this->assertSame('Updated', $model->content); 82 + // Model is filled but not saved 83 + $this->assertTrue($model->isDirty('content')); 84 + } 85 + 86 + public function test_update_model_applies_meta(): void 87 + { 88 + $model = new TestModel(['content' => 'Original']); 89 + $record = new TestRecord(text: 'Updated'); 90 + $meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123']; 91 + 92 + $this->mapper->updateModel($model, $record, $meta); 93 + 94 + $this->assertSame('at://test/col/rkey', $model->atp_uri); 95 + $this->assertSame('cid123', $model->atp_cid); 96 + } 97 + 98 + public function test_find_by_uri_returns_model_when_exists(): void 99 + { 100 + $model = TestModel::create([ 101 + 'content' => 'Test', 102 + 'atp_uri' => 'at://did:plc:test/app.test.record/abc', 103 + ]); 104 + 105 + $found = $this->mapper->findByUri('at://did:plc:test/app.test.record/abc'); 106 + 107 + $this->assertNotNull($found); 108 + $this->assertSame($model->id, $found->id); 109 + } 110 + 111 + public function test_find_by_uri_returns_null_when_not_exists(): void 112 + { 113 + $found = $this->mapper->findByUri('at://nonexistent/col/rkey'); 114 + 115 + $this->assertNull($found); 116 + } 117 + 118 + public function test_upsert_creates_new_model_when_uri_not_found(): void 119 + { 120 + $record = new TestRecord(text: 'New record'); 121 + $meta = [ 122 + 'uri' => 'at://did:plc:test/app.test.record/new123', 123 + 'cid' => 'bafyrei123', 124 + ]; 125 + 126 + $model = $this->mapper->upsert($record, $meta); 127 + 128 + $this->assertTrue($model->exists); 129 + $this->assertSame('New record', $model->content); 130 + $this->assertSame('at://did:plc:test/app.test.record/new123', $model->atp_uri); 131 + } 132 + 133 + public function test_upsert_updates_existing_model_when_uri_found(): void 134 + { 135 + $existing = TestModel::create([ 136 + 'content' => 'Original', 137 + 'atp_uri' => 'at://did:plc:test/app.test.record/exists', 138 + 'atp_cid' => 'old_cid', 139 + ]); 140 + 141 + $record = new TestRecord(text: 'Updated content'); 142 + $meta = [ 143 + 'uri' => 'at://did:plc:test/app.test.record/exists', 144 + 'cid' => 'new_cid', 145 + ]; 146 + 147 + $model = $this->mapper->upsert($record, $meta); 148 + 149 + $this->assertSame($existing->id, $model->id); 150 + $this->assertSame('Updated content', $model->content); 151 + $this->assertSame('new_cid', $model->atp_cid); 152 + } 153 + 154 + public function test_upsert_without_uri_creates_new_model(): void 155 + { 156 + $record = new TestRecord(text: 'No URI'); 157 + 158 + $model = $this->mapper->upsert($record, []); 159 + 160 + $this->assertTrue($model->exists); 161 + $this->assertSame('No URI', $model->content); 162 + $this->assertNull($model->atp_uri); 163 + } 164 + 165 + public function test_delete_by_uri_deletes_model_when_exists(): void 166 + { 167 + TestModel::create([ 168 + 'content' => 'To delete', 169 + 'atp_uri' => 'at://did:plc:test/app.test.record/todelete', 170 + ]); 171 + 172 + $result = $this->mapper->deleteByUri('at://did:plc:test/app.test.record/todelete'); 173 + 174 + $this->assertTrue($result); 175 + $this->assertNull($this->mapper->findByUri('at://did:plc:test/app.test.record/todelete')); 176 + } 177 + 178 + public function test_delete_by_uri_returns_false_when_not_exists(): void 179 + { 180 + $result = $this->mapper->deleteByUri('at://nonexistent/col/rkey'); 181 + 182 + $this->assertFalse($result); 183 + } 184 + }
+137
tests/Unit/SchemaMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\Support\SchemaMapper; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class SchemaMapperTest extends TestCase 11 + { 12 + public function test_record_class_returns_schema_class(): void 13 + { 14 + $mapper = new SchemaMapper( 15 + schemaClass: TestRecord::class, 16 + modelClass: TestModel::class, 17 + toAttributes: fn () => [], 18 + toRecordData: fn () => [], 19 + ); 20 + 21 + $this->assertSame(TestRecord::class, $mapper->recordClass()); 22 + } 23 + 24 + public function test_model_class_returns_model_class(): void 25 + { 26 + $mapper = new SchemaMapper( 27 + schemaClass: TestRecord::class, 28 + modelClass: TestModel::class, 29 + toAttributes: fn () => [], 30 + toRecordData: fn () => [], 31 + ); 32 + 33 + $this->assertSame(TestModel::class, $mapper->modelClass()); 34 + } 35 + 36 + public function test_lexicon_returns_schema_lexicon(): void 37 + { 38 + $mapper = new SchemaMapper( 39 + schemaClass: TestRecord::class, 40 + modelClass: TestModel::class, 41 + toAttributes: fn () => [], 42 + toRecordData: fn () => [], 43 + ); 44 + 45 + $this->assertSame('app.test.record', $mapper->lexicon()); 46 + } 47 + 48 + public function test_to_model_invokes_to_attributes_closure(): void 49 + { 50 + $closureCalled = false; 51 + 52 + $mapper = new SchemaMapper( 53 + schemaClass: TestRecord::class, 54 + modelClass: TestModel::class, 55 + toAttributes: function (TestRecord $record) use (&$closureCalled) { 56 + $closureCalled = true; 57 + 58 + return ['content' => $record->text.'_transformed']; 59 + }, 60 + toRecordData: fn () => [], 61 + ); 62 + 63 + $record = new TestRecord(text: 'original'); 64 + $model = $mapper->toModel($record); 65 + 66 + $this->assertTrue($closureCalled); 67 + $this->assertSame('original_transformed', $model->content); 68 + } 69 + 70 + public function test_to_record_invokes_to_record_data_closure(): void 71 + { 72 + $closureCalled = false; 73 + 74 + $mapper = new SchemaMapper( 75 + schemaClass: TestRecord::class, 76 + modelClass: TestModel::class, 77 + toAttributes: fn () => [], 78 + toRecordData: function (TestModel $model) use (&$closureCalled) { 79 + $closureCalled = true; 80 + 81 + return ['text' => strtoupper($model->content)]; 82 + }, 83 + ); 84 + 85 + $model = new TestModel(['content' => 'hello']); 86 + $record = $mapper->toRecord($model); 87 + 88 + $this->assertTrue($closureCalled); 89 + $this->assertSame('HELLO', $record->text); 90 + } 91 + 92 + public function test_closures_receive_correct_types(): void 93 + { 94 + $receivedRecordType = null; 95 + $receivedModelType = null; 96 + 97 + $mapper = new SchemaMapper( 98 + schemaClass: TestRecord::class, 99 + modelClass: TestModel::class, 100 + toAttributes: function ($record) use (&$receivedRecordType) { 101 + $receivedRecordType = get_class($record); 102 + 103 + return ['content' => $record->text]; 104 + }, 105 + toRecordData: function ($model) use (&$receivedModelType) { 106 + $receivedModelType = get_class($model); 107 + 108 + return ['text' => $model->content]; 109 + }, 110 + ); 111 + 112 + $mapper->toModel(new TestRecord(text: 'test')); 113 + $mapper->toRecord(new TestModel(['content' => 'test'])); 114 + 115 + $this->assertSame(TestRecord::class, $receivedRecordType); 116 + $this->assertSame(TestModel::class, $receivedModelType); 117 + } 118 + 119 + public function test_upsert_works_with_schema_mapper(): void 120 + { 121 + $mapper = new SchemaMapper( 122 + schemaClass: TestRecord::class, 123 + modelClass: TestModel::class, 124 + toAttributes: fn (TestRecord $r) => ['content' => $r->text], 125 + toRecordData: fn (TestModel $m) => ['text' => $m->content], 126 + ); 127 + 128 + $record = new TestRecord(text: 'schema mapper test'); 129 + $meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123']; 130 + 131 + $model = $mapper->upsert($record, $meta); 132 + 133 + $this->assertTrue($model->exists); 134 + $this->assertSame('schema mapper test', $model->content); 135 + $this->assertSame('at://test/col/rkey', $model->atp_uri); 136 + } 137 + }
+118
tests/Unit/Sync/ConflictDetectorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use Carbon\Carbon; 6 + use SocialDept\AtpParity\Sync\ConflictDetector; 7 + use SocialDept\AtpParity\Tests\Fixtures\SyncableModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 9 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 10 + use SocialDept\AtpParity\Tests\TestCase; 11 + 12 + class ConflictDetectorTest extends TestCase 13 + { 14 + private ConflictDetector $detector; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + $this->detector = new ConflictDetector(); 20 + } 21 + 22 + public function test_no_conflict_when_cid_matches(): void 23 + { 24 + $model = new TestModel([ 25 + 'atp_cid' => 'samecid123', 26 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 27 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes 28 + ]); 29 + $record = new TestRecord(text: 'Remote content'); 30 + 31 + $hasConflict = $this->detector->hasConflict($model, $record, 'samecid123'); 32 + 33 + $this->assertFalse($hasConflict); 34 + } 35 + 36 + public function test_no_conflict_when_no_local_changes(): void 37 + { 38 + $model = new TestModel([ 39 + 'atp_cid' => 'oldcid', 40 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 41 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), // updated before sync 42 + ]); 43 + $record = new TestRecord(text: 'Remote content'); 44 + 45 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 46 + 47 + $this->assertFalse($hasConflict); 48 + } 49 + 50 + public function test_conflict_when_cid_differs_and_local_changes(): void 51 + { 52 + $model = new TestModel([ 53 + 'atp_cid' => 'oldcid', 54 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 55 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes 56 + ]); 57 + $record = new TestRecord(text: 'Remote content'); 58 + 59 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 60 + 61 + $this->assertTrue($hasConflict); 62 + } 63 + 64 + public function test_conflict_when_never_synced(): void 65 + { 66 + $model = new TestModel([ 67 + 'atp_cid' => 'cid', 68 + // No atp_synced_at means never synced, which implies local changes 69 + ]); 70 + $record = new TestRecord(text: 'Remote'); 71 + 72 + $hasConflict = $this->detector->hasConflict($model, $record, 'differentcid'); 73 + 74 + $this->assertTrue($hasConflict); 75 + } 76 + 77 + public function test_uses_syncs_with_atp_trait_method(): void 78 + { 79 + $model = new SyncableModel([ 80 + 'atp_cid' => 'oldcid', 81 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 82 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), 83 + ]); 84 + $record = new TestRecord(text: 'Remote'); 85 + 86 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 87 + 88 + $this->assertTrue($hasConflict); 89 + } 90 + 91 + public function test_no_conflict_when_synced_after_update_with_trait(): void 92 + { 93 + $model = new SyncableModel([ 94 + 'atp_cid' => 'oldcid', 95 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), 96 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 97 + ]); 98 + $record = new TestRecord(text: 'Remote'); 99 + 100 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 101 + 102 + $this->assertFalse($hasConflict); 103 + } 104 + 105 + public function test_no_conflict_without_updated_at(): void 106 + { 107 + $model = new TestModel([ 108 + 'atp_cid' => 'cid', 109 + 'atp_synced_at' => Carbon::now(), 110 + // No updated_at 111 + ]); 112 + $record = new TestRecord(text: 'Remote'); 113 + 114 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 115 + 116 + $this->assertFalse($hasConflict); 117 + } 118 + }
+66
tests/Unit/Sync/ConflictResolutionTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use SocialDept\AtpParity\Sync\ConflictResolution; 6 + use SocialDept\AtpParity\Sync\PendingConflict; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class ConflictResolutionTest extends TestCase 11 + { 12 + public function test_remote_wins_creates_resolved_resolution(): void 13 + { 14 + $model = new TestModel(); 15 + 16 + $resolution = ConflictResolution::remoteWins($model); 17 + 18 + $this->assertTrue($resolution->isResolved()); 19 + $this->assertFalse($resolution->isPending()); 20 + $this->assertSame('remote', $resolution->winner); 21 + $this->assertSame($model, $resolution->model); 22 + $this->assertNull($resolution->pending); 23 + } 24 + 25 + public function test_local_wins_creates_resolved_resolution(): void 26 + { 27 + $model = new TestModel(); 28 + 29 + $resolution = ConflictResolution::localWins($model); 30 + 31 + $this->assertTrue($resolution->isResolved()); 32 + $this->assertFalse($resolution->isPending()); 33 + $this->assertSame('local', $resolution->winner); 34 + $this->assertSame($model, $resolution->model); 35 + $this->assertNull($resolution->pending); 36 + } 37 + 38 + public function test_pending_creates_unresolved_resolution(): void 39 + { 40 + $pending = new PendingConflict(); 41 + 42 + $resolution = ConflictResolution::pending($pending); 43 + 44 + $this->assertFalse($resolution->isResolved()); 45 + $this->assertTrue($resolution->isPending()); 46 + $this->assertSame('manual', $resolution->winner); 47 + $this->assertNull($resolution->model); 48 + $this->assertSame($pending, $resolution->pending); 49 + } 50 + 51 + public function test_is_resolved_returns_correct_boolean(): void 52 + { 53 + $resolved = new ConflictResolution(resolved: true, winner: 'remote'); 54 + $unresolved = new ConflictResolution(resolved: false, winner: 'manual'); 55 + 56 + $this->assertTrue($resolved->isResolved()); 57 + $this->assertFalse($unresolved->isResolved()); 58 + } 59 + 60 + public function test_is_pending_returns_false_when_no_pending_conflict(): void 61 + { 62 + $resolution = new ConflictResolution(resolved: false, winner: 'manual'); 63 + 64 + $this->assertFalse($resolution->isPending()); 65 + } 66 + }
+83
tests/Unit/Sync/ConflictStrategyTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use SocialDept\AtpParity\Sync\ConflictStrategy; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class ConflictStrategyTest extends TestCase 9 + { 10 + public function test_remote_wins_has_correct_value(): void 11 + { 12 + $this->assertSame('remote', ConflictStrategy::RemoteWins->value); 13 + } 14 + 15 + public function test_local_wins_has_correct_value(): void 16 + { 17 + $this->assertSame('local', ConflictStrategy::LocalWins->value); 18 + } 19 + 20 + public function test_newest_wins_has_correct_value(): void 21 + { 22 + $this->assertSame('newest', ConflictStrategy::NewestWins->value); 23 + } 24 + 25 + public function test_manual_has_correct_value(): void 26 + { 27 + $this->assertSame('manual', ConflictStrategy::Manual->value); 28 + } 29 + 30 + public function test_from_config_returns_remote_wins_by_default(): void 31 + { 32 + config()->set('parity.conflicts.strategy', 'remote'); 33 + 34 + $strategy = ConflictStrategy::fromConfig(); 35 + 36 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 37 + } 38 + 39 + public function test_from_config_returns_local_wins(): void 40 + { 41 + config()->set('parity.conflicts.strategy', 'local'); 42 + 43 + $strategy = ConflictStrategy::fromConfig(); 44 + 45 + $this->assertSame(ConflictStrategy::LocalWins, $strategy); 46 + } 47 + 48 + public function test_from_config_returns_newest_wins(): void 49 + { 50 + config()->set('parity.conflicts.strategy', 'newest'); 51 + 52 + $strategy = ConflictStrategy::fromConfig(); 53 + 54 + $this->assertSame(ConflictStrategy::NewestWins, $strategy); 55 + } 56 + 57 + public function test_from_config_returns_manual(): void 58 + { 59 + config()->set('parity.conflicts.strategy', 'manual'); 60 + 61 + $strategy = ConflictStrategy::fromConfig(); 62 + 63 + $this->assertSame(ConflictStrategy::Manual, $strategy); 64 + } 65 + 66 + public function test_from_config_defaults_to_remote_wins_for_invalid_value(): void 67 + { 68 + config()->set('parity.conflicts.strategy', 'invalid'); 69 + 70 + $strategy = ConflictStrategy::fromConfig(); 71 + 72 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 73 + } 74 + 75 + public function test_from_config_defaults_to_remote_wins_when_not_set(): void 76 + { 77 + config()->set('parity.conflicts.strategy', null); 78 + 79 + $strategy = ConflictStrategy::fromConfig(); 80 + 81 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 82 + } 83 + }