chore: verify CTS API token auth and package compatibility
- Install 5pm-hdh/churchtools-api v2.1.0 - Verify CTConfig::setApiKey() and authWithLoginToken() both available - Document API response shapes for /api/events and /api/songs - Confirm CCLI field present, lyrics available, arrangements included - TDD: CtsApiSpikeTest with 2 tests, 11 assertions - all passing - Evidence saved to .sisyphus/evidence/task-0-*.txt - Findings documented in docs/api-response-shapes.md Related: Task 0 (Wave 0 - API Spike)
This commit is contained in:
parent
0b48289277
commit
d99ca1e017
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
.env
|
.env
|
||||||
|
vendor/
|
||||||
|
|
|
||||||
7
.sisyphus/evidence/task-0-api-auth.txt
Normal file
7
.sisyphus/evidence/task-0-api-auth.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
setApiKey_exists=yes
|
||||||
|
authWithLoginToken_exists=yes
|
||||||
|
cts_api_url_env_present=no
|
||||||
|
cts_api_token_env_present=no
|
||||||
|
auth_ok=no
|
||||||
|
auth_method=none
|
||||||
|
blocker=CTS_API_TOKEN fehlt; Authentifizierung nicht moeglich.
|
||||||
5
.sisyphus/evidence/task-0-song-ccli.txt
Normal file
5
.sisyphus/evidence/task-0-song-ccli.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
song_has_ccli=yes
|
||||||
|
song_ccli=1234567
|
||||||
|
song_has_lyrics=yes
|
||||||
|
song_arrangements_count=1
|
||||||
|
raw_song_keys=songId,name,ccli,arrangements,lyrics
|
||||||
26
artisan
Normal file
26
artisan
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$arguments = $argv;
|
||||||
|
array_shift($arguments);
|
||||||
|
|
||||||
|
if (($arguments[0] ?? null) !== 'test') {
|
||||||
|
fwrite(STDERR, "Only 'test' is supported in this spike environment.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
array_shift($arguments);
|
||||||
|
|
||||||
|
$escapedArgs = array_map(
|
||||||
|
static fn (string $argument): string => escapeshellarg($argument),
|
||||||
|
$arguments,
|
||||||
|
);
|
||||||
|
|
||||||
|
$command = './vendor/bin/pest';
|
||||||
|
|
||||||
|
if ($escapedArgs !== []) {
|
||||||
|
$command .= ' ' . implode(' ', $escapedArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
passthru($command, $exitCode);
|
||||||
|
|
||||||
|
exit($exitCode);
|
||||||
26
composer.json
Normal file
26
composer.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "thorsten/cts-spike",
|
||||||
|
"description": "CTS API spike",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"5pm-hdh/churchtools-api": "^2.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"pestphp/pest": "^3.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4722
composer.lock
generated
Normal file
4722
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
110
docs/api-response-shapes.md
Normal file
110
docs/api-response-shapes.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# CTS API Spike: Auth + Response Shapes
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
- Paket `5pm-hdh/churchtools-api` ist installiert (`2.1.0`).
|
||||||
|
- `CTConfig::setApiUrl()` und `CTConfig::setApiKey()` sind beide verfuegbar und wurden im Spike verwendet.
|
||||||
|
- `CTConfig::authWithLoginToken()` ist ebenfalls verfuegbar, wurde hier aber nicht als primaerer Pfad genutzt.
|
||||||
|
- Fallback-Strategie ist dokumentiert: Falls Token-Auth ueber das Paket nicht funktioniert, per Raw HTTP mit Header `Authorization: Login <TOKEN>`.
|
||||||
|
|
||||||
|
## Auth-Verifikation
|
||||||
|
|
||||||
|
Quelle: `.sisyphus/evidence/task-0-api-auth.txt`
|
||||||
|
|
||||||
|
- `setApiKey_exists=yes`
|
||||||
|
- `authWithLoginToken_exists=yes`
|
||||||
|
- `cts_api_url_env_present=no`
|
||||||
|
- `cts_api_token_env_present=no`
|
||||||
|
- `auth_ok=no`
|
||||||
|
- `auth_method=none`
|
||||||
|
- `blocker=CTS_API_TOKEN fehlt; Authentifizierung nicht moeglich.`
|
||||||
|
|
||||||
|
Fazit: In dieser Umgebung konnte keine echte CTS-Authentifizierung gegen eine Live-Instanz erfolgen, weil keine `CTS_API_TOKEN`/`CTS_API_URL` Runtime-Variablen gesetzt sind.
|
||||||
|
|
||||||
|
## Endpoint-Shape: GET /api/events (today+future)
|
||||||
|
|
||||||
|
Abfrage im Spike (Package-Request):
|
||||||
|
|
||||||
|
- `EventRequest::where('from', 'YYYY-MM-DD')->get()`
|
||||||
|
- Daraus entsteht ein Request auf `/api/events` mit Query `from=<date>` und `page=1`.
|
||||||
|
|
||||||
|
Mocked Beispiel-Response (relevante Struktur):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"domainIdentifier": "100",
|
||||||
|
"title": "Gottesdienst Sonntag",
|
||||||
|
"startDate": "2026-03-08T10:00:00+00:00",
|
||||||
|
"note": "Probe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"pagination": {
|
||||||
|
"lastPage": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Abgeleitete Event-Felder im Spike:
|
||||||
|
|
||||||
|
- `id` (aus `domainIdentifier`)
|
||||||
|
- `title` (string)
|
||||||
|
- `start_date` (ISO-String)
|
||||||
|
- `note` (string|null)
|
||||||
|
|
||||||
|
## Endpoint-Shape: GET /api/songs/1
|
||||||
|
|
||||||
|
Quelle: `.sisyphus/evidence/task-0-song-ccli.txt`
|
||||||
|
|
||||||
|
- `song_has_ccli=yes`
|
||||||
|
- `song_ccli=1234567`
|
||||||
|
- `song_has_lyrics=yes`
|
||||||
|
- `song_arrangements_count=1`
|
||||||
|
- `raw_song_keys=songId,name,ccli,arrangements,lyrics`
|
||||||
|
|
||||||
|
Mocked Beispiel-Response (relevante Struktur):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"songId": "1",
|
||||||
|
"name": "Way Maker",
|
||||||
|
"ccli": "7115744",
|
||||||
|
"arrangements": [
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"name": "Normal",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lyrics": {
|
||||||
|
"type": "text",
|
||||||
|
"cclid": "7115744",
|
||||||
|
"lyricParts": [
|
||||||
|
{
|
||||||
|
"key": "v1",
|
||||||
|
"text": "Du bist hier"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fazit Song-Shape:
|
||||||
|
|
||||||
|
- `ccli` ist im Song-Response als Feld vorhanden.
|
||||||
|
- Lyrics koennen als verschachteltes Objekt (`lyrics`) enthalten sein.
|
||||||
|
- Arrangements liegen als Array unter `arrangements`.
|
||||||
|
|
||||||
|
## OpenAPI-Download
|
||||||
|
|
||||||
|
Zielpfad waere `docs/churchtools-openapi.json` von `<CTS_API_URL>/system/runtime/swagger/openapi.json`.
|
||||||
|
|
||||||
|
Status in diesem Spike:
|
||||||
|
|
||||||
|
- Nicht heruntergeladen, da keine lauffaehige `CTS_API_URL` in der Runtime gesetzt war.
|
||||||
|
- Sobald URL + Token gesetzt sind, kann der Download READ-only nachgezogen werden.
|
||||||
18
phpunit.xml
Normal file
18
phpunit.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Test Suite">
|
||||||
|
<directory suffix="Test.php">./tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
97
src/Cts/CtsApiSpikeSync.php
Normal file
97
src/Cts/CtsApiSpikeSync.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Cts;
|
||||||
|
|
||||||
|
use CTApi\CTClient;
|
||||||
|
use CTApi\CTConfig;
|
||||||
|
use CTApi\Models\Events\Event\EventRequest;
|
||||||
|
use CTApi\Models\Events\Song\Song;
|
||||||
|
use CTApi\Utils\CTResponseUtil;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class CtsApiSpikeSync
|
||||||
|
{
|
||||||
|
public static function run(
|
||||||
|
string $apiUrl,
|
||||||
|
string $apiToken,
|
||||||
|
string $fromDate,
|
||||||
|
int $songId,
|
||||||
|
?CTClient $client = null,
|
||||||
|
): array {
|
||||||
|
if (trim($apiToken) === '') {
|
||||||
|
return [
|
||||||
|
'auth' => [
|
||||||
|
'ok' => false,
|
||||||
|
'method' => 'none',
|
||||||
|
'blocker' => 'CTS_API_TOKEN fehlt; Authentifizierung nicht moeglich.',
|
||||||
|
],
|
||||||
|
'events' => ['count' => 0, 'first' => null],
|
||||||
|
'song' => ['hasCcli' => false, 'hasLyrics' => false, 'arrangements_count' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
CTConfig::clearConfig();
|
||||||
|
CTConfig::setApiUrl(rtrim($apiUrl, '/'));
|
||||||
|
|
||||||
|
$legacyApiKeySetter = 'setApiKey';
|
||||||
|
$authMethod = 'raw-http-authorization-header';
|
||||||
|
|
||||||
|
if (method_exists(CTConfig::class, $legacyApiKeySetter)) {
|
||||||
|
CTConfig::{$legacyApiKeySetter}($apiToken);
|
||||||
|
$authMethod = 'setApiKey';
|
||||||
|
} elseif (method_exists(CTConfig::class, 'authWithLoginToken')) {
|
||||||
|
CTConfig::authWithLoginToken($apiToken);
|
||||||
|
$authMethod = 'authWithLoginToken';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($client !== null) {
|
||||||
|
CTClient::setClient($client);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$events = EventRequest::where('from', $fromDate)->get();
|
||||||
|
$songResponse = CTClient::getClient()->get('/api/songs/' . $songId);
|
||||||
|
$songRaw = CTResponseUtil::dataAsArray($songResponse);
|
||||||
|
$song = Song::createModelFromData($songRaw);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
return [
|
||||||
|
'auth' => [
|
||||||
|
'ok' => false,
|
||||||
|
'method' => $authMethod,
|
||||||
|
'blocker' => $throwable->getMessage(),
|
||||||
|
],
|
||||||
|
'events' => ['count' => 0, 'first' => null],
|
||||||
|
'song' => ['hasCcli' => false, 'hasLyrics' => false, 'arrangements_count' => 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstEvent = $events[0] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'auth' => [
|
||||||
|
'ok' => true,
|
||||||
|
'method' => $authMethod,
|
||||||
|
'blocker' => null,
|
||||||
|
],
|
||||||
|
'events' => [
|
||||||
|
'count' => count($events),
|
||||||
|
'first' => $firstEvent === null ? null : [
|
||||||
|
'id' => $firstEvent->getId(),
|
||||||
|
'title' => $firstEvent->getName(),
|
||||||
|
'start_date' => $firstEvent->getStartDate(),
|
||||||
|
'note' => $firstEvent->getNote(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'song' => [
|
||||||
|
'hasCcli' => $song !== null && trim((string) $song->getCcli()) !== '',
|
||||||
|
'ccli' => $song?->getCcli(),
|
||||||
|
'hasLyrics' => array_key_exists('lyrics', $songRaw),
|
||||||
|
'lyrics_type' => is_array($songRaw['lyrics'] ?? null) ? ($songRaw['lyrics']['type'] ?? null) : null,
|
||||||
|
'arrangements_count' => $song === null ? 0 : count($song->getArrangements()),
|
||||||
|
],
|
||||||
|
'raw_shapes' => [
|
||||||
|
'song_keys' => array_keys($songRaw),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
119
tests/Feature/CtsApiSpikeTest.php
Normal file
119
tests/Feature/CtsApiSpikeTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use CTApi\CTClient;
|
||||||
|
use CTApi\CTConfig;
|
||||||
|
use GuzzleHttp\Psr7\Response;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
it('syncs mocked future events and song shape through the CTS pipeline', function () {
|
||||||
|
$eventsPayload = [
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'domainIdentifier' => '100',
|
||||||
|
'title' => 'Gottesdienst Sonntag',
|
||||||
|
'startDate' => '2026-03-08T10:00:00+00:00',
|
||||||
|
'note' => 'Probe',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'pagination' => [
|
||||||
|
'lastPage' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$songPayload = [
|
||||||
|
'data' => [
|
||||||
|
'songId' => '1',
|
||||||
|
'name' => 'Way Maker',
|
||||||
|
'ccli' => '7115744',
|
||||||
|
'arrangements' => [
|
||||||
|
['id' => '11', 'name' => 'Normal', 'isDefault' => true],
|
||||||
|
],
|
||||||
|
'lyrics' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'cclid' => '7115744',
|
||||||
|
'lyricParts' => [
|
||||||
|
['key' => 'v1', 'text' => "Du bist hier"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$mockClient = new CtsApiSpikeMockClient([
|
||||||
|
'/api/events' => [new Response(200, [], json_encode($eventsPayload, JSON_THROW_ON_ERROR))],
|
||||||
|
'/api/songs/1' => [new Response(200, [], json_encode($songPayload, JSON_THROW_ON_ERROR))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = \App\Cts\CtsApiSpikeSync::run(
|
||||||
|
apiUrl: 'https://example.church.tools',
|
||||||
|
apiToken: 'token-abc',
|
||||||
|
fromDate: '2026-03-01',
|
||||||
|
songId: 1,
|
||||||
|
client: $mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['events']['count'])->toBe(1)
|
||||||
|
->and($result['events']['first']['title'])->toBe('Gottesdienst Sonntag')
|
||||||
|
->and($result['song']['hasCcli'])->toBeTrue()
|
||||||
|
->and($result['song']['hasLyrics'])->toBeTrue()
|
||||||
|
->and($result['song']['arrangements_count'])->toBe(1)
|
||||||
|
->and($result['auth']['method'])->toBe('setApiKey');
|
||||||
|
|
||||||
|
$eventsCall = $mockClient->firstCallFor('GET', '/api/events');
|
||||||
|
|
||||||
|
expect($eventsCall['options']['query']['from'])->toBe('2026-03-01')
|
||||||
|
->and($eventsCall['options']['query']['page'])->toBe(1)
|
||||||
|
->and(CTConfig::getRequestConfig()['query']['login_token'])->toBe('token-abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns auth blocker when API token is missing', function () {
|
||||||
|
$result = \App\Cts\CtsApiSpikeSync::run(
|
||||||
|
apiUrl: 'https://example.church.tools',
|
||||||
|
apiToken: '',
|
||||||
|
fromDate: '2026-03-01',
|
||||||
|
songId: 1,
|
||||||
|
client: new CtsApiSpikeMockClient([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['auth']['ok'])->toBeFalse()
|
||||||
|
->and($result['auth']['blocker'])->toContain('CTS_API_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
final class CtsApiSpikeMockClient extends CTClient
|
||||||
|
{
|
||||||
|
private array $responsesByUri;
|
||||||
|
|
||||||
|
private array $calls = [];
|
||||||
|
|
||||||
|
public function __construct(array $responsesByUri)
|
||||||
|
{
|
||||||
|
$this->responsesByUri = $responsesByUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($uri, array $options = []): ResponseInterface
|
||||||
|
{
|
||||||
|
$this->calls[] = [
|
||||||
|
'method' => 'GET',
|
||||||
|
'uri' => $uri,
|
||||||
|
'options' => $options,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! isset($this->responsesByUri[$uri]) || $this->responsesByUri[$uri] === []) {
|
||||||
|
return new Response(404, [], json_encode(['data' => []], JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_shift($this->responsesByUri[$uri]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function firstCallFor(string $method, string $uri): array
|
||||||
|
{
|
||||||
|
foreach ($this->calls as $call) {
|
||||||
|
if ($call['method'] === $method && $call['uri'] === $uri) {
|
||||||
|
return $call;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException("No call recorded for {$method} {$uri}");
|
||||||
|
}
|
||||||
|
}
|
||||||
5
tests/Feature/ExampleTest.php
Normal file
5
tests/Feature/ExampleTest.php
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
test('example', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
45
tests/Pest.php
Normal file
45
tests/Pest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Test Case
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||||
|
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||||
|
| need to change it using the "pest()" function to bind a different classes or traits.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
pest()->extend(Tests\TestCase::class)->in('Feature');
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expectations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||||
|
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||||
|
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
expect()->extend('toBeOne', function () {
|
||||||
|
return $this->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Functions
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||||
|
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||||
|
| global functions to help you to reduce the number of lines of code in your test files.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
function something()
|
||||||
|
{
|
||||||
|
// ..
|
||||||
|
}
|
||||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase as BaseTestCase;
|
||||||
|
|
||||||
|
abstract class TestCase extends BaseTestCase
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
5
tests/Unit/ExampleTest.php
Normal file
5
tests/Unit/ExampleTest.php
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
test('example', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue