* feat(GH-21): complete runtime backend and JavaScript demos * fix(GH-21): follow listener address for the API catalog * ci(#8): pin settings-safe QQQ orb 0.6.8
This commit was merged in pull request #22.
This commit is contained in:
1 parent
e1625d2000
commit
ce464ffb9f
21 files changed
+934
-67
No files matched your search
@@ -3,7 +3,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.5
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Read-only API catalog, transient notes, and the fixed JavaScript calculation.
|
||||
INSERT INTO permission (name, object_type, object_label)
|
||||
VALUES
|
||||
('apiCatalog.read', 'table', 'API Catalog'),
|
||||
('demoNote.read', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.insert', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.edit', 'table', 'Demo Note (Transient)'),
|
||||
('demoNote.delete', 'table', 'Demo Note (Transient)'),
|
||||
('calculateOrderTotal.hasAccess', 'process', 'Calculate Order Total (JavaScript)')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO role_permission_int (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM role CROSS JOIN permission
|
||||
WHERE permission.name IN ('apiCatalog.read', 'demoNote.read', 'demoNote.insert',
|
||||
'demoNote.edit', 'demoNote.delete', 'calculateOrderTotal.hasAccess')
|
||||
AND (role.name = 'admin' OR (role.name = 'viewer' AND permission.name IN ('apiCatalog.read', 'demoNote.read')))
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING;
|
||||
@@ -55,3 +55,64 @@ QQQ_ALL_COMPOSE_BROKERS=true mvn -pl qqq-all-app -am -Dtest=FullProfileBrokerInt
|
||||
```
|
||||
|
||||
The test uses the loopback management ports to read and pause/resume the Artemis subscription queue, then creates, reads, purges, and deletes a temporary RabbitMQ queue. For a customized `.env`, source that file instead. It is skipped during ordinary `mvn verify`.
|
||||
|
||||
## Local runtime demonstrations (#21)
|
||||
|
||||
Both profiles include the BOM-managed API backend and JavaScript module. The shaded
|
||||
JAR retains Nashorn's `ScriptEngineFactory` service registration. No extra service,
|
||||
API key, script editor, or user-supplied code is needed.
|
||||
|
||||
| Demo | Behavior | Access |
|
||||
| --- | --- | --- |
|
||||
| `apiCatalog` | QQQ API-backed table reads three synthetic products from this app's `/demo/catalog` HTTP endpoint; native queries page through the endpoint and get-by-ID uses `/demo/catalog/{id}`. | Admin and Demo/Viewer can read; neither receives write grants. |
|
||||
| `calculateOrderTotal` | Executes fixed JavaScript through `ExecuteCodeAction`: quantity × unit price in cents → `totalCents`. It changes no records. | Admin only. |
|
||||
| `demoNote` | Memory-backed table, seeded with `Transient demo note` on every start and cleared on shutdown. | Admin can edit; Demo/Viewer can read. |
|
||||
|
||||
The internal catalog URL follows `QQQ_ALL_BIND_HOST` and the configured HTTP port.
|
||||
IPv4/IPv6 wildcard listeners use their corresponding loopback address; literal
|
||||
IPv6 hosts are bracketed correctly in the URL.
|
||||
|
||||
The catalog endpoint exposes **only synthetic public demo data**. It is not an
|
||||
outbound proxy and does not forward session credentials. The adapter supports
|
||||
ascending ID order, limit/skip pagination, and one exact ID filter; unsupported
|
||||
filters and sorts fail explicitly. Native get of an absent ID returns no record.
|
||||
The endpoint rejects invalid or excessive page sizes (maximum 1000).
|
||||
|
||||
With the core JAR running on port 8080, exercise the real QQQ routes:
|
||||
|
||||
```sh
|
||||
curl -fsS -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:8080/qqq/v1/table/apiCatalog/query
|
||||
curl -fsS http://127.0.0.1:8080/data/apiCatalog/103
|
||||
curl -fsS -H 'Cookie: sessionId=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -H 'Content-Type: application/json' -d '{}' 'http://127.0.0.1:8080/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250'
|
||||
```
|
||||
|
||||
The last response contains `values.totalCents: 3750`. Quantity must be an integer
|
||||
in 1..1000; unit price must be an integer in 0..1000000 cents. The process does not
|
||||
accept code from the caller. This demonstration does not alter stored-script or
|
||||
Test-mode behavior.
|
||||
|
||||
Memory notes are deliberately **not persisted** with the data directory or a
|
||||
Compose volume. Restarting discards edits and restores the seed. Like the existing
|
||||
QQQ memory and ESB runtime singletons, this demo assumes one application per JVM.
|
||||
The memory lifecycle touches only `demoNote`; it never resets the global store.
|
||||
|
||||
Full-profile PostgreSQL now uses QQQ's first-party `PostgreSQLBackendMetaData` and
|
||||
strategy, including driver/URL selection, quoted identifiers, default-value
|
||||
inserts, generated IDs, null binding, and UTC timestamp handling. Existing qbit
|
||||
schema provisioning and compatibility checks remain in place.
|
||||
|
||||
Fresh full-profile volumes apply `infra/postgres/05-runtime-demo-permissions.sql`.
|
||||
For an existing volume, apply the idempotent grants and then sign in again:
|
||||
|
||||
```sh
|
||||
COMPOSE_PROFILES=full docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < infra/postgres/05-runtime-demo-permissions.sql
|
||||
```
|
||||
|
||||
`mvn -pl qqq-all-app -am verify` includes native local API, calculation/permission,
|
||||
memory restart, and packaged-JAR checks. `scripts/run_core_smoke.py` and
|
||||
`scripts/run_full_smoke.py` verify these routes with actual Admin and restricted
|
||||
sessions; full smoke also retains the OIDC, qbit, and broker checks. The opt-in
|
||||
`PostgresCompatibilityTest` uses `QQQ_ALL_TEST_POSTGRES_PORT` and
|
||||
`QQQ_ALL_TEST_POSTGRES_PASSWORD` against a local `qqq` database/user; it creates and
|
||||
drops only a uniquely named test table. It is skipped during ordinary verify.
|
||||
All eight included qbits, their explicit deferrals, and release gates are unchanged.
|
||||
@@ -30,7 +30,10 @@
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-api</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-language-support-javascript</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-postgres</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-mongodb</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-sqlite</artifactId></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.scripts.ExecuteCodeAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.scripts.ExecuteCodeOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType;
|
||||
|
||||
/** Execute a fixed, side-effect-free JavaScript calculation through QQQ. */
|
||||
public class CalculateOrderTotalStep implements BackendStep
|
||||
{
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Integer quantity = input.getValueInteger("quantity");
|
||||
Integer unitPriceCents = input.getValueInteger("unitPriceCents");
|
||||
if(quantity == null || quantity < 1 || quantity > 1000
|
||||
|| unitPriceCents == null || unitPriceCents < 0 || unitPriceCents > 1_000_000)
|
||||
{
|
||||
throw new QException("Supply quantity 1..1000 and unitPriceCents 0..1000000");
|
||||
}
|
||||
ExecuteCodeOutput result = new ExecuteCodeOutput();
|
||||
new ExecuteCodeAction().run(new ExecuteCodeInput()
|
||||
.withCodeReference(new QCodeReference().withCodeType(QCodeType.JAVA_SCRIPT)
|
||||
.withInlineCode("return quantity * unitPriceCents;"))
|
||||
.withInput(Map.of("quantity", quantity, "unitPriceCents", unitPriceCents)), result);
|
||||
output.addValue("totalCents", ((Number) result.getOutput()).intValue());
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ final class CoreRuntime implements AutoCloseable
|
||||
Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath();
|
||||
String brokerUrl = "tcp://127.0.0.1:" + brokerPort;
|
||||
DemoOrderWebhookReceiver.clear();
|
||||
DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl).defineQInstance(), port);
|
||||
DemoQBitData.ensureSchema(dataDirectory, new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost).defineQInstance(), port);
|
||||
Configuration configuration = new ConfigurationImpl()
|
||||
.setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false)
|
||||
.setBindingsDirectory(brokerDirectory.resolve("bindings").toString())
|
||||
@@ -58,7 +58,7 @@ final class CoreRuntime implements AutoCloseable
|
||||
try
|
||||
{
|
||||
QApplicationLauncher launcher = QApplicationLauncher.run(
|
||||
new QqqAllApplication(dataDirectory, brokerUrl),
|
||||
new QqqAllApplication(dataDirectory, brokerUrl, true, port, bindHost),
|
||||
new QApplicationLauncherConfig().withRegisterShutdownHook(false)
|
||||
.withServerCustomizer(server -> server.withPort(port)
|
||||
.withJavalinConfigCustomizer(config -> config.jetty.host = bindHost)));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.actions.BaseAPIActionUtil;
|
||||
|
||||
/** The local catalog supports ascending ID pages and optional exact ID selection. */
|
||||
public class DemoCatalogApi extends BaseAPIActionUtil
|
||||
{
|
||||
@Override
|
||||
public String buildUrlSuffixForSingleRecordGet(Serializable primaryKey) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return "/" + Integer.parseInt(String.valueOf(primaryKey));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw new QException("Invalid catalog ID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Integer getApiStandardLimit()
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String buildQueryStringForGet(QQueryFilter filter, Integer limit, Integer skip, Map<String, QFieldMetaData> fields) throws QException
|
||||
{
|
||||
String query = "?limit=" + (limit == null ? 2 : limit) + "&skip=" + (skip == null ? 0 : skip);
|
||||
if(filter != null)
|
||||
{
|
||||
if(!filter.getSubFilters().isEmpty() || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND
|
||||
|| filter.getCriteria().size() > 1 || filter.getOrderBys().stream().anyMatch(order -> !"id".equals(order.getFieldName()) || !order.getIsAscending()))
|
||||
{
|
||||
throw new QException("Demo catalog supports ascending ID order and one exact ID filter only");
|
||||
}
|
||||
if(!filter.getCriteria().isEmpty())
|
||||
{
|
||||
var criterion = filter.getCriteria().getFirst();
|
||||
if(!"id".equals(criterion.getFieldName()) || criterion.getOperator() != QCriteriaOperator.EQUALS || criterion.getValues().size() != 1)
|
||||
{
|
||||
throw new QException("Demo catalog supports one exact ID filter only");
|
||||
}
|
||||
try
|
||||
{
|
||||
query += "&id=" + Integer.parseInt(String.valueOf(criterion.getValues().getFirst()));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw new QException("Invalid catalog ID", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CountOutput doCount(QTableMetaData table, CountInput input) throws QException
|
||||
{
|
||||
QQueryFilter filter = input.getFilter() == null ? new QQueryFilter() : input.getFilter().clone();
|
||||
filter.setLimit(null);
|
||||
filter.setSkip(null);
|
||||
return new CountOutput().withCount(doQuery(table, new QueryInput(table.getName()).withFilter(filter)).getRecords().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import static io.javalin.apibuilder.ApiBuilder.get;
|
||||
|
||||
/** Synthetic public supplier data, owned by the same server as the reference app. */
|
||||
public class DemoCatalogRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final List<Map<String, Object>> CATALOG = List.of(
|
||||
Map.of("id", 101, "name", "Starter Kit", "unitPriceCents", 2500),
|
||||
Map.of("id", 102, "name", "Refill Pack", "unitPriceCents", 1250),
|
||||
Map.of("id", 103, "name", "Shipping Box", "unitPriceCents", 300));
|
||||
|
||||
@Override
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
}
|
||||
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.apiBuilder(() ->
|
||||
{
|
||||
get("/demo/catalog/{id}", context ->
|
||||
{
|
||||
try
|
||||
{
|
||||
int id = Integer.parseInt(context.pathParam("id"));
|
||||
var row = CATALOG.stream().filter(item -> Integer.valueOf(id).equals(item.get("id"))).findFirst();
|
||||
if(row.isPresent())
|
||||
{
|
||||
context.json(row.get());
|
||||
}
|
||||
else
|
||||
{
|
||||
context.status(404);
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
context.status(400);
|
||||
}
|
||||
});
|
||||
get("/demo/catalog", context ->
|
||||
{
|
||||
try
|
||||
{
|
||||
int skip = Integer.parseInt(context.queryParamAsClass("skip", String.class).getOrDefault("0"));
|
||||
int limit = Integer.parseInt(context.queryParamAsClass("limit", String.class).getOrDefault("2"));
|
||||
Integer id = context.queryParam("id") == null ? null : Integer.valueOf(context.queryParam("id"));
|
||||
if(skip < 0 || limit < 1 || limit > 1000)
|
||||
{
|
||||
context.status(400);
|
||||
return;
|
||||
}
|
||||
context.json(CATALOG.stream().filter(row -> id == null || id.equals(row.get("id")))
|
||||
.skip(skip).limit(limit).toList());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
context.status(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionOutputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.api.model.AuthorizationType;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APIBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.api.model.metadata.APITableBackendDetails;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
|
||||
/** Shared, local-only demonstrations included in both profiles. */
|
||||
final class DemoRuntimeFeatures
|
||||
{
|
||||
private DemoRuntimeFeatures()
|
||||
{
|
||||
}
|
||||
|
||||
static void add(QInstance instance, int port, String bindHost) throws QException
|
||||
{
|
||||
instance.addBackend(new APIBackendMetaData().withName("demoApi")
|
||||
.withBaseUrl(catalogBaseUrl(bindHost, port))
|
||||
.withAuthorizationType(AuthorizationType.NONE).withContentType("application/json")
|
||||
.withActionUtil(new QCodeReference(DemoCatalogApi.class)));
|
||||
instance.addTable(new QTableMetaData().withName("apiCatalog").withLabel("API Catalog")
|
||||
.withBackendName("demoApi").withPrimaryKeyField("id")
|
||||
.withBackendDetails(new APITableBackendDetails().withTablePath("/catalog"))
|
||||
.withCapabilities(Capability.TABLE_QUERY, Capability.TABLE_GET, Capability.TABLE_COUNT)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("unitPriceCents", QFieldType.INTEGER)));
|
||||
instance.addBackend(new QBackendMetaData().withName("transientMemory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName("demoNote").withLabel("Demo Note (Transient)")
|
||||
.withBackendName("transientMemory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("text", QFieldType.STRING)));
|
||||
instance.withRuntimeService(new QCodeReference(TransientNotesService.class));
|
||||
instance.addProcess(new QProcessMetaData().withName("calculateOrderTotal").withLabel("Calculate Order Total (JavaScript)")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
|
||||
.withStep(new QBackendStepMetaData().withName("calculate")
|
||||
.withCode(new QCodeReference(CalculateOrderTotalStep.class))
|
||||
.withInputData(new QFunctionInputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("quantity", QFieldType.INTEGER),
|
||||
new QFieldMetaData("unitPriceCents", QFieldType.INTEGER))))
|
||||
.withOutputMetaData(new QFunctionOutputMetaData().withFieldList(List.of(
|
||||
new QFieldMetaData("totalCents", QFieldType.INTEGER))))));
|
||||
QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(new QCodeReference(DemoCatalogRoutes.class));
|
||||
}
|
||||
|
||||
private static String catalogBaseUrl(String bindHost, int port) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
InetAddress address = InetAddress.getByName(bindHost);
|
||||
String host = address.isAnyLocalAddress()
|
||||
? (address instanceof Inet6Address ? "::1" : "127.0.0.1") : bindHost;
|
||||
return new URI("http", null, host, port, "/demo", null, null).toASCIIString();
|
||||
}
|
||||
catch(UnknownHostException | URISyntaxException e)
|
||||
{
|
||||
throw new QException("Could not derive demo catalog URL from HTTP bind host", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
session.withPermissions("apiCatalog.read", "demoNote.read");
|
||||
Boolean admin = ADMIN_SESSION.equals(session.getUuid());
|
||||
String name = admin ? "Admin" : "Demo User";
|
||||
String userId = admin ? "core:admin" : "core:demo";
|
||||
@@ -76,6 +77,7 @@ public class DemoUsers implements QAuthenticationModuleCustomizerInterface
|
||||
}
|
||||
if(admin)
|
||||
{
|
||||
session.withPermissions("calculateOrderTotal.hasAccess", "demoNote.insert", "demoNote.edit", "demoNote.delete");
|
||||
session.withPermissions("esbView.hasAccess", "esbOperate.hasAccess", "esbDelete.hasAccess");
|
||||
for(String processName : WEBHOOK_PROCESSES)
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.kingsrook.qqq.backend.module.filesystem.sftp.model.metadata.SFTPTable
|
||||
import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBTableBackendDetails;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import com.kingsrook.qqq.esb.model.EsbDestinationType;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
@@ -66,7 +67,7 @@ final class FullProfileApplication extends AbstractQQQApplication
|
||||
@Override
|
||||
public QInstance defineQInstance() throws QException
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false).defineQInstance();
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, settings.get("ARTEMIS_URL"), false, settings.port("PORT", "8080"), settings.get("BIND_HOST")).defineQInstance();
|
||||
addBackends(instance);
|
||||
addTables(instance);
|
||||
addAuthentication(instance);
|
||||
@@ -111,13 +112,9 @@ final class FullProfileApplication extends AbstractQQQApplication
|
||||
|
||||
private void addBackends(QInstance instance)
|
||||
{
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("postgres").withVendor("postgres")
|
||||
instance.addBackend(new PostgreSQLBackendMetaData().withName("postgres")
|
||||
.withHostName(settings.get("POSTGRES_HOST")).withPort(settings.port("POSTGRES_PORT", "5432"))
|
||||
.withDatabaseName(settings.get("POSTGRES_DATABASE"))
|
||||
.withJdbcUrl("jdbc:postgresql://" + settings.get("POSTGRES_HOST") + ":"
|
||||
+ settings.port("POSTGRES_PORT", "5432") + "/" + settings.get("POSTGRES_DATABASE"))
|
||||
.withJdbcDriverClassName("org.postgresql.Driver")
|
||||
.withActionStrategyCodeReference(new QCodeReference(PostgresActionStrategy.class))
|
||||
.withUsername(settings.get("POSTGRES_USER")).withPassword(settings.get("POSTGRES_PASSWORD")));
|
||||
instance.addBackend(new RDBMSBackendMetaData().withName("mysql").withVendor("mysql")
|
||||
.withHostName(settings.get("MYSQL_HOST")).withPort(settings.port("MYSQL_PORT", "3306"))
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.strategy.BaseRDBMSActionStrategy;
|
||||
|
||||
/** PostgreSQL syntax for the shared QQQ RDBMS actions. */
|
||||
public class PostgresActionStrategy extends BaseRDBMSActionStrategy
|
||||
{
|
||||
@Override
|
||||
public String getIdentifierQuoteString()
|
||||
{
|
||||
return "\"";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getInsertDefaultValuesClause()
|
||||
{
|
||||
return "DEFAULT VALUES";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int bindParamObject(PreparedStatement statement, int index, Object value) throws SQLException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
statement.setNull(index, Types.NULL);
|
||||
return 1;
|
||||
}
|
||||
return super.bindParamObject(statement, index, value);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ public final class QqqAllApplication extends AbstractQQQApplication
|
||||
private final Path dataDirectory;
|
||||
private final String brokerUrl;
|
||||
private final boolean includeDemoQbits;
|
||||
private final int httpPort;
|
||||
private final String bindHost;
|
||||
|
||||
public QqqAllApplication(Path dataDirectory, String brokerUrl)
|
||||
{
|
||||
@@ -66,6 +68,18 @@ public final class QqqAllApplication extends AbstractQQQApplication
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits)
|
||||
{
|
||||
this(dataDirectory, brokerUrl, includeDemoQbits, 8080);
|
||||
}
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort)
|
||||
{
|
||||
this(dataDirectory, brokerUrl, includeDemoQbits, httpPort, "127.0.0.1");
|
||||
}
|
||||
|
||||
QqqAllApplication(Path dataDirectory, String brokerUrl, boolean includeDemoQbits, int httpPort, String bindHost)
|
||||
{
|
||||
this.bindHost = bindHost;
|
||||
this.httpPort = httpPort;
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.brokerUrl = brokerUrl;
|
||||
this.includeDemoQbits = includeDemoQbits;
|
||||
@@ -152,6 +166,7 @@ public final class QqqAllApplication extends AbstractQQQApplication
|
||||
QJavalinMetaData.ofOrWithNew(instance)
|
||||
.withAdditionalRouteProviderReference(new QCodeReference(DemoOrderWebhookReceiver.class));
|
||||
}
|
||||
DemoRuntimeFeatures.add(instance, httpPort, bindHost);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/** Seed and clear only the application's transient memory table. */
|
||||
public class TransientNotesService implements QRuntimeServiceInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(TransientNotesService.class);
|
||||
private QInstance instance;
|
||||
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "transientDemoNotes";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(QInstance instance) throws QException
|
||||
{
|
||||
this.instance = instance;
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
clearNotes();
|
||||
new InsertAction().execute(new InsertInput("demoNote").withRecords(List.of(
|
||||
new QRecord().withValue("id", 1).withValue("text", "Transient demo note"))));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
clearNotes();
|
||||
}
|
||||
catch(Exception cleanup)
|
||||
{
|
||||
failure.addSuppressed(cleanup);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
clearNotes();
|
||||
}
|
||||
catch(Exception failure)
|
||||
{
|
||||
LOG.warn("Could not clear transient demo notes", failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearNotes() throws QException
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput("demoNote").withQueryFilter(new QQueryFilter()));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -39,28 +41,56 @@ class CoreJarIT
|
||||
Path log = dataDirectory.resolve("app.log");
|
||||
ProcessBuilder builder = new ProcessBuilder(
|
||||
Path.of(System.getProperty("java.home"), "bin", "java").toString(), "-jar", jar.toString());
|
||||
builder.environment().put("QQQ_ALL_PROFILE", "core");
|
||||
builder.environment().put("QQQ_ALL_PORT", port.toString());
|
||||
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("data").toString());
|
||||
builder.redirectErrorStream(true).redirectOutput(log.toFile());
|
||||
Process process = builder.start();
|
||||
try
|
||||
{
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
URI healthUri = URI.create("http://127.0.0.1:" + port + "/health");
|
||||
HttpResponse<String> health = awaitHealth(client, healthUri, process, log);
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"),
|
||||
"Core profile must bind only to loopback");
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
URI healthUri = URI.create("http://127.0.0.1:" + port + "/health");
|
||||
HttpResponse<String> health = awaitHealth(client, healthUri, process, log);
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("\"UP\""));
|
||||
assertTrue(Files.readString(log).contains("{127.0.0.1:" + port + "}"),
|
||||
"Core profile must bind only to loopback");
|
||||
|
||||
HttpRequest query = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build();
|
||||
HttpResponse<String> customers = client.send(query, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, customers.statusCode(), customers.body());
|
||||
assertTrue(customers.body().contains("Ada Lovelace"));
|
||||
HttpRequest query = HttpRequest.newBuilder()
|
||||
.uri(URI.create("http://127.0.0.1:" + port + "/qqq/v1/table/customer/query"))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build();
|
||||
HttpResponse<String> customers = client.send(query, HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, customers.statusCode(), customers.body());
|
||||
assertTrue(customers.body().contains("Ada Lovelace"));
|
||||
for(String table : java.util.List.of("apiCatalog", "demoNote"))
|
||||
{
|
||||
var result = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/qqq/v1/table/" + table + "/query")).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, result.statusCode(), result.body());
|
||||
var records = new org.json.JSONObject(result.body()).getJSONArray("records");
|
||||
assertEquals(table.equals("apiCatalog") ? 3 : 1, records.length());
|
||||
}
|
||||
var calculation = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"))
|
||||
.header("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, calculation.statusCode(), calculation.body());
|
||||
assertEquals(3750, new org.json.JSONObject(calculation.body()).getJSONObject("values").getInt("totalCents"));
|
||||
}
|
||||
try(var packaged = new java.util.jar.JarFile(jar.toFile()))
|
||||
{
|
||||
var service = packaged.getJarEntry("META-INF/services/javax.script.ScriptEngineFactory");
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(service);
|
||||
try(var input = packaged.getInputStream(service))
|
||||
{
|
||||
assertTrue(new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)
|
||||
.contains("org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory"));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -73,6 +103,69 @@ class CoreJarIT
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"::1", "::", "0:0:0:0:0:0:0:0", "0.0.0.0"})
|
||||
void packagedCatalogUsesConfiguredListener(String bindHost) throws Exception
|
||||
{
|
||||
String clientHost = bindHost.contains(":") ? "::1" : "127.0.0.1";
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0, 50, java.net.InetAddress.getByName(clientHost)))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
URI base = new URI("http", null, clientHost, port, "/", null, null);
|
||||
Path log = dataDirectory.resolve("bound-app.log");
|
||||
ProcessBuilder builder = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
|
||||
"-jar", System.getProperty("qqqAllJar"));
|
||||
builder.environment().put("QQQ_ALL_PROFILE", "core");
|
||||
builder.environment().put("QQQ_ALL_BIND_HOST", bindHost);
|
||||
builder.environment().put("QQQ_ALL_PORT", Integer.toString(port));
|
||||
builder.environment().put("QQQ_ALL_DATA_DIR", dataDirectory.resolve("bound-data").toString());
|
||||
builder.redirectErrorStream(true).redirectOutput(log.toFile());
|
||||
Process process = builder.start();
|
||||
try(HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build())
|
||||
{
|
||||
assertEquals(200, awaitHealth(client, base.resolve("health"), process, log).statusCode());
|
||||
var direct = client.send(HttpRequest.newBuilder(base.resolve("demo/catalog"))
|
||||
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, direct.statusCode(), "Configured listener must serve its catalog before testing the backend");
|
||||
assertEquals(2, new org.json.JSONArray(direct.body()).length());
|
||||
|
||||
var all = catalogQuery(client, base, "{}");
|
||||
assertEquals(java.util.List.of(101, 102, 103), all.toList().stream()
|
||||
.map(row -> ((java.util.Map<?, ?>) ((java.util.Map<?, ?>) row).get("values")).get("id")).toList());
|
||||
for(int skip = 0; skip < 3; skip++)
|
||||
{
|
||||
var page = catalogQuery(client, base, "{\"filter\":{\"skip\":" + skip + ",\"limit\":1}}");
|
||||
assertEquals(1, page.length());
|
||||
assertEquals(java.util.List.of(101, 102, 103).get(skip), page.getJSONObject(0).getJSONObject("values").getInt("id"));
|
||||
}
|
||||
assertEquals(0, catalogQuery(client, base, "{\"filter\":{\"skip\":3,\"limit\":1}}").length());
|
||||
var get = client.send(HttpRequest.newBuilder(base.resolve("data/apiCatalog/103"))
|
||||
.timeout(Duration.ofSeconds(10)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, get.statusCode(), get.body());
|
||||
assertEquals("Shipping Box", new org.json.JSONObject(get.body()).getJSONObject("values").getString("name"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.destroy();
|
||||
if(!process.waitFor(5, TimeUnit.SECONDS))
|
||||
{
|
||||
process.destroyForcibly();
|
||||
assertTrue(process.waitFor(5, TimeUnit.SECONDS), "Owned packaged process must stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static org.json.JSONArray catalogQuery(HttpClient client, URI base, String body) throws Exception
|
||||
{
|
||||
var response = client.send(HttpRequest.newBuilder(base.resolve("qqq/v1/table/apiCatalog/query"))
|
||||
.timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, response.statusCode(), response.body());
|
||||
return new org.json.JSONObject(response.body()).getJSONArray("records");
|
||||
}
|
||||
|
||||
private static HttpResponse<String> awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
|
||||
|
||||
@@ -45,6 +45,7 @@ class FullProfileTest
|
||||
new QInstanceValidator().validate(instance);
|
||||
|
||||
RDBMSBackendMetaData postgres = (RDBMSBackendMetaData) instance.getBackend("postgres");
|
||||
assertEquals("com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData", postgres.getClass().getName());
|
||||
assertEquals("postgres.example.test", postgres.getHostName());
|
||||
assertEquals("jdbc:postgresql://postgres.example.test:5432/qqq", ConnectionManager.getJdbcUrl(postgres));
|
||||
assertEquals("org.postgresql.Driver", ConnectionManager.getJdbcDriverClassName(postgres));
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Opt-in native PostgreSQL oracle; owns only its uniquely named table. */
|
||||
@EnabledIfEnvironmentVariable(named = "QQQ_ALL_TEST_POSTGRES_PORT", matches = "[0-9]+")
|
||||
@Timeout(30)
|
||||
class PostgresCompatibilityTest
|
||||
{
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void nativeStrategyPreservesGeneratedIdsNullsAndUtcTimestamps() throws Exception
|
||||
{
|
||||
var environment = FullProfileTest.environment();
|
||||
environment.put("QQQ_ALL_POSTGRES_HOST", "127.0.0.1");
|
||||
environment.put("QQQ_ALL_POSTGRES_PORT", System.getenv("QQQ_ALL_TEST_POSTGRES_PORT"));
|
||||
environment.put("QQQ_ALL_POSTGRES_DATABASE", "qqq");
|
||||
environment.put("QQQ_ALL_POSTGRES_USER", "qqq");
|
||||
environment.put("QQQ_ALL_POSTGRES_PASSWORD", System.getenv("QQQ_ALL_TEST_POSTGRES_PASSWORD"));
|
||||
var instance = new FullProfileApplication(directory, FullProfileSettings.from(environment)).defineQInstance();
|
||||
var backend = (RDBMSBackendMetaData) instance.getBackend("postgres");
|
||||
String table = "compat_" + UUID.randomUUID().toString().replace("-", "");
|
||||
instance.addTable(new QTableMetaData().withName("compatibility").withBackendName("postgres")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("value", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("created", QFieldType.DATE_TIME)));
|
||||
instance.addTable(new QTableMetaData().withName("compatibilityDefault").withBackendName("postgres")
|
||||
.withBackendDetails(new RDBMSTableBackendDetails().withTableName(table)).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
var previous = QContext.capture();
|
||||
try(var connection = DriverManager.getConnection(ConnectionManager.getJdbcUrl(backend), backend.getUsername(), backend.getPassword());
|
||||
var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("CREATE TABLE " + table + " (id SERIAL PRIMARY KEY, value TEXT, created TIMESTAMP)");
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
var empty = new InsertAction().execute(new InsertInput("compatibilityDefault").withRecords(List.of(new QRecord()))).getRecords().getFirst();
|
||||
assertNotNull(empty.getValueInteger("id"));
|
||||
Instant timestamp = Instant.parse("2026-09-25T12:34:56Z");
|
||||
var row = new InsertAction().execute(new InsertInput("compatibility").withRecords(List.of(
|
||||
new QRecord().withValue("value", "demo").withValue("created", timestamp)))).getRecords().getFirst();
|
||||
assertTrue(row.getValueInteger("id") > empty.getValueInteger("id"));
|
||||
new UpdateAction().execute(new UpdateInput("compatibility").withRecords(List.of(
|
||||
new QRecord().withValue("id", row.getValueInteger("id")).withValue("value", null))));
|
||||
assertNull(GetAction.execute("compatibility", row.getValueInteger("id")).getValue("value"));
|
||||
try(var result = statement.executeQuery("SELECT value, created FROM " + table + " WHERE id = " + row.getValueInteger("id")))
|
||||
{
|
||||
assertTrue(result.next());
|
||||
assertNull(result.getString(1));
|
||||
assertEquals(timestamp, result.getTimestamp(2, java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))).toInstant());
|
||||
assertFalse(result.next());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
statement.execute("DROP TABLE " + table);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import javax.script.ScriptEngineManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/** The reference application must ship its advertised backend and language. */
|
||||
class RuntimeAssemblyTest
|
||||
{
|
||||
@Test
|
||||
void includesApiBackendAndJavaScriptEngine()
|
||||
{
|
||||
assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.backend.module.api.APIBackendModule"));
|
||||
assertDoesNotThrow(() -> Class.forName("com.kingsrook.qqq.languages.javascript.QJavaScriptExecutor"));
|
||||
assertNotNull(new ScriptEngineManager().getEngineByName("JavaScript"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* 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
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.esb.connection.EsbConnectionManager;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Exercise the assembled demo against its real local HTTP backend. */
|
||||
@Timeout(45)
|
||||
class RuntimeFeaturesTest
|
||||
{
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@AfterEach
|
||||
void closeOwnedBrokerConnections()
|
||||
{
|
||||
EsbConnectionManager.getInstance().closeAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiCatalogQueriesGetsAndPaginates() throws Exception
|
||||
{
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getTable("apiCatalog"));
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals(List.of(101, 102, 103), new QueryAction().execute(new QueryInput("apiCatalog")).getRecords()
|
||||
.stream().map(record -> record.getValueInteger("id")).toList());
|
||||
assertEquals(102, new QueryAction().execute(new QueryInput("apiCatalog")
|
||||
.withFilter(new QQueryFilter().withSkip(1).withLimit(1))).getRecords().getFirst().getValueInteger("id"));
|
||||
assertEquals("Shipping Box", GetAction.execute("apiCatalog", 103).getValueString("name"));
|
||||
assertNull(GetAction.execute("apiCatalog", 999));
|
||||
assertEquals(3, new CountAction().execute(new CountInput("apiCatalog")).getCount());
|
||||
var exactId = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, 103));
|
||||
assertEquals(1, new CountAction().execute(new CountInput("apiCatalog").withFilter(exactId)).getCount());
|
||||
assertEquals(List.of(103), new QueryAction().execute(new QueryInput("apiCatalog").withFilter(exactId)).getRecords()
|
||||
.stream().map(record -> record.getValueInteger("id")).toList());
|
||||
assertThrows(QException.class, () -> new QueryAction().execute(new QueryInput("apiCatalog")
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "Starter Kit")))));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculationUsesRealJavaScriptAndRequiresPermission() throws Exception
|
||||
{
|
||||
int port = freePort();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, port); HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getProcess("calculateOrderTotal"));
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port
|
||||
+ "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"))
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}"));
|
||||
assertEquals(403, client.send(request.header("Cookie", "sessionId=" + DemoUsers.DEMO_SESSION).build(), HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
var allowed = client.send(request.setHeader("Cookie", "sessionId=" + DemoUsers.ADMIN_SESSION).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, allowed.statusCode(), allowed.body());
|
||||
assertEquals(3750, new org.json.JSONObject(allowed.body()).getJSONObject("values").getInt("totalCents"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculationRejectsMissingNegativeAndExcessiveInputs()
|
||||
{
|
||||
for(Integer quantity : java.util.Arrays.asList(null, 0, -1, 1001))
|
||||
{
|
||||
var input = new RunBackendStepInput().addValue("quantity", quantity).addValue("unitPriceCents", 1250);
|
||||
assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput()));
|
||||
}
|
||||
for(Integer price : java.util.Arrays.asList(null, -1, 1_000_001))
|
||||
{
|
||||
var input = new RunBackendStepInput().addValue("quantity", 3).addValue("unitPriceCents", price);
|
||||
assertThrows(QException.class, () -> new CalculateOrderTotalStep().run(input, new RunBackendStepOutput()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void memoryNotesResetAcrossRuntimeRestart() throws Exception
|
||||
{
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
assertNotNull(runtime.getLauncher().getQInstance().getTable("demoNote"));
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
new UpdateAction().execute(new UpdateInput("demoNote").withRecords(List.of(new QRecord().withValue("id", 1).withValue("text", "Only this run"))));
|
||||
assertEquals("Only this run", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
closeOwnedBrokerConnections();
|
||||
try(CoreRuntime runtime = CoreRuntime.launch(directory, freePort()))
|
||||
{
|
||||
QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession());
|
||||
try
|
||||
{
|
||||
assertEquals("Transient demo note", GetAction.execute("demoNote", 1).getValueString("text"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int freePort() throws Exception
|
||||
{
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -81,7 +81,7 @@ class SmokeClient:
|
||||
self.cookies = http.cookiejar.CookieJar()
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies))
|
||||
|
||||
def request(self, path, body=None, method=None):
|
||||
def request(self, path, body=None, method=None, expected_status=200):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
if self.session_id:
|
||||
@@ -92,12 +92,16 @@ class SmokeClient:
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
if response.status != expected_status:
|
||||
raise AssertionError(f"{path}: HTTP {response.status}")
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status = error.code
|
||||
error.close()
|
||||
try:
|
||||
if status == expected_status:
|
||||
return error.read()
|
||||
finally:
|
||||
error.close()
|
||||
raise AssertionError(f"{path}: HTTP {status}") from None
|
||||
|
||||
def json(self, path, body=None):
|
||||
@@ -197,9 +201,31 @@ def check_artemis_round_trip(client, timeout):
|
||||
return process_uuid
|
||||
|
||||
|
||||
def check_runtime_features(client, can_calculate=True):
|
||||
records = client.records("apiCatalog")
|
||||
if [record.get("values", {}).get("id") for record in records] != [101, 102, 103]:
|
||||
raise AssertionError("API catalog did not traverse all pages")
|
||||
record = client.json("/data/apiCatalog/103")
|
||||
if record.get("values", {}).get("name") != "Shipping Box":
|
||||
raise AssertionError("API catalog get returned incorrect record")
|
||||
client.require_record("demoNote", "text", "Transient demo note")
|
||||
path = "/processes/calculateOrderTotal/run?quantity=3&unitPriceCents=1250"
|
||||
if can_calculate:
|
||||
result = client.json(path, {})
|
||||
if result.get("values", {}).get("totalCents") != 3750:
|
||||
raise AssertionError("JavaScript calculation returned incorrect total")
|
||||
else:
|
||||
client.request(path, {}, expected_status=403)
|
||||
client.request("/data/demoNote/1", {"text": "forbidden"}, method="PATCH", expected_status=403)
|
||||
client.request("/data/apiCatalog/", {"id": 999, "name": "forbidden"}, expected_status=403)
|
||||
print("API catalog, transient memory, JavaScript permissions: OK")
|
||||
|
||||
|
||||
def check_core(client, timeout):
|
||||
check_health_and_dashboard(client, timeout)
|
||||
check_tables(client, CORE_TABLES)
|
||||
check_runtime_features(client)
|
||||
check_runtime_features(SmokeClient(client.base_url, "dddddddd-dddd-4ddd-8ddd-dddddddddddd"), False)
|
||||
check_artemis_round_trip(client, timeout)
|
||||
|
||||
|
||||
@@ -312,9 +338,11 @@ def check_full(base_url, keycloak_url, query_index, timeout):
|
||||
check_health_and_dashboard(viewer, timeout)
|
||||
oidc_login(viewer, keycloak_url, "demo-user", os.environ["DEMO_USER_PASSWORD"])
|
||||
viewer.require_record("customer", "name", "Ada Lovelace")
|
||||
check_runtime_features(viewer, False)
|
||||
admin = SmokeClient(base_url)
|
||||
oidc_login(admin, keycloak_url, "demo-admin", os.environ["DEMO_ADMIN_PASSWORD"])
|
||||
check_tables(admin, FULL_TABLES, require_seed=False)
|
||||
check_runtime_features(admin)
|
||||
check_quick_search(admin, query_index, os.environ.get("QQQ_ALL_OPENSEARCH_INDEX", "qqq-all-customers"), timeout)
|
||||
process_uuid = check_artemis_round_trip(admin, timeout)
|
||||
check_rabbitmq(admin, timeout, process_uuid)
|
||||
|
||||
+31
-1
@@ -19,7 +19,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from smoke import SmokeClient, check_artemis_round_trip, check_core, check_rabbitmq, check_tables, CORE_TABLES
|
||||
from smoke import SmokeClient, check_artemis_round_trip, check_core, check_rabbitmq, check_tables, check_runtime_features, CORE_TABLES
|
||||
import run_full_smoke
|
||||
from run_full_smoke import ROOT
|
||||
|
||||
@@ -42,6 +42,8 @@ class DemoHandler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/data/apiCatalog/103":
|
||||
return self.send_json({"values": {"id": 103, "name": "Shipping Box"}})
|
||||
if self.path == "/health":
|
||||
return self.send_json({"status": "UP"})
|
||||
if self.path == "/":
|
||||
@@ -62,6 +64,12 @@ class DemoHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
if self.path == "/data/apiCatalog/":
|
||||
return self.send_error(403)
|
||||
if self.path.startswith("/processes/calculateOrderTotal/run"):
|
||||
if "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" not in self.headers.get("Cookie", ""):
|
||||
return self.send_error(403)
|
||||
return self.send_json({"values": {"totalCents": 3750}})
|
||||
if self.path == "/data/order/":
|
||||
assert body["orderNo"].startswith("SMOKE-")
|
||||
type(self).orders += 1
|
||||
@@ -70,6 +78,8 @@ class DemoHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json({"records": [{"values": {"id": 100 + self.orders}}]})
|
||||
if self.path.startswith("/qqq/v1/table/") and self.path.endswith("/query"):
|
||||
table = self.path.split("/")[4]
|
||||
if table == "apiCatalog":
|
||||
return self.send_json({"records": [{"values": {"id": i}} for i in (101, 102, 103)]})
|
||||
if table == "processTrace":
|
||||
records = [{"recordLabel": self.trace_label,
|
||||
"values": {"id": i, "processUUID": f"trace-{i}",
|
||||
@@ -77,6 +87,7 @@ class DemoHandler(BaseHTTPRequestHandler):
|
||||
for i in range(self.orders)]
|
||||
else:
|
||||
values = {
|
||||
"demoNote": {"id": 1, "text": "Transient demo note"},
|
||||
"customer": {"id": 1, "name": "Ada Lovelace"},
|
||||
"order": {"id": 1, "orderNo": "ORD-1001"},
|
||||
"orderLine": {"id": 1, "quantity": 2},
|
||||
@@ -89,6 +100,11 @@ class DemoHandler(BaseHTTPRequestHandler):
|
||||
return self.send_json({"records": records})
|
||||
self.send_error(404)
|
||||
|
||||
def do_PATCH(self):
|
||||
if self.path == "/data/demoNote/1":
|
||||
return self.send_error(403)
|
||||
self.send_error(404)
|
||||
|
||||
def send_json(self, value):
|
||||
data = json.dumps(value).encode()
|
||||
self.send_response(200)
|
||||
@@ -104,6 +120,19 @@ class SmokeTest(unittest.TestCase):
|
||||
DemoHandler.trace_order_id = None
|
||||
DemoHandler.inserted_marker = None
|
||||
|
||||
def test_catalog_missing_final_page_fails(self):
|
||||
client = mock.Mock()
|
||||
client.records.return_value = [{"values": {"id": value}} for value in (101, 102)]
|
||||
with self.assertRaisesRegex(AssertionError, "all pages"):
|
||||
check_runtime_features(client)
|
||||
|
||||
def test_wrong_javascript_result_fails(self):
|
||||
client = mock.Mock()
|
||||
client.records.return_value = [{"values": {"id": value}} for value in (101, 102, 103)]
|
||||
client.json.side_effect = [{"values": {"name": "Shipping Box"}}, {"values": {"totalCents": 3749}}]
|
||||
with self.assertRaisesRegex(AssertionError, "incorrect total"):
|
||||
check_runtime_features(client)
|
||||
|
||||
def test_core_runner_fails_promptly_when_jar_exits(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
jar = Path(directory) / "broken.jar"
|
||||
@@ -294,6 +323,7 @@ class SmokeTest(unittest.TestCase):
|
||||
thread.start()
|
||||
try:
|
||||
client = SmokeClient(f"http://127.0.0.1:{server.server_port}")
|
||||
client.session_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
check_core(client, timeout=2)
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
Reference in new issue
Block a user