+19
-15
phpunit.xml
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}