From 217e65be8cb4ca554f962f886115c6348aca97c4 Mon Sep 17 00:00:00 2001 From: Jonathan Jauhari <40555491+jonjau@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:33:12 +0000 Subject: [PATCH 1/2] Add JUnit extension example --- README.md | 16 +++ doc/example/StateChangeLogger.java | 179 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 doc/example/StateChangeLogger.java diff --git a/README.md b/README.md index 299d952..2a40a19 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,22 @@ There's a few things we can loosely infer: - It takes 2 steps to get from `Status` of 'ordered' to 'delivered'. - We could probably cancel 'Ord2' before it goes to the 'delivered' `Status`. +## API usage + +A roughly-written example JUnit extension can be found in [doc/example/StateChangeLogger.java](doc/example/StateChangeLogger.java). + +It won't run, but might give a better idea of the use case above. + +So to track database changes and log them to Pythia for a test suite: + +```java + @ExtendWith(StateChangeLogger.class) + @Test + void myTest() { + //... do something with database here + } +``` + ## License Pythia is currently licensed under the terms of both the MIT license and the Apache License (Version 2.0). See [`LICENSE-MIT`](/LICENSE-MIT) and [`LICENSE-APACHE`](/LICENSE-APACHE) for more details. diff --git a/doc/example/StateChangeLogger.java b/doc/example/StateChangeLogger.java new file mode 100644 index 0000000..f16e80e --- /dev/null +++ b/doc/example/StateChangeLogger.java @@ -0,0 +1,179 @@ +package org.example; + +import org.junit.jupiter.api.extension.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.CookieManager; +import java.time.Instant; +import java.util.*; +import java.nio.charset.StandardCharsets; +import java.net.URLEncoder; +import java.lang.reflect.Method; + +import myorg.dbclient.Database; + +public class StateChangeLogger implements BeforeTestExecutionCallback, AfterTestExecutionCallback { + private static final String BASE_URL = "http://localhost:3000"; + private static final String FACTS_ENDPOINT = BASE_URL + "/api/order/facts"; + private static final String TOKEN_ENDPOINT = BASE_URL + "/sessions/resume"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // Get user token from Pythia server and set it here for the client + private static final String USER_TOKEN = "HIzwqZWKTqq50P08967U2Q"; + private static final String TABLE_NAME = "order"; + private static final List FACT_FIELDS = List.of( + "OrId", + "Amount", + "Status", + "Message", + "DispatchDate", + "DeliveryDate" + ); + + private final Map startTimes = new HashMap<>(); + private final Map contexts = new HashMap<>(); + + @Override + public void beforeTestExecution(ExtensionContext context) { + startTimes.put(context.getUniqueId(), Instant.now()); + contexts.put(context.getUniqueId(), context.getDisplayName() + Instant.now().toString()); + + System.out.printf("[Before] Running test: %s at %s %n", context.getDisplayName(), context.getUniqueId()); + } + + @Override + public void afterTestExecution(ExtensionContext context) { + System.out.printf("[After] Finished test: %s%n", context.getDisplayName()); + + try { + var db = (Database) invoke(context.getRequiredTestInstance(), "mainDb"); + List> facts = fetchChangedFacts(db, context); + sendFactsToServer(facts); + } catch (Exception e) { + throw new RuntimeException("Error in afterTestExecution", e); + } + } + + private Object invoke(Object instance, String methodName) { + Class clazz = instance.getClass(); + while (clazz != null) { + try { + Method m = clazz.getDeclaredMethod(methodName); + m.setAccessible(true); + return m.invoke(instance); + } catch (NoSuchMethodException e) { + clazz = clazz.getSuperclass(); + } catch (Exception e) { + throw new RuntimeException("Failed to invoke method: " + methodName, e); + } + } + return null; + } + + private List> fetchChangedFacts(Database db, ExtensionContext context) { + return db + .sqlQuery( + buildFactsQuery(TABLE_NAME, FACT_FIELDS) + ) + .setParameter("Context", contexts.get(context.getUniqueId())) + .setParameter("Time0", startTimes.get(context.getUniqueId())) + .findList() + .stream() + .map(row -> { + Map map = new LinkedHashMap<>(); + FACT_FIELDS.forEach(column -> { + var value = row.get(column); + map.put(column, value != null ? value.toString() : null); + }); + map.put("Context", row.get("Context").toString()); + map.put("EditTime1", row.get("EditTime1").toString()); + map.put("EditTime2", row.get("EditTime2").toString()); + map.put("SeqNum", row.get("SeqNum").toString()); + return map; + }) + .toList(); + } + + private static String buildFactsQuery(String tableName, List fields) { + String fieldList = fields.stream() + .map(f -> String.format("%s.%s AS \"%s\"", tableName, f, f)) + .reduce((a, b) -> a + ",\n " + b) + .orElse(""); + + String extraColumns = """ + :Context AS "Context", + %1$s.EditTime AS "EditTime1", + %1$s.EditTime AS "EditTime2" + """.formatted(tableName); + + String selectMain = """ + SELECT + %1$s + ,CAST(%2$s.SeqNum AS VARCHAR) AS "SeqNum" + FROM %2$s + WHERE %2$s.EditTime > :Time0 + """.formatted(fieldList + ",\n" + extraColumns, tableName); + + String auditTableName = "Audit_" + tableName; + + fieldList = fields.stream() + .map(f -> String.format("%s.%s AS \"%s\"", auditTableName, f, f)) + .reduce((a, b) -> a + ",\n " + b) + .orElse(""); + + extraColumns = """ + :Context AS "Context", + %1$s.EditTime AS "EditTime1", + %1$s.EditTime AS "EditTime2" + """.formatted(auditTableName); + + String selectAudit = """ + SELECT + %1$s + ,CAST(%2$s.SeqNum AS VARCHAR) AS "SeqNum" + FROM %2$s + WHERE %2$s.EditTime > :Time0 + """.formatted(fieldList + ",\n" + extraColumns, auditTableName); + + return selectMain + "\nUNION\n" + selectAudit; + } + + private void sendFactsToServer(List> facts) throws Exception { + Map payload = Map.of("facts", facts); + String json = MAPPER.writeValueAsString(payload); + + HttpClient client = createAuthenticatedHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(FACTS_ENDPOINT)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + System.out.printf("POST %s -> %d %s%n", FACTS_ENDPOINT, response.statusCode(), response.body()); + } + + private HttpClient createAuthenticatedHttpClient() throws Exception { + CookieManager cookieManager = new CookieManager(); + HttpClient client = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(TOKEN_ENDPOINT)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString( + "user_token=" + URLEncoder.encode(USER_TOKEN, StandardCharsets.UTF_8))) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + System.out.printf("Auth response: %d %s%n", response.statusCode(), response.body()); + + return client; + } +} \ No newline at end of file From facaad3dae29aa4cfec511a3ad91fc9ba1535afd Mon Sep 17 00:00:00 2001 From: Jonathan Jauhari <40555491+jonjau@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:53:11 +0000 Subject: [PATCH 2/2] Add example payloads --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/README.md b/README.md index 2a40a19..df1b321 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,66 @@ So to track database changes and log them to Pythia for a test suite: } ``` +### Example payloads + +To set the `user_token` cookie on the client, `POST` to `/sessions/resume` with a `user_token` form field set to an existing `user_token`. + +Then, to add a record type: + +```json + { + "name": "order", + "id_fields": ["OrId"], + "data_fields": [ + "Amount", + "Status", + "Message", + "DispatchDate", + "DeliveryDate" + ], + "metadata_fields": ["Context", "SeqNum"] + } +``` + +and to add facts for that record type: + +```json +{ + "facts": [ + { + "OrId": "Ord1", + "Amount": "100000", + "Status": "ordered", + "Message": "", + "DispatchDate": "", + "DeliveryDate": "", + "Context": "Test_FailOrder", + "SeqNum": "0" + }, + { + "OrId": "Ord1", + "Amount": "0", + "Status": "error", + "Message": "no stock", + "DispatchDate": "", + "DeliveryDate": "", + "Context": "Test_FailOrder", + "SeqNum": "1" + }, + { + "OrId": "Ord2", + "Amount": "100", + "Status": "delivered", + "Message": "", + "DispatchDate": "2025-02-02", + "DeliveryDate": "2023-02-04", + "Context": "Test_FailOrder", + "SeqNum": "2" + } + ] +} +``` + ## License Pythia is currently licensed under the terms of both the MIT license and the Apache License (Version 2.0). See [`LICENSE-MIT`](/LICENSE-MIT) and [`LICENSE-APACHE`](/LICENSE-APACHE) for more details.