diff --git a/WORKFLOW.md b/WORKFLOW.md deleted file mode 100644 index af14cf7..0000000 --- a/WORKFLOW.md +++ /dev/null @@ -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: - - - - ... -- Build-Kommando: .\mvnw.cmd clean verify ... -- Build-Status: ERFOLGREICH / FEHLGESCHLAGEN -- Offene Punkte: keine / -- Risiken: keine / -``` - ---- - -## 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. -``` diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java index 5039167..88669b4 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java @@ -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. *

@@ -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. + *

+ * For testing only: 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. *

diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java index 375cea8..3e0569a 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapterTest.java @@ -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 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 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 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 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 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 httpResponse = mockHttpResponse(200, "{}"); - when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse); - - adapter.invoke(request); - - ArgumentCaptor 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