feat(#21): complete runtime demos and backend modules #22

Merged
KofTwentyTwo merged 3 commits from feature/GH-21-runtime-completion into feature/GH-8-ci-smoke 2026-09-30 15:58:38 +00:00
21 changed files with 934 additions and 67 deletions

No files matched your search

+1 -1
View File
@@ -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;
+61
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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()