Skip to content

Commit 8d64a89

Browse files
committed
Run site lifecycle as PHPUnit
1 parent dad121c commit 8d64a89

6 files changed

Lines changed: 356 additions & 120 deletions

File tree

.github/workflows/milliner-site-template.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
./scripts/ping.sh
8080
8181
- name: Test Drupal to Milliner to Fedora lifecycle
82-
run: ./crayfish/Milliner/tests/Integration/site-template.sh
82+
run: docker compose exec -T milliner composer integration:site-template
8383

8484
- name: Collect service logs
8585
if: failure()

Milliner/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@
7777
"@check",
7878
"vendor/bin/phpunit --exclude-group integration"
7979
],
80-
"integration": "vendor/bin/phpunit --group integration",
80+
"integration": "vendor/bin/phpunit --group integration --exclude-group site-template",
81+
"integration:site-template": "vendor/bin/phpunit --group site-template",
8182
"coverage": "vendor/bin/phpunit --exclude-group integration --coverage-clover clover.xml"
8283
},
8384
"conflict": {

Milliner/tests/Integration/Dockerfile.site-template

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ COPY --from=packaged --chown=nginx:nginx \
2424
RUN --mount=type=cache,id=milliner-pr-composer-${TARGETARCH},sharing=locked,target=/root/.composer/cache \
2525
composer install \
2626
--working-dir=/var/www/crayfish/Milliner \
27-
--no-dev \
2827
--no-interaction \
2928
--no-progress \
3029
--prefer-dist \
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
<?php
2+
3+
namespace App\Islandora\Milliner\Tests\Integration;
4+
5+
use GuzzleHttp\Client;
6+
use GuzzleHttp\Exception\GuzzleException;
7+
use Islandora\EntityMapper\EntityMapper;
8+
use JsonException;
9+
use PHPUnit\Framework\Attributes\CoversNothing;
10+
use PHPUnit\Framework\Attributes\Group;
11+
use PHPUnit\Framework\TestCase;
12+
use Psr\Http\Message\ResponseInterface;
13+
use Throwable;
14+
15+
#[CoversNothing]
16+
#[Group('integration')]
17+
#[Group('site-template')]
18+
final class SiteTemplateLifecycleTest extends TestCase
19+
{
20+
private const FEDORA_TITLE = 'http://purl.org/dc/terms/title';
21+
22+
private const WAIT_SECONDS = 300;
23+
24+
private Client $drupal;
25+
26+
private Client $fedora;
27+
28+
private ?int $nodeId = null;
29+
30+
private ?string $fedoraUrl = null;
31+
32+
protected function setUp(): void
33+
{
34+
parent::setUp();
35+
36+
if (getenv('MILLINER_SITE_TEMPLATE_TEST') === false) {
37+
$this->markTestSkipped('The site-template stack is required for this test.');
38+
}
39+
40+
$this->assertSame(
41+
'1',
42+
getenv('MILLINER_SITE_TEMPLATE_TEST'),
43+
'MILLINER_SITE_TEMPLATE_TEST must be set to 1.'
44+
);
45+
46+
$this->drupal = new Client([
47+
'auth' => ['admin', $this->readSecret('/run/secrets/DRUPAL_DEFAULT_ACCOUNT_PASSWORD')],
48+
'base_uri' => rtrim($this->requireEnvironment('MILLINER_SITE_DRUPAL_URL'), '/') . '/',
49+
'connect_timeout' => 10,
50+
'headers' => [
51+
'Accept' => 'application/json',
52+
'Host' => $this->requireEnvironment('MILLINER_SITE_DRUPAL_HOST'),
53+
],
54+
'http_errors' => false,
55+
'timeout' => 30,
56+
]);
57+
$this->fedora = new Client([
58+
'connect_timeout' => 10,
59+
'headers' => [
60+
'Accept' => 'application/ld+json',
61+
'Authorization' => 'Bearer ' . $this->readSecret('/run/secrets/JWT_ADMIN_TOKEN'),
62+
],
63+
'http_errors' => false,
64+
'timeout' => 30,
65+
]);
66+
}
67+
68+
public function testDrupalNodeLifecycleIsPersistedToFedora(): void
69+
{
70+
$suffix = bin2hex(random_bytes(8));
71+
$createdTitle = "Milliner integration create $suffix";
72+
$updatedTitle = "Milliner integration update $suffix";
73+
74+
try {
75+
$modelId = $this->collectionModelId();
76+
$created = $this->drupal->post('entity/node?_format=json', [
77+
'json' => [
78+
'type' => [['target_id' => 'islandora_object']],
79+
'title' => [['value' => $createdTitle]],
80+
'status' => [['value' => true]],
81+
'field_model' => [['target_id' => $modelId]],
82+
],
83+
]);
84+
$this->assertResponseStatus(201, $created, 'Drupal node creation');
85+
86+
$createdEntity = $this->decodeResponse($created, 'Drupal node creation');
87+
$this->nodeId = $this->positiveIntegerField($createdEntity, 'nid', 'value');
88+
$uuid = $this->stringField($createdEntity, 'uuid', 'value');
89+
$this->assertMatchesRegularExpression(
90+
'/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i',
91+
$uuid
92+
);
93+
$this->assertSame($createdTitle, $this->stringField($createdEntity, 'title', 'value'));
94+
$this->assertSame(
95+
$modelId,
96+
$this->positiveIntegerField($createdEntity, 'field_model', 'target_id')
97+
);
98+
99+
$fedoraBaseUrl = rtrim($this->requireEnvironment('MILLINER_SITE_FEDORA_URL'), '/');
100+
$this->fedoraUrl = $fedoraBaseUrl . '/' . (new EntityMapper())->getFedoraPath($uuid);
101+
$this->waitForFedoraTitle($createdTitle);
102+
103+
$createdChanged = $this->dateTimeField($createdEntity, 'changed', 'value');
104+
$clockDeadline = microtime(true) + 2;
105+
while (time() <= $createdChanged->getTimestamp() && microtime(true) < $clockDeadline) {
106+
usleep(100000);
107+
}
108+
$this->assertGreaterThan(
109+
$createdChanged->getTimestamp(),
110+
time(),
111+
'The clock did not advance beyond Drupal\'s creation timestamp.'
112+
);
113+
114+
$updated = $this->drupal->patch("node/{$this->nodeId}?_format=json", [
115+
'json' => [
116+
'type' => [['target_id' => 'islandora_object']],
117+
'title' => [['value' => $updatedTitle]],
118+
],
119+
]);
120+
$this->assertResponseStatus(200, $updated, 'Drupal node update');
121+
122+
$updatedEntity = $this->decodeResponse($updated, 'Drupal node update');
123+
$this->assertSame($uuid, $this->stringField($updatedEntity, 'uuid', 'value'));
124+
$this->assertSame($updatedTitle, $this->stringField($updatedEntity, 'title', 'value'));
125+
$this->waitForFedoraTitle($updatedTitle, [$createdTitle]);
126+
127+
$nodeId = $this->nodeId;
128+
$deleted = $this->drupal->delete("node/$nodeId?_format=json");
129+
$this->assertResponseStatus(204, $deleted, 'Drupal node deletion');
130+
$this->assertSame('', trim((string) $deleted->getBody()));
131+
$this->nodeId = null;
132+
133+
$missing = $this->drupal->get("node/$nodeId?_format=json");
134+
$this->assertResponseStatus(404, $missing, 'Drupal deleted-node lookup');
135+
$this->waitForFedoraAbsence();
136+
$this->fedoraUrl = null;
137+
} finally {
138+
$this->cleanUpCreatedResources();
139+
}
140+
}
141+
142+
private function collectionModelId(): int
143+
{
144+
$response = $this->drupal->get('term_from_term_name', [
145+
'query' => [
146+
'_format' => 'json',
147+
'name' => 'Collection',
148+
'vocab' => 'islandora_models',
149+
],
150+
]);
151+
$this->assertResponseStatus(200, $response, 'Collection model lookup');
152+
153+
$models = $this->decodeResponse($response, 'Collection model lookup');
154+
$this->assertCount(1, $models, 'Expected exactly one Collection model term.');
155+
$this->assertIsArray($models[0]);
156+
157+
return $this->positiveIntegerField($models[0], 'tid', 'value');
158+
}
159+
160+
private function waitForFedoraTitle(string $expected, array $unexpected = []): void
161+
{
162+
$deadline = microtime(true) + self::WAIT_SECONDS;
163+
$lastResult = 'No request was made.';
164+
165+
do {
166+
try {
167+
$response = $this->fedora->get($this->fedoraUrl);
168+
$lastResult = $this->describeResponse($response);
169+
if ($response->getStatusCode() === 200) {
170+
$document = $this->decodeResponse($response, 'Fedora resource lookup');
171+
$titles = $this->predicateValues($document, self::FEDORA_TITLE);
172+
if (in_array($expected, $titles, true)
173+
&& array_intersect($unexpected, $titles) === []
174+
) {
175+
return;
176+
}
177+
$lastResult .= "\nDecoded Fedora titles: " . json_encode($titles);
178+
}
179+
} catch (GuzzleException | JsonException $exception) {
180+
$lastResult = get_class($exception) . ': ' . $exception->getMessage();
181+
}
182+
183+
usleep(5000000);
184+
} while (microtime(true) < $deadline);
185+
186+
$this->fail(
187+
"Fedora did not contain the expected title '$expected' at {$this->fedoraUrl}.\n" .
188+
"Last result:\n$lastResult"
189+
);
190+
}
191+
192+
private function waitForFedoraAbsence(): void
193+
{
194+
$deadline = microtime(true) + self::WAIT_SECONDS;
195+
$lastResult = 'No request was made.';
196+
197+
do {
198+
try {
199+
$response = $this->fedora->get($this->fedoraUrl);
200+
$lastResult = $this->describeResponse($response);
201+
if (in_array($response->getStatusCode(), [404, 410], true)) {
202+
return;
203+
}
204+
} catch (GuzzleException $exception) {
205+
$lastResult = get_class($exception) . ': ' . $exception->getMessage();
206+
}
207+
208+
usleep(5000000);
209+
} while (microtime(true) < $deadline);
210+
211+
$this->fail(
212+
"Fedora resource still exists at {$this->fedoraUrl}.\nLast result:\n$lastResult"
213+
);
214+
}
215+
216+
private function predicateValues(array $document, string $predicate): array
217+
{
218+
$values = [];
219+
foreach ($document as $key => $value) {
220+
if ($key === $predicate && is_array($value)) {
221+
foreach ($value as $item) {
222+
if (is_array($item) && isset($item['@value']) && is_string($item['@value'])) {
223+
$values[] = $item['@value'];
224+
}
225+
}
226+
}
227+
if (is_array($value)) {
228+
$values = array_merge($values, $this->predicateValues($value, $predicate));
229+
}
230+
}
231+
232+
return array_values(array_unique($values));
233+
}
234+
235+
private function positiveIntegerField(array $entity, string $field, string $property): int
236+
{
237+
$value = $this->fieldValue($entity, $field, $property);
238+
$this->assertTrue(
239+
is_int($value) || (is_string($value) && ctype_digit($value)),
240+
"$field.$property must be an integer."
241+
);
242+
$value = (int) $value;
243+
$this->assertGreaterThan(0, $value, "$field.$property must be positive.");
244+
245+
return $value;
246+
}
247+
248+
private function stringField(array $entity, string $field, string $property): string
249+
{
250+
$value = $this->fieldValue($entity, $field, $property);
251+
$this->assertIsString($value, "$field.$property must be a string.");
252+
$this->assertNotSame('', $value, "$field.$property must not be empty.");
253+
254+
return $value;
255+
}
256+
257+
private function dateTimeField(
258+
array $entity,
259+
string $field,
260+
string $property
261+
): \DateTimeImmutable {
262+
$value = $this->stringField($entity, $field, $property);
263+
$dateTime = \DateTimeImmutable::createFromFormat(DATE_RFC3339, $value);
264+
$this->assertNotFalse($dateTime, "$field.$property must be an RFC3339 timestamp.");
265+
266+
return $dateTime;
267+
}
268+
269+
private function fieldValue(array $entity, string $field, string $property): mixed
270+
{
271+
$this->assertArrayHasKey($field, $entity);
272+
$this->assertIsArray($entity[$field]);
273+
$this->assertNotEmpty($entity[$field]);
274+
$this->assertIsArray($entity[$field][0]);
275+
$this->assertArrayHasKey($property, $entity[$field][0]);
276+
277+
return $entity[$field][0][$property];
278+
}
279+
280+
private function decodeResponse(ResponseInterface $response, string $operation): array
281+
{
282+
$body = (string) $response->getBody();
283+
try {
284+
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
285+
} catch (JsonException $exception) {
286+
$this->fail("$operation returned invalid JSON: {$exception->getMessage()}\n$body");
287+
}
288+
$this->assertIsArray($decoded, "$operation must return a JSON object or array.");
289+
290+
return $decoded;
291+
}
292+
293+
private function assertResponseStatus(
294+
int $expected,
295+
ResponseInterface $response,
296+
string $operation
297+
): void {
298+
$this->assertSame(
299+
$expected,
300+
$response->getStatusCode(),
301+
"$operation returned an unexpected response.\n" . $this->describeResponse($response)
302+
);
303+
}
304+
305+
private function describeResponse(ResponseInterface $response): string
306+
{
307+
return "HTTP {$response->getStatusCode()}\n" . (string) $response->getBody();
308+
}
309+
310+
private function readSecret(string $path): string
311+
{
312+
$contents = @file_get_contents($path);
313+
$this->assertNotFalse($contents, "Required secret is not readable: $path");
314+
$contents = trim($contents);
315+
$this->assertNotSame('', $contents, "Required secret is empty: $path");
316+
317+
return $contents;
318+
}
319+
320+
private function requireEnvironment(string $name): string
321+
{
322+
$value = getenv($name);
323+
$this->assertNotFalse($value, "Required environment variable is missing: $name");
324+
$this->assertNotSame('', $value, "Required environment variable is empty: $name");
325+
326+
return $value;
327+
}
328+
329+
private function cleanUpCreatedResources(): void
330+
{
331+
if ($this->nodeId !== null) {
332+
try {
333+
$this->drupal->delete("node/{$this->nodeId}?_format=json");
334+
} catch (Throwable) {
335+
// Preserve the original test failure.
336+
}
337+
}
338+
if ($this->fedoraUrl !== null) {
339+
try {
340+
$this->fedora->delete($this->fedoraUrl);
341+
} catch (Throwable) {
342+
// Preserve the original test failure.
343+
}
344+
}
345+
}
346+
}

Milliner/tests/Integration/site-template.override.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ services:
22
milliner:
33
image: local/milliner:ci
44
pull_policy: never
5+
environment:
6+
MILLINER_SITE_TEMPLATE_TEST: "1"
7+
MILLINER_SITE_DRUPAL_URL: http://drupal
8+
MILLINER_SITE_DRUPAL_HOST: ${DOMAIN}
9+
MILLINER_SITE_FEDORA_URL: http://fcrepo:8080/fcrepo/rest
10+
secrets:
11+
- source: DRUPAL_DEFAULT_ACCOUNT_PASSWORD
512
fcrepo:
613
# This is the forward-compatibility canary; the surrounding workflow is commit-pinned.
714
image: islandora/fcrepo6:main

0 commit comments

Comments
 (0)