1
<?php
2
declare(strict_types=1);
3
4
use Machweis\Backend\SyncService;
5
use Machweis\Backend\MasterDataService;
6
use Machweis\Backend\EmployeeProfileService;
7
8
require dirname(__DIR__) . '/src/autoload.php';
9
10
$pdo = new PDO('sqlite::memory:');
11
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
12
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
13
@$pdo->sqliteCreateFunction('UTC_TIMESTAMP', static fn(): string => gmdate('Y-m-d H:i:s'));
14
$pdo->exec(<<<'SQL'
15
CREATE TABLE tenants (id TEXT PRIMARY KEY);
16
CREATE TABLE tenant_memberships (tenant_id TEXT NOT NULL, user_id TEXT NOT NULL, status TEXT NOT NULL, UNIQUE(tenant_id,user_id));
17
CREATE TABLE customers (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL, contact TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
18
CREATE TABLE sites (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, customer_id TEXT NOT NULL, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
19
CREATE TABLE jobs (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, customer_id TEXT NOT NULL, site_id TEXT NULL, number TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
20
CREATE TABLE employee_profiles (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT NULL, employee_number TEXT NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
21
CREATE TABLE job_assignments (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, job_id TEXT NOT NULL, employee_id TEXT NOT NULL, assigned_from TEXT NULL, assigned_until TEXT NULL, row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL, UNIQUE(job_id,employee_id));
22
CREATE TABLE sync_changes (change_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, operation TEXT NOT NULL, row_version INTEGER NOT NULL, data_json TEXT NOT NULL, changed_at TEXT NOT NULL);
23
CREATE INDEX sync_changes_tenant_cursor ON sync_changes(tenant_id,change_id);
24
CREATE TABLE sync_operations (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash TEXT NOT NULL, result_json TEXT NULL, http_status INTEGER NULL, created_at TEXT NOT NULL, completed_at TEXT NULL, UNIQUE(tenant_id,operation_id), UNIQUE(tenant_id,idempotency_key));
25
INSERT INTO tenants (id) VALUES ('tenant-a'), ('tenant-b');
26
SQL);
27
28
$sync = new SyncService($pdo);
29
$ids = [
30
'customer' => '10000000-0000-4000-8000-000000000001',
31
'site' => '10000000-0000-4000-8000-000000000002',
32
'job' => '10000000-0000-4000-8000-000000000003',
33
'employee' => '10000000-0000-4000-8000-000000000004',
34
'assignment' => '10000000-0000-4000-8000-000000000005',
35
'tenant_b_customer' => '20000000-0000-4000-8000-000000000001',
36
];
37
38
$operation = static fn(string $operationId, string $entity, string $action, string $entityId, int $baseRevision, array $payload = []): array => [
39
'operation_id' => $operationId,
40
'idempotency_key' => 'key-' . $operationId,
41
'entity' => $entity,
42
'action' => $action,
43
'entity_id' => $entityId,
44
'base_revision' => $baseRevision,
45
'payload' => $payload,
46
];
47
48
$createCustomer = $operation('op-customer-create', 'customers', 'create', $ids['customer'], 0, ['name' => 'Kunde A']);
49
$first = $sync->push('tenant-a', 'owner', [$createCustomer]);
50
assert($first['status'] === 200);
51
assert($first['results'][0]['status'] === 'applied');
52
assert($first['results'][0]['row_version'] === 1);
53
assert($first['results'][0]['replayed'] === false);
54
55
$repeat = $sync->push('tenant-a', 'owner', [$createCustomer]);
56
assert($repeat['status'] === 200);
57
assert($repeat['results'][0]['replayed'] === true);
58
assert((int) $pdo->query("SELECT COUNT(*) FROM customers WHERE tenant_id='tenant-a'")->fetchColumn() === 1);
59
assert((int) $pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn() === 1);
60
61
$reusedKey = $createCustomer;
62
$reusedKey['payload']['name'] = 'Manipulierter Inhalt';
63
$reused = $sync->push('tenant-a', 'owner', [$reusedKey]);
64
assert($reused['status'] === 422);
65
assert($reused['results'][0]['code'] === 'idempotency_key_reused');
66
67
$batch = [
68
$operation('op-site-create', 'sites', 'create', $ids['site'], 0, ['customer_id'=>$ids['customer'],'name'=>'Baustelle']),
69
$operation('op-job-create', 'jobs', 'create', $ids['job'], 0, ['customer_id'=>$ids['customer'],'site_id'=>$ids['site'],'number'=>'A-1','title'=>'Auftrag']),
70
$operation('op-employee-create', 'employee_profiles', 'create', $ids['employee'], 0, ['employee_number'=>'M-1','first_name'=>'Max','last_name'=>'Muster','is_active'=>true]),
71
$operation('op-assignment-create', 'job_assignments', 'create', $ids['assignment'], 0, ['job_id'=>$ids['job'],'employee_id'=>$ids['employee']]),
72
];
73
$created = $sync->push('tenant-a', 'owner', $batch);
74
assert($created['status'] === 200);
75
assert(array_column($created['results'], 'status') === ['applied','applied','applied','applied']);
76
77
$update = $operation('op-customer-update', 'customers', 'update', $ids['customer'], 1, ['name'=>'Kunde A aktualisiert']);
78
$updated = $sync->push('tenant-a', 'office', [$update]);
79
assert($updated['status'] === 200);
80
assert($updated['results'][0]['row_version'] === 2);
81
82
$stale = $operation('op-customer-stale', 'customers', 'update', $ids['customer'], 1, ['name'=>'Veraltet']);
83
$conflict = $sync->push('tenant-a', 'owner', [$stale]);
84
assert($conflict['status'] === 409);
85
assert($conflict['results'][0]['code'] === 'row_version_conflict');
86
assert($conflict['results'][0]['current_row_version'] === 2);
87
assert($sync->push('tenant-a', 'owner', [$stale])['results'][0]['replayed'] === true);
88
89
$malformed = $sync->push('tenant-a','owner',[['entity'=>'customers']]);
90
assert($malformed['status'] === 422);
91
assert($malformed['results'][0]['code'] === 'validation_failed');
92
93
$forbiddenEmployee = $operation('op-employee-supervisor', 'employee_profiles', 'update', $ids['employee'], 1, ['phone'=>'123']);
94
$forbidden = $sync->push('tenant-a', 'supervisor', [$forbiddenEmployee]);
95
assert($forbidden['status'] === 403);
96
assert($forbidden['results'][0]['code'] === 'forbidden');
97
98
$delete = $operation('op-assignment-delete', 'job_assignments', 'delete', $ids['assignment'], 1);
99
$deleted = $sync->push('tenant-a', 'supervisor', [$delete]);
100
assert($deleted['status'] === 200);
101
assert($deleted['results'][0]['row_version'] === 2);
102
103
$pullFirstPage = $sync->pull('tenant-a', 0, 2);
104
assert(count($pullFirstPage['changes']) === 2);
105
assert($pullFirstPage['has_more'] === true);
106
$pullNextPage = $sync->pull('tenant-a', $pullFirstPage['cursor'], 100);
107
assert(count($pullNextPage['changes']) === 5);
108
assert($pullNextPage['has_more'] === false);
109
assert($pullNextPage['changes'][array_key_last($pullNextPage['changes'])]['operation'] === 'delete');
110
assert($sync->pull('tenant-a', $pullNextPage['cursor'])['changes'] === []);
111
112
$tenantBOperation = $operation('op-customer-create', 'customers', 'create', $ids['tenant_b_customer'], 0, ['name'=>'Kunde B']);
113
$tenantB = $sync->push('tenant-b', 'owner', [$tenantBOperation]);
114
assert($tenantB['status'] === 200);
115
$tenantBPull = $sync->pull('tenant-b', 0);
116
assert(count($tenantBPull['changes']) === 1);
117
assert($tenantBPull['changes'][0]['data']['tenant_id'] === 'tenant-b');
118
foreach ($sync->pull('tenant-a', 0)['changes'] as $change) assert($change['data']['tenant_id'] === 'tenant-a');
119
$crossTenantUpdate=$operation('op-cross-tenant','customers','update',$ids['customer'],2,['name'=>'Fremdzugriff']);
120
$crossTenant=$sync->push('tenant-b','owner',[$crossTenantUpdate]);
121
assert($crossTenant['status']===409);
122
assert($crossTenant['results'][0]['current_row_version']===null);
123
assert($pdo->query("SELECT name FROM customers WHERE id='".$ids['customer']."'")->fetchColumn()==='Kunde A aktualisiert');
124
125
$beforeRestWrites=(int)$pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn();
126
(new MasterDataService($pdo))->createCustomer('tenant-a',['name'=>'REST-Kunde']);
127
(new EmployeeProfileService($pdo))->create('tenant-a',['employeeNumber'=>'REST-M-1','firstName'=>'Erika','lastName'=>'Muster','isActive'=>true]);
128
assert((int)$pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn()===$beforeRestWrites+2);
129
130
$migration=file_get_contents(dirname(__DIR__).'/migrations/005_master_data_sync.sql');
131
assert(is_string($migration));
132
assert(str_contains($migration,'UNIQUE KEY sync_operations_tenant_operation (tenant_id, operation_id)'));
133
assert(str_contains($migration,'UNIQUE KEY sync_operations_tenant_idempotency (tenant_id, idempotency_key)'));
134
assert(str_contains($migration,'INDEX sync_changes_tenant_cursor (tenant_id, change_id)'));
135
$serviceSource=file_get_contents(dirname(__DIR__).'/src/SyncService.php');
136
assert(is_string($serviceSource));
137
assert(!str_contains($serviceSource,'change_id AS cursor'));
138
assert(str_contains($serviceSource,"'cursor' => (int) \$row['change_id']"));
139
140
fwrite(STDOUT, "sync_contract: OK\n");
141