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:
Thorsten Bus 2026-03-01 18:56:03 +01:00
parent 0b48289277
commit d99ca1e017
14 changed files with 5196 additions and 0 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.env
vendor/

View 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.

View 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
View 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
View 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

File diff suppressed because it is too large Load diff

110
docs/api-response-shapes.md Normal file
View 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
View 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>

View 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),
],
];
}
}

View 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}");
}
}

View file

@ -0,0 +1,5 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});

45
tests/Pest.php Normal file
View 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
View file

@ -0,0 +1,10 @@
<?php
namespace Tests;
use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View file

@ -0,0 +1,5 @@
<?php
test('example', function () {
expect(true)->toBeTrue();
});