Files
qbit-data-analysis/src/test/java/com/kingsrook/qbits/dataanalysis/DataAnalysisApiTest.java
T
james.maesandClaude Opus 5.5 3aa728d88a feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:37 -05:00

525 lines
31 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.dataanalysis;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Real HTTP session, schema and drill round-trip acceptance tests.
*******************************************************************************/
class DataAnalysisApiTest extends EngineFixture
{
private QApplicationJavalinServer server;
private UiApiInvoker invoker;
private String caller = "analysis-user";
/*******************************************************************************
** Give MOCK sessions only the grants represented by their caller cookie.
*******************************************************************************/
public static class Sessions implements QAuthenticationModuleCustomizerInterface
{
/*******************************************************************************
** Build the host's ordinary user permission and record-lock policy.
*******************************************************************************/
@Override
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
QSession grants = TestSupport.admin().withPermissions("orders.read", "customer.read").withSecurityKeyValue("orders.all", true);
session.setPermissions(grants.getPermissions());
session.setSecurityKeyValues(grants.getSecurityKeyValues());
if(!"denied".equals(session.getUuid()))
{
session.withPermission("orders.analyze");
}
session.withSecurityKeyValue("data_analysis_user", session.getUser().getIdReference());
if(session.getUuid().startsWith("watch-"))
{
session.getSecurityKeyValues().remove("data_analysis.admin");
}
}
}
/*******************************************************************************
** Start on an OS-selected port using the actual spec router and authentication.
*******************************************************************************/
private void start() throws Exception
{
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("http-test").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(Sessions.class)));
QInstance fixture = instance;
server = new QApplicationJavalinServer(new AbstractQQQApplication()
{
@Override
public QInstance defineQInstance()
{
return (fixture);
}
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true);
server.start();
invoker = UiApiInvoker.forServer(server);
}
/*******************************************************************************
** Stop the server before the fixture releases context.
*******************************************************************************/
@AfterEach
void stop()
{
if(server != null)
{
server.stop();
}
QJavalinImplementation.setSessionCookieHttpOnly(false);
}
/*******************************************************************************
** Invoke through loopback, forwarding the actual session cookie.
*******************************************************************************/
private JsonNode call(HttpMethod method, String path, Object body, int status) throws Exception
{
String savedId = path.startsWith("/saved/") ? path.split("/")[2] : null;
if(savedId != null)
{
path = path.replace("/saved/" + savedId + "/", "/saved/{id}/");
}
var request = new UiApiRequest().withMethod(method).withPath(DataAnalysisApi.PREFIX + path)
.withPathParam("table", "orders").withSessionUUID(caller).withUserTimezone("America/Chicago");
if(savedId != null)
{
request.withPathParam("id", savedId);
}
if(body != null)
{
request.withJsonBody(body);
}
var response = invoker.invoke(request);
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(status);
if(status >= 400)
{
for(String field : List.of("error", "code", "message", "traceId"))
{
assertThat(response.getJson().path(field).asText()).as(field).isNotBlank();
}
}
return (response.getJson());
}
/*******************************************************************************
** All operations, assistant discovery, OpenAPI and count-drill equivalence.
*******************************************************************************/
@Test
void happyPathsAndDrills() throws Exception
{
start();
JsonNode capabilities = call(HttpMethod.GET, "/tables/{table}/capabilities", null, 200);
assertThat(capabilities.path("canAnalyze").asBoolean()).isTrue();
assertThat(capabilities.path("backendCapabilities").toString()).contains("NUMERIC_BUCKET");
assertThat(capabilities.path("fields").toString()).doesNotContain("secret").contains("CATEGORY", "HISTOGRAM");
for(String field : List.of("amount", "note", "created", "order_date", "paid", "status"))
{
checkDrills(call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", field), 200));
}
checkDrills(call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "filter",
Map.of("criteria", List.of(Map.of("fieldName", "created", "operator", "EQUALS", "values", List.of("2026-01-01T12:00:00Z"))))), 200));
checkDrills(call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "status", "pageSize", 1), 200));
checkDrills(call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", List.of("amount", "note")), 200));
checkDrills(call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of("quantity"), "limit", 2), 200));
for(String operation : List.of("COLUMN_PROFILE", "TABLE_PROFILE"))
{
JsonNode started = call(HttpMethod.POST, "/runs", Map.of("operation", operation, "tableName", "orders", "request",
operation.equals("COLUMN_PROFILE") ? Map.of("fieldName", "quantity") : Map.of("fields", List.of("paid"))), 200);
JsonNode run = null;
for(int i = 0; i < 100; i++)
{
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/runs/{runId}")
.withPathParam("runId", started.path("runId").asText()).withSessionUUID(caller).withUserTimezone("America/Chicago"));
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(200);
run = response.getJson();
if(!run.path("status").asText().equals("RUNNING"))
{
break;
}
Thread.sleep(20);
}
assertThat(run.path("status").asText()).isEqualTo("COMPLETE");
var denied = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/runs/{runId}")
.withPathParam("runId", started.path("runId").asText()).withSessionUUID("another-owner"));
assertThat(denied.getStatus()).isEqualTo(403);
assertThat(run.path(operation.equals("COLUMN_PROFILE") ? "columnProfile" : "tableProfile").isObject()).isTrue();
}
assertThat(invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList()).hasSize(21);
var client = java.net.http.HttpClient.newHttpClient();
var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create("http://127.0.0.1:" + server.getBoundPort() + "/qqq/v1/openapi.json")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
JsonNode api = new ObjectMapper().readTree(response.body());
assertThat(api.path("paths").has("/qqq/v1/qbit/data-analysis/tables/{table}/column-profile")).isTrue();
assertThat(api.path("components").path("schemas").has("DataAnalysisMetricV1")).isTrue();
}
/*******************************************************************************
** Estimate payloads preserve exact totals and send drills to all matching rows.
*******************************************************************************/
@Test
void estimateDrills() throws Exception
{
config.withDefaultSampleThresholdRows(2).withDefaultSampleRows(4);
instance.withSupplementalMetaData(new DataAnalysisRuntime(config));
start();
JsonNode profile = call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount"), 200);
assertThat(profile.path("estimate").asBoolean()).isTrue();
assertThat(profile.path("sampleRows").asLong()).isEqualTo(4);
JsonNode rows = profile.path("counts").get(0);
assertThat(rows.path("value").asLong()).isEqualTo(12);
assertThat(rows.path("estimate").asBoolean()).isFalse();
assertThat(profile.path("counts").get(1).path("formatted").asText()).startsWith("≈ ");
assertThat(profile.toString()).doesNotContain("\"operator\":\"IN\"");
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/count")
.withPathParam("tableName", "orders").withJsonBody(Map.of("filter", rows.path("drill").path("filter")))
.withSessionUUID(caller).withUserTimezone("America/Chicago"));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getJson().path("count").asLong()).isEqualTo(12);
checkDrills(profile);
JsonNode table = call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", List.of("amount", "quantity")), 200);
assertThat(table.path("estimate").asBoolean()).isTrue();
assertThat(table.path("rowCount").path("value").asLong()).isEqualTo(12);
checkDrills(table);
}
/*******************************************************************************
** Recursively post returned count drills to the core count endpoint.
*******************************************************************************/
private void checkDrills(JsonNode node) throws Exception
{
if(node.path("estimate").asBoolean(false))
{
return;
}
if(node.isObject() && node.hasNonNull("drill"))
{
JsonNode expected = node.has("count") ? node.get("count") : node.has("y") ? node.get("y") : "COUNT".equals(node.path("valueType").asText()) ? node.get("value") : null;
if(expected != null && expected.isNumber())
{
JsonNode drill = node.get("drill");
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/count")
.withPathParam("tableName", drill.path("tableName").asText()).withJsonBody(Map.of("filter", drill.get("filter"))).withSessionUUID(caller).withUserTimezone("America/Chicago"));
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(200);
assertThat(response.getJson().path("count").asLong()).as(drill.toString()).isEqualTo(expected.asLong());
}
}
if(node.isContainerNode())
{
for(JsonNode child : node)
{
checkDrills(child);
}
}
}
/*******************************************************************************
** Denials, locked fields, missing tables and malformed request bodies.
*******************************************************************************/
@Test
void errors() throws Exception
{
start();
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "secret"), 400);
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "missing"), 400);
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of(), 400);
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", 1), 400);
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "exact", "yes"), 400);
call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "note", "pageSize", 0), 400);
call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of()), 400);
call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of("fields", "amount"), 400);
call(HttpMethod.POST, "/runs", Map.of("operation", "PIVOT"), 400);
call(HttpMethod.POST, "/runs", com.fasterxml.jackson.databind.node.NullNode.getInstance(), 400);
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount", "filter",
Map.of("criteria", List.of(Map.of("fieldName", "secret", "operator", "EQUALS", "values", List.of("hidden"))))), 403);
var absent = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath(DataAnalysisApi.PREFIX + "/tables/{table}/capabilities")
.withPathParam("table", "missing").withSessionUUID(caller).withUserTimezone("America/Chicago"));
assertThat(absent.getStatus()).isEqualTo(404);
caller = "denied";
assertThat(call(HttpMethod.GET, "/tables/{table}/capabilities", null, 200).path("canAnalyze").asBoolean()).isFalse();
call(HttpMethod.POST, "/tables/{table}/column-profile", Map.of("fieldName", "amount"), 403);
call(HttpMethod.POST, "/tables/{table}/distinct-values", Map.of("fieldName", "amount"), 403);
call(HttpMethod.POST, "/tables/{table}/table-profile", Map.of(), 403);
call(HttpMethod.POST, "/tables/{table}/duplicates", Map.of("fields", List.of("amount")), 403);
call(HttpMethod.POST, "/runs", Map.of("operation", "COLUMN_PROFILE", "tableName", "orders", "request", Map.of("fieldName", "amount")), 403);
}
/*******************************************************************************
** Generated fragment matches the committed contract and validates all references.
*******************************************************************************/
@Test
void contract() throws Exception
{
MiddlewareVersionV1 version = new MiddlewareVersionV1();
version.setQInstance(instance);
String yaml = UiApiContract.generateExtensionYaml(version, DataAnalysisApi.extension());
Path path = Path.of("src/main/resources/openapi/qqq-v1/data-analysis.yaml");
if(Boolean.getBoolean("openapi.write"))
{
Files.createDirectories(path.getParent());
Files.writeString(path, yaml);
}
assertThat(Files.exists(path)).as("Regenerate with ./mvnw -o test -Dtest=DataAnalysisApiTest#contract -Dopenapi.write=true -Dqqq.snapshot.version=4.1.99-SNAPSHOT").isTrue();
assertThat(Files.readString(path).replace("\r\n", "\n")).as("OpenAPI changed: regenerate with -Dopenapi.write=true").isEqualTo(yaml.replace("\r\n", "\n"));
assertThat(UiApiContract.check(yaml, Files.readString(path), null, 1, "-Dopenapi.write=true")).isEmpty();
}
@Test
void pivotHttp() throws Exception
{
start();
var body = Map.of("rows", List.of(Map.of("field", "status")), "columns", List.of(Map.of("field", "customer")),
"values", List.of(Map.of("operator", "COUNT")));
JsonNode result = call(HttpMethod.POST, "/tables/{table}/pivot", body, 200);
assertThat(result.path("cells").size()).isEqualTo(4);
checkDrills(result);
checkDrills(call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", "paid")),
"values", List.of(Map.of("operator", "COUNT")), "limitGroups", 1), 200));
for(String field : List.of("note", "amount", "quantity"))
{
checkDrills(call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", field)),
"values", List.of(Map.of("operator", "COUNT")), "limitGroups", 1), 200));
}
for(String unit : List.of("DAY", "HOUR", "DAY_OF_WEEK", "HOUR_OF_DAY"))
{
checkDrills(call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", "created", "dateBucket", unit)),
"values", List.of(Map.of("operator", "COUNT"))), 200));
}
checkDrills(call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", "amount", "numericBins", Map.of("count", 10))),
"values", List.of(Map.of("operator", "COUNT"))), 200));
for(String field : List.of("secret", "missing"))
{
call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", field)), "values", List.of(Map.of("operator", "COUNT"))), 400);
call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "MIN", "field", field))), 400);
}
call(HttpMethod.POST, "/tables/{table}/pivot", Map.of(), 400);
call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", "status", "values", List.of(Map.of("operator", "COUNT"))), 400);
call(HttpMethod.POST, "/tables/{table}/pivot", Map.of("rows", List.of(Map.of("field", "status")), "values", List.of(Map.of("operator", "COUNT")), "limitGroups", 1.5), 400);
caller = "denied";
call(HttpMethod.POST, "/tables/{table}/pivot", body, 403);
}
/*******************************************************************************
** Exercise all five relationship endpoints and count drills.
*******************************************************************************/
@Test
void relationshipHttp() throws Exception
{
start();
JsonNode compare = call(HttpMethod.POST, "/tables/{table}/compare", Map.of("fields", List.of("amount", "status"), "labelA", "Selected", "labelB", "All"), 200);
assertThat(compare.path("fields").size()).isEqualTo(2);
assertThat(compare.path("fields").get(0).path("values").get(0).path("sideA").path("label").asText()).isEqualTo("Selected");
relationshipCountDrills(compare);
JsonNode shift = call(HttpMethod.POST, "/tables/{table}/shift", Map.of("field", "status", "dateField", "order_date", "bucket", "DAY"), 200);
assertThat(shift.path("psi").path("value").asDouble()).isZero();
relationshipCountDrills(shift);
JsonNode correlation = call(HttpMethod.POST, "/tables/{table}/correlation", Map.of("fields", List.of("amount", "quantity")), 200);
assertThat(correlation.path("cells").size()).isEqualTo(4);
assertThat(correlation.path("cells").get(1).path("n").asLong()).isEqualTo(11);
JsonNode scatter = call(HttpMethod.POST, "/tables/{table}/scatter", Map.of("xField", "id", "yField", "amount", "limit", 3), 200);
assertThat(scatter.path("sampleSize").asInt()).isEqualTo(3);
assertThat(scatter.path("trend").path("slope").asDouble()).isEqualTo(10);
for(JsonNode point : scatter.path("points"))
{
JsonNode drill = point.path("drill");
var response = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/count")
.withPathParam("tableName", "orders").withJsonBody(Map.of("filter", drill.get("filter"))).withSessionUUID(caller));
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getJson().path("count").asLong()).isEqualTo(1);
}
relationshipCountDrills(call(HttpMethod.POST, "/tables/{table}/crosstab", Map.of("rowField", "status", "columnField", "customer"), 200));
// All new schemas are discoverable from the same served OpenAPI catalog.
assertThat(com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi.extension().getSemanticVersion()).isEqualTo("1.5.0");
}
/*******************************************************************************
** Exercise all five relationship endpoints and count drills.
*******************************************************************************/
/*******************************************************************************
** Verify strict request validation, privacy and authorization over HTTP.
*******************************************************************************/
@Test
void relationshipHttpErrorsAndPrivacy() throws Exception
{
config.withDefaultMinGroupSize(20);
instance.withSupplementalMetaData(new DataAnalysisRuntime(config));
start();
var requests = Map.of("compare", Map.of("fields", List.of("amount")), "shift", Map.of("field", "status", "dateField", "order_date"),
"correlation", Map.of("fields", List.of("amount", "quantity")), "scatter", Map.of("xField", "id", "yField", "amount"),
"crosstab", Map.of("rowField", "status", "columnField", "customer"));
assertThat(call(HttpMethod.POST, "/tables/{table}/scatter", requests.get("scatter"), 200).path("points").size()).isZero();
for(String field : List.of("secret", "missing"))
{
call(HttpMethod.POST, "/tables/{table}/compare", Map.of("fields", List.of(field)), 400);
call(HttpMethod.POST, "/tables/{table}/shift", Map.of("field", "status", "dateField", field), 400);
call(HttpMethod.POST, "/tables/{table}/correlation", Map.of("fields", List.of("id", field)), 400);
call(HttpMethod.POST, "/tables/{table}/scatter", Map.of("xField", "id", "yField", field), 400);
call(HttpMethod.POST, "/tables/{table}/crosstab", Map.of("rowField", "status", "columnField", field), 400);
}
call(HttpMethod.POST, "/tables/{table}/compare", Map.of("fields", "amount"), 400);
call(HttpMethod.POST, "/tables/{table}/compare", Map.of("fields", List.of("amount"), "filterA", "bad"), 400);
call(HttpMethod.POST, "/tables/{table}/compare", Map.of("fields", List.of("amount"), "labelA", 7), 400);
call(HttpMethod.POST, "/tables/{table}/scatter", Map.of("xField", "id", "yField", "amount", "limit", 2.5), 400);
call(HttpMethod.POST, "/tables/{table}/scatter", Map.of("xField", "id", "yField", "amount", "limit", 2001), 400);
call(HttpMethod.POST, "/tables/{table}/correlation", Map.of("fields", List.of("id")), 400);
call(HttpMethod.POST, "/tables/{table}/correlation", Map.of("fields", List.of("id", "id")), 400);
call(HttpMethod.POST, "/tables/{table}/shift", Map.of("field", "amount", "dateField", "order_date", "bucket", "BAD"), 400);
call(HttpMethod.POST, "/tables/{table}/crosstab", Map.of("rowField", "status", "columnField", "customer", "limit", 21), 400);
for(String operation : requests.keySet())
{
call(HttpMethod.POST, "/tables/{table}/" + operation, Map.of(), 400);
}
caller = "denied";
for(var request : requests.entrySet())
{
call(HttpMethod.POST, "/tables/{table}/" + request.getKey(), request.getValue(), 403);
}
}
/*******************************************************************************
** Post each released count drill to the actual core count endpoint.
*******************************************************************************/
private void relationshipCountDrills(JsonNode node) throws Exception
{
if(node.path("valueType").asText().equals("COUNT"))
{
checkDrills(node);
}
else if(node.isContainerNode())
{
for(JsonNode child : node)
{
relationshipCountDrills(child);
}
}
}
@Test
void watchPreviewOverHttpIsReadOnlyAndOwnerLocked() throws Exception
{
caller = "watch-owner";
String owner = "User:" + Math.floorMod(caller.hashCode(), 10_000);
java.time.ZoneId zone = java.time.ZoneId.of("America/Chicago");
java.time.LocalDate today = java.time.LocalDate.now(zone);
for(int i = 0; i < 5; i++)
{
TestSupport.insert("orders", new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("id", 100 + i).withValue("customer", 1)
.withValue("created", today.minusDays(i < 2 ? 3 - i : 1).atTime(12, 0).atZone(zone).toInstant()));
}
var row = TestSupport.insert(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.WATCH,
WatchDetectorsTest.definition("SPIKE", "COUNT", "{\"k\":2}").withValue("owner_user_id", owner));
start();
var request = new UiApiRequest().withMethod(HttpMethod.POST).withPath(DataAnalysisApi.PREFIX + "/watches/{id}/preview")
.withPathParam("id", row.getValueString("id")).withSessionUUID(caller).withUserTimezone("America/Chicago");
var response = invoker.invoke(request);
assertThat(response.getStatus()).as(response.getBody()).isEqualTo(200);
JsonNode json = response.getJson();
assertThat(json.path("series").size()).isEqualTo(3);
assertThat(json.path("verdict").path("firing").asBoolean()).isTrue();
assertThat(json.path("verdict").path("value").asDouble()).isEqualTo(3);
assertThat(json.path("chart").path("kind").asText()).isEqualTo("TIME_SERIES");
assertThat(json.path("chart").path("annotations").toString()).contains("Baseline band");
checkDrills(json);
assertThat(com.kingsrook.qbits.dataanalysis.watch.WatchRecords.get(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.WATCH, row.getValue("id")).getValue("last_evaluated_at")).isNull();
assertThat(invoker.invoke(request.withSessionUUID("watch-outsider")).getStatus()).isEqualTo(404);
assertThat(invoker.invoke(request.withSessionUUID("denied")).getStatus()).isEqualTo(403);
assertThat(invoker.invoke(request.withSessionUUID(caller).withPathParam("id", "99999")).getStatus()).isEqualTo(404);
assertThat(invoker.invoke(request.withPathParam("id", "invalid")).getStatus()).isEqualTo(400);
}
@Test
void savedOperationsAndFileResponsesOverHttp() throws Exception
{
var shared = TestSupport.insert(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SAVED,
SavedAnalysesTest.definition("alice", true, "TABLE_PROFILE", Map.of("fields", List.of("amount", "secret"))));
var privateRow = TestSupport.insert(com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables.SAVED,
SavedAnalysesTest.definition("alice", false, "COLUMN_PROFILE", Map.of("fieldName", "amount")));
start();
caller = "watch-saved-viewer";
String path = "/saved/" + shared.getValue("id");
var result = call(HttpMethod.POST, path + "/run", null, 200);
assertThat(result.path("kind").asText()).isEqualTo("TABLE_PROFILE");
assertThat(result.path("warnings").toString()).contains("Some fields are not available to you");
assertThat(result.path("tableProfile").path("fields").size()).isEqualTo(1);
call(HttpMethod.POST, "/saved/" + privateRow.getValue("id") + "/run", null, 404);
call(HttpMethod.POST, "/saved/invalid/run", null, 400);
assertThat(call(HttpMethod.PUT, path + "/star", null, 200).path("starred").asBoolean()).isTrue();
call(HttpMethod.PUT, path + "/star", null, 200);
assertThat(call(HttpMethod.GET, "/saved", null, 200).path("analyses").get(0).path("starred").asBoolean()).isTrue();
assertThat(call(HttpMethod.DELETE, path + "/star", null, 200).path("starred").asBoolean()).isFalse();
assertThat(call(HttpMethod.POST, "/tables/{table}/sql", Map.of("kind", "TABLE_PROFILE", "request", Map.of("fields", List.of("amount"))), 200).path("available").asBoolean()).isFalse();
var client = java.net.http.HttpClient.newHttpClient();
for(String format : List.of("csv", "xlsx"))
{
String url = "http://127.0.0.1:" + server.getBoundPort() + "/qqq/v1" + DataAnalysisApi.PREFIX + path + "/export?format=" + format;
var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(url)).header("Cookie", "sessionUUID=" + caller).GET().build(), java.net.http.HttpResponse.BodyHandlers.ofByteArray());
assertThat(response.statusCode()).as(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)).isEqualTo(200);
assertThat(response.headers().firstValue("Content-Disposition").orElse("")).contains("analysis." + format);
if(format.equals("xlsx"))
{
try(var workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(new java.io.ByteArrayInputStream(response.body())))
{
assertThat(workbook.getSheetAt(0).getLastRowNum()).isPositive();
}
}
else
{
assertThat(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8)).contains("Completeness");
}
}
var export = invoker.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath(DataAnalysisApi.PREFIX + "/export").withSessionUUID(caller)
.withJsonBody(Map.of("kind", "COLUMN_PROFILE", "tableName", "orders", "request", Map.of("fieldName", "amount"), "format", "csv")));
assertThat(export.getStatus()).as(export.getBody()).isEqualTo(200);
assertThat(export.getBody()).contains("Counts");
call(HttpMethod.POST, "/export", Map.of("kind", "TABLE_PROFILE", "tableName", "orders", "request", Map.of(), "format", "pdf"), 400);
caller = "denied";
call(HttpMethod.POST, path + "/run", null, 403);
var operations = invoker.listOperations().stream().filter(o -> o.pathTemplate().startsWith(DataAnalysisApi.PREFIX)).toList();
assertThat(operations).hasSize(21);
assertThat(operations.stream().map(Object::toString).toList().toString()).contains("/saved/{id}/run", "/saved/{id}/star", "/saved/{id}/export", "/tables/{table}/sql", "/export");
}
}