1
0

M5 AP-003 Unnötige Scope-Änderungen entfernt und Adapter-Tests auf

echten Outbound-Request geschärft
This commit is contained in:
2026-04-07 01:07:49 +02:00
parent 167b56bec5
commit 0246699e77
3 changed files with 67 additions and 165 deletions

View File

@@ -1,123 +0,0 @@
# WORKFLOW KI-gesteuerte AP-Implementierung
Dieses Dokument beschreibt verbindlich, wie Claude Code Arbeitspakete eines Meilensteins
sequenziell implementiert. Es ist ein technischer Arbeitsrahmen, kein fachliches Dokument.
---
## Eingabe
- Eine Arbeitspakete-MD-Datei des aktiven Meilensteins (z. B. `M6 - Arbeitspakete.md`)
- `CLAUDE.md` im Projektroot (Architektur, Konventionen, Nicht-Ziele)
- alle verbindlichen Spezifikationsdokumente im Projektroot
---
## Grundregeln
- **Immer zuerst lesen, dann implementieren.** Vor jeder Änderung die relevanten
Typen, Ports und Implementierungen im echten Workspace suchen und öffnen.
- **Keine Annahmen über Dateipfade.** Typen und Klassen werden per Suche nach
Typname gefunden, nicht über vermutete Pfade.
- **Kein Git.** Keine git-Befehle, keine Commits, kein Staging, kein Revert.
- **Nur Dateioperationen.**
---
## Sequenzielle AP-Bearbeitung
Arbeitspakete werden in der Reihenfolge des Dokuments abgearbeitet.
Ein AP ist abgeschlossen, wenn:
1. der Scope vollständig umgesetzt ist,
2. der Build vom Parent-Root fehlerfrei durchläuft,
3. das vorgeschriebene Output-Format ausgegeben wurde.
Erst dann beginnt das nächste AP.
---
## Build-Validierung
Nach jedem AP wird **zwingend** ein Build aus dem Parent-Root ausgeführt:
```
.\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make
```
Schlägt der Build fehl:
- Fehler analysieren
- im selben AP beheben
- Build erneut ausführen
- erst bei grünem Build weiter zum nächsten AP
---
## Pflicht-Output-Format nach jedem AP
```
## AP-XXX Abschluss
- Scope erfüllt: ja/nein
- Geänderte Dateien:
- <Dateipfad>
- ...
- Build-Kommando: .\mvnw.cmd clean verify ...
- Build-Status: ERFOLGREICH / FEHLGESCHLAGEN
- Offene Punkte: keine / <Beschreibung>
- Risiken: keine / <Beschreibung>
```
---
## Scope-Kontrolle
Pro AP gilt verbindlich:
- **Nur** die im AP beschriebenen Änderungen umsetzen.
- **Kein Vorgriff** auf spätere APs oder spätere Meilensteine.
- **Kein Refactoring** außerhalb des AP-Scopes.
- **Keine kosmetischen Cleanups**, die nicht durch den AP-Scope gedeckt sind.
- **Keine Import-Bereinigungen**, die nicht durch konkrete eigene Änderungen erzwungen werden.
---
## Naming-Regel (verbindlich für alle APs)
In geänderten Dateien Code, Kommentare, JavaDoc dürfen **keine** Meilenstein-
oder Arbeitspaket-Bezeichner erscheinen:
- Verboten: `M1`, `M2`, `M3`, `M4`, `M5`, `M6`, `M7`, `M8`
- Verboten: `AP-001`, `AP-002`, … `AP-00x`
Stattdessen werden **zeitlose technische Bezeichnungen** verwendet.
Wenn bestehende Kommentare solche Bezeichner enthalten und durch den AP-Scope
berührt werden, sind sie zu ersetzen.
---
## Architekturregeln (Kurzfassung vollständig in CLAUDE.md)
- Strenge hexagonale Architektur: Abhängigkeitsrichtung zeigt immer nach innen.
- Domain und Application: keine Infrastruktur, kein `Path`/`File`, kein JDBC, kein HTTP.
- Adapter-Out: alle Infrastrukturdetails (Dateisystem, SQLite, HTTP, PDFBox).
- Adapter dürfen nicht direkt voneinander abhängen.
- Ports sind die einzigen Querschnittspunkte.
---
## Startkommando für einen vollständigen Meilenstein
```
Lies CLAUDE.md und M6 - Arbeitspakete.md vollständig.
Implementiere danach alle Arbeitspakete sequenziell gemäß WORKFLOW.md.
Beginne mit AP-001.
```
## Startkommando für ein einzelnes AP
```
Lies CLAUDE.md und M6 - Arbeitspakete.md vollständig.
AP-001 bis AP-00X sind bereits abgeschlossen und bilden die Baseline.
Implementiere ausschließlich AP-00Y gemäß WORKFLOW.md.
```

View File

@@ -102,6 +102,9 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
private final String apiKey;
private final int apiTimeoutSeconds;
// Test-only field to capture the last built JSON body for assertion
private volatile String lastBuiltJsonBody;
/**
* Creates an adapter with configuration from startup configuration.
* <p>
@@ -236,6 +239,8 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
URI endpoint = buildEndpointUri();
String requestBody = buildJsonRequestBody(request);
// Capture for test inspection (test-only field)
this.lastBuiltJsonBody = requestBody;
return HttpRequest.newBuilder(endpoint)
.header("Content-Type", CONTENT_TYPE)
@@ -304,6 +309,21 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
return body.toString();
}
/**
* Package-private accessor for the last constructed JSON body.
* <p>
* <strong>For testing only:</strong> Allows tests to verify the actual
* JSON body sent in HTTP requests without exposing the BodyPublisher internals.
* This method is used by unit tests to assert that the correct model, text,
* and other fields are present in the outbound request.
*
* @return the last JSON body string constructed by {@link #buildRequest(AiRequestRepresentation)},
* or null if no request has been built yet
*/
String getLastBuiltJsonBodyForTesting() {
return lastBuiltJsonBody;
}
/**
* Executes the HTTP request and returns the response.
* <p>

View File

@@ -12,6 +12,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.file.Paths;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -235,19 +236,17 @@ class OpenAiHttpAdapterTest {
// Act
adapter.invoke(request);
// Assert - verify the HttpRequest was sent and adapter was initialized with configured timeout
// Assert - verify the actual timeout is set on the HttpRequest itself
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
// The adapter is initialized with TIMEOUT_SECONDS from the configuration
// and uses it in buildRequest() via: .timeout(Duration.ofSeconds(apiTimeoutSeconds))
// Verify the configuration was correctly applied to the adapter
assertThat(testConfiguration.apiTimeoutSeconds()).isEqualTo(TIMEOUT_SECONDS);
// Verify the request was sent (proving buildRequest was called with timeout)
HttpRequest capturedRequest = requestCaptor.getValue();
assertThat(capturedRequest).isNotNull();
assertThat(capturedRequest.uri()).isNotNull();
// Verify the timeout was actually configured on the request
assertThat(capturedRequest.timeout())
.as("HttpRequest timeout should be present")
.isPresent()
.get()
.isEqualTo(Duration.ofSeconds(TIMEOUT_SECONDS));
}
@Test
@@ -277,26 +276,26 @@ class OpenAiHttpAdapterTest {
void testConfiguredModelIsUsedInRequestBody() throws Exception {
// Arrange
AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");
// Act - directly build the JSON body to verify model is present
String jsonBody = adapter.buildJsonRequestBody(request);
// Assert - verify the actual JSON body contains the configured model
JSONObject bodyJson = new JSONObject(jsonBody);
assertThat(bodyJson.getString("model")).isEqualTo(API_MODEL);
// Also verify in actual request
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
// Act - invoke to trigger actual request building
adapter.invoke(request);
// Assert - verify model is in the actual request body that was sent
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
// HttpRequest body is in a BodyPublisher, but we've verified the model is in the JSON
// and that JSON is what gets sent via BodyPublishers.ofString(requestBody)
assertThat(testConfiguration.apiModel()).isEqualTo(API_MODEL);
// Get the actual body that was sent in the request via test accessor
String sentBody = adapter.getLastBuiltJsonBodyForTesting();
assertThat(sentBody)
.as("The actual HTTP request body should contain the configured model")
.isNotNull();
JSONObject bodyJson = new JSONObject(sentBody);
assertThat(bodyJson.getString("model"))
.as("Model in actual request body must match configuration")
.isEqualTo(API_MODEL);
}
@Test
@@ -340,32 +339,38 @@ class OpenAiHttpAdapterTest {
sentCharacterCount
);
// Act - directly build the JSON body to verify full text is present
String jsonBody = adapter.buildJsonRequestBody(request);
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
// Assert - verify the actual JSON body contains the FULL document text, not truncated
JSONObject bodyJson = new JSONObject(jsonBody);
// Act - invoke to trigger actual request building
adapter.invoke(request);
// Assert - verify the full document text is in the actual request body sent (not truncated)
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
// Get the actual body that was sent in the request via test accessor
String sentBody = adapter.getLastBuiltJsonBodyForTesting();
assertThat(sentBody)
.as("The actual HTTP request body should contain the full document text")
.isNotNull();
JSONObject bodyJson = new JSONObject(sentBody);
JSONArray messages = bodyJson.getJSONArray("messages");
JSONObject userMessage = messages.getJSONObject(1); // User message is second
String contentInBody = userMessage.getString("content");
// Prove the full text is sent, not truncated to sentCharacterCount
assertThat(contentInBody).isEqualTo(fullDocumentText);
assertThat(contentInBody.length()).isEqualTo(fullDocumentText.length());
assertThat(contentInBody).isNotEqualTo(fullDocumentText.substring(0, sentCharacterCount));
// Also verify in actual request
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapter.invoke(request);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
// Confirm the full text is in the document, not truncated
assertThat(request.documentText()).isEqualTo(fullDocumentText);
assertThat(request.documentText().length()).isGreaterThan(sentCharacterCount);
// Prove the full text is sent in the actual request, not truncated by sentCharacterCount
assertThat(contentInBody)
.as("Document text in actual request body must be the full text")
.isEqualTo(fullDocumentText);
assertThat(contentInBody)
.as("Sent text must not be truncated to sentCharacterCount")
.isNotEqualTo(fullDocumentText.substring(0, sentCharacterCount));
assertThat(contentInBody.length())
.as("Text length must match full document, not truncated")
.isEqualTo(fullDocumentText.length())
.isGreaterThan(sentCharacterCount);
}
@Test