diff --git a/qqq-all-app/README.md b/qqq-all-app/README.md new file mode 100644 index 0000000..0fb0c20 --- /dev/null +++ b/qqq-all-app/README.md @@ -0,0 +1,12 @@ +# Core reference app + +Build with Java 21 and Maven, then start the standalone core profile: + +```sh +mvn -pl qqq-all-app -am verify +java -jar qqq-all-app/target/qqq-all-app-4.1.0-SNAPSHOT.jar +``` + +The core app binds to `127.0.0.1` and serves the Next dashboard at `http://127.0.0.1:8080/` and health at `/health`. Set `QQQ_ALL_PORT` to change the port and `QQQ_ALL_DATA_DIR` to change the default `./data` directory. On first start it seeds customers and orders in H2, order lines in SQLite, and products as JSON files. Artemis runs in the same JVM; inserts and updates to `order` publish to the `orderEvents` topic and trigger `syncOrder`. + +Core uses mock authentication. The `sessionId` cookie selects the two fixed demo identities: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` for Admin and `dddddddd-dddd-4ddd-8ddd-dddddddddddd` for Demo User. Any other session ID uses Demo User. These identities are for local demonstrations; role-based permissions are wired in task C5. diff --git a/qqq-all-app/pom.xml b/qqq-all-app/pom.xml index 5c8db9a..0912da7 100644 --- a/qqq-all-app/pom.xml +++ b/qqq-all-app/pom.xml @@ -16,4 +16,76 @@ QQQ All - Reference Application Reference QQQ application with every backend, qBit, and the ESB wired, plus seeded demo data. + + + + com.kingsrook.qqq + qqq-all-bom + ${project.version} + pom + import + + + + + + com.kingsrook.qqqqqq-backend-core + com.kingsrook.qqqqqq-backend-module-rdbms + com.kingsrook.qqqqqq-backend-module-sqlite + com.kingsrook.qqqqqq-backend-module-filesystem + com.kingsrook.qqqqqq-middleware-javalin + com.kingsrook.qqqqqq-middleware-health + com.kingsrook.qqqqqq-esb + com.kingsrook.qqqqqq-frontend-next + com.h2databaseh2 + org.xerialsqlite-jdbc + org.apache.artemisartemis-jakarta-server2.57.0 + org.junit.jupiterjunit-jupiter6.0.2test + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + shade + + + + + com.kingsrook.qqq.all.QqqAllApplication + + + + + *:* + META-INF/*.SFMETA-INF/*.DSAMETA-INF/*.RSA + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.6 + + + ${project.build.directory}/${project.build.finalName}.jar + + + + + integration-testverify + + + + + + diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreData.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreData.java new file mode 100644 index 0000000..ac2b0eb --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreData.java @@ -0,0 +1,63 @@ +/* + * 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.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +/** Creates the small, repeatable core demo data set before the web server starts. */ +final class CoreData +{ + private CoreData() + { + } + + static String h2DatabaseName(Path dataDirectory) + { + return dataDirectory.resolve("h2/qqq-all").toAbsolutePath().toString(); + } + + static Path sqlitePath(Path dataDirectory) + { + return dataDirectory.resolve("sqlite/qqq-all.db"); + } + + static void seed(Path dataDirectory) throws IOException, SQLException + { + Files.createDirectories(dataDirectory.resolve("h2")); + Files.createDirectories(dataDirectory.resolve("sqlite")); + Files.createDirectories(dataDirectory.resolve("files/products")); + + try(Connection connection = DriverManager.getConnection( + "jdbc:h2:file:" + h2DatabaseName(dataDirectory) + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + Statement statement = connection.createStatement()) + { + statement.execute("CREATE TABLE IF NOT EXISTS customer (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(120), email VARCHAR(200))"); + statement.execute("CREATE TABLE IF NOT EXISTS orders (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, order_no VARCHAR(40), customer_id INTEGER, status VARCHAR(40))"); + statement.execute("MERGE INTO customer (id, name, email) KEY (id) VALUES (1, 'Ada Lovelace', 'ada@example.test'), (2, 'Grace Hopper', 'grace@example.test')"); + statement.execute("MERGE INTO orders (id, order_no, customer_id, status) KEY (id) VALUES (1, 'ORD-1001', 1, 'NEW')"); + } + + try(Connection connection = DriverManager.getConnection("jdbc:sqlite:" + sqlitePath(dataDirectory)); + Statement statement = connection.createStatement()) + { + statement.execute("CREATE TABLE IF NOT EXISTS order_line (id INTEGER PRIMARY KEY, order_id INTEGER, product_id INTEGER, quantity INTEGER)"); + statement.execute("INSERT OR IGNORE INTO order_line (id, order_id, product_id, quantity) VALUES (1, 1, 1, 2)"); + } + + Path product = dataDirectory.resolve("files/products/1.json"); + if(Files.notExists(product)) + { + Files.writeString(product, "{\"id\":1,\"name\":\"QRun Starter Kit\",\"price\":29.00}\n"); + } + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreEsbRuntimeService.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreEsbRuntimeService.java new file mode 100644 index 0000000..f2f3281 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreEsbRuntimeService.java @@ -0,0 +1,33 @@ +/* + * 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 com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.esb.runtime.QEsbRuntime; + +/** Launches the ESB subscriber through the common QQQ application launcher. */ +public class CoreEsbRuntimeService implements QRuntimeServiceInterface +{ + @Override + public String getName() + { + return "coreEsb"; + } + + @Override + public void start(QInstance instance) + { + QEsbRuntime.getInstance().start(instance); + } + + @Override + public void stop() + { + QEsbRuntime.getInstance().stop(); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java new file mode 100644 index 0000000..1492c66 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/CoreRuntime.java @@ -0,0 +1,94 @@ +/* + * 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.IOException; +import java.net.ServerSocket; +import java.nio.file.Path; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncher; +import com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; + +/** Owns the embedded broker and launcher as one closeable core profile. */ +final class CoreRuntime implements AutoCloseable +{ + private final EmbeddedActiveMQ broker; + private final QApplicationLauncher launcher; + private final Thread shutdownHook; + + private CoreRuntime(EmbeddedActiveMQ broker, QApplicationLauncher launcher) + { + this.broker = broker; + this.launcher = launcher; + this.shutdownHook = new Thread(this::close, "qqq-all-core-shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + + static CoreRuntime launch(Path dataDirectory, Integer port) throws Exception + { + CoreData.seed(dataDirectory); + Integer brokerPort = findFreePort(); + Path brokerDirectory = dataDirectory.resolve("artemis").toAbsolutePath(); + String brokerUrl = "tcp://127.0.0.1:" + brokerPort; + Configuration configuration = new ConfigurationImpl() + .setPersistenceEnabled(false).setSecurityEnabled(false).setJMXManagementEnabled(false) + .setBindingsDirectory(brokerDirectory.resolve("bindings").toString()) + .setJournalDirectory(brokerDirectory.resolve("journal").toString()) + .setPagingDirectory(brokerDirectory.resolve("paging").toString()) + .setLargeMessagesDirectory(brokerDirectory.resolve("large-messages").toString()) + .addAcceptorConfiguration("tcp", brokerUrl); + EmbeddedActiveMQ broker = new EmbeddedActiveMQ().setConfiguration(configuration); + broker.start(); + try + { + QApplicationLauncher launcher = QApplicationLauncher.run( + new QqqAllApplication(dataDirectory, brokerUrl), + new QApplicationLauncherConfig().withRegisterShutdownHook(false) + .withServerCustomizer(server -> server.withPort(port) + .withJavalinConfigCustomizer(config -> config.jetty.host = "127.0.0.1"))); + return new CoreRuntime(broker, launcher); + } + catch(Exception | LinkageError e) + { + broker.stop(); + throw e; + } + } + + private static Integer findFreePort() throws IOException + { + try(ServerSocket socket = new ServerSocket(0)) + { + return socket.getLocalPort(); + } + } + + QApplicationLauncher getLauncher() + { + return launcher; + } + + @Override + public void close() + { + launcher.stop(); + try + { + broker.stop(); + } + catch(Exception e) + { + throw new IllegalStateException("Could not stop embedded Artemis", e); + } + if(Thread.currentThread() != shutdownHook) + { + Runtime.getRuntime().removeShutdownHook(shutdownHook); + } + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java new file mode 100644 index 0000000..cfb90d9 --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/DemoUsers.java @@ -0,0 +1,31 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; + +/** Fixed identities for local mock authentication; the session cookie selects one. */ +public class DemoUsers implements QAuthenticationModuleCustomizerInterface +{ + public static final String ADMIN_SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + public static final String DEMO_SESSION = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + + @Override + public void customizeSession(QInstance instance, QSession session, Map context) + { + Boolean admin = ADMIN_SESSION.equals(session.getUuid()); + String name = admin ? "Admin" : "Demo User"; + String userId = admin ? "core:admin" : "core:demo"; + session.getUser().setIdReference(userId); + session.getUser().setFullName(name); + session.withValueForFrontend("user", new LinkedHashMap<>(Map.of("name", name, "email", userId + "@example.test"))); + } +} diff --git a/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java new file mode 100644 index 0000000..9eec1bd --- /dev/null +++ b/qqq-all-app/src/main/java/com/kingsrook/qqq/all/QqqAllApplication.java @@ -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.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +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.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +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.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.Cardinality; +import com.kingsrook.qqq.backend.module.filesystem.base.model.metadata.RecordFormat; +import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemBackendMetaData; +import com.kingsrook.qqq.backend.module.filesystem.local.model.metadata.FilesystemTableBackendDetails; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails; +import com.kingsrook.qqq.backend.module.sqlite.model.metadata.SQLiteBackendMetaData; +import com.kingsrook.qqq.esb.model.EsbDestinationType; +import com.kingsrook.qqq.esb.model.EsbInstanceMetaData; +import com.kingsrook.qqq.esb.model.EsbProcessMetaData; +import com.kingsrook.qqq.esb.model.EsbProviderType; +import com.kingsrook.qqq.esb.model.EsbTableEvent; +import com.kingsrook.qqq.esb.model.EsbTableMetaData; +import com.kingsrook.qqq.esb.model.EsbTablePublication; +import com.kingsrook.qqq.esb.model.EsbTrigger; +import com.kingsrook.qqq.esb.model.QEsbDestinationMetaData; +import com.kingsrook.qqq.esb.model.QEsbProviderMetaData; +import com.kingsrook.qqq.middleware.health.JavalinHealthRouteProvider; +import com.kingsrook.qqq.middleware.health.indicators.MemoryHealthIndicator; +import com.kingsrook.qqq.middleware.health.model.metadata.HealthCheckMetaData; +import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; + +/** The self-contained core QQQ reference application. */ +public final class QqqAllApplication extends AbstractQQQApplication +{ + private final Path dataDirectory; + private final String brokerUrl; + + public QqqAllApplication(Path dataDirectory, String brokerUrl) + { + this.dataDirectory = dataDirectory; + this.brokerUrl = brokerUrl; + } + + public static void main(String[] args) throws Exception + { + Path dataDirectory = Path.of(System.getenv().getOrDefault("QQQ_ALL_DATA_DIR", "./data")); + Integer port = Integer.valueOf(System.getenv().getOrDefault("QQQ_ALL_PORT", "8080")); + CoreRuntime.launch(dataDirectory, port); + } + + @Override + public QInstance defineQInstance() throws QException + { + QInstance instance = new QInstance().withFailOnMetaDataProducerError(true); + instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData() + .withName("mock").withType(QAuthenticationType.MOCK) + .withCustomizer(new QCodeReference(DemoUsers.class))); + + instance.addBackend(new RDBMSBackendMetaData() + .withName("h2").withVendor("h2").withHostName("file") + .withDatabaseName(CoreData.h2DatabaseName(dataDirectory)).withUsername("sa")); + instance.addBackend(new SQLiteBackendMetaData().withName("sqlite") + .withPath(CoreData.sqlitePath(dataDirectory).toAbsolutePath().toString())); + instance.addBackend(new FilesystemBackendMetaData().withName("files") + .withBasePath(dataDirectory.resolve("files").toAbsolutePath().toString())); + + instance.addTable(new QTableMetaData().withName("customer").withLabel("Customer") + .withBackendName("h2").withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("email", QFieldType.STRING))); + instance.addTable(new QTableMetaData().withName("order").withLabel("Order") + .withBackendName("h2").withBackendDetails(new RDBMSTableBackendDetails().withTableName("orders")) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("orderNo", QFieldType.STRING).withBackendName("order_no")) + .withField(new QFieldMetaData("customerId", QFieldType.INTEGER).withBackendName("customer_id")) + .withField(new QFieldMetaData("status", QFieldType.STRING)) + .withSupplementalMetaData(new EsbTableMetaData().withPublication(new EsbTablePublication() + .withDestinationName("orderEvents").withEvents(List.of(EsbTableEvent.INSERT, EsbTableEvent.UPDATE))))); + instance.addTable(new QTableMetaData().withName("orderLine").withLabel("Order Line") + .withBackendName("sqlite").withBackendDetails(new RDBMSTableBackendDetails().withTableName("order_line")) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("orderId", QFieldType.INTEGER).withBackendName("order_id")) + .withField(new QFieldMetaData("productId", QFieldType.INTEGER).withBackendName("product_id")) + .withField(new QFieldMetaData("quantity", QFieldType.INTEGER))); + instance.addTable(new QTableMetaData().withName("product").withLabel("Product") + .withBackendName("files").withPrimaryKeyField("id") + .withBackendDetails(new FilesystemTableBackendDetails() + .withBasePath("products").withRecordFormat(RecordFormat.JSON) + .withCardinality(Cardinality.MANY).withGlob("*.json")) + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("price", QFieldType.DECIMAL))); + + instance.addProcess(new QProcessMetaData().withName("syncOrder").withLabel("Sync Order") + .withTableName("order") + .withStep(new QBackendStepMetaData().withName("sync") + .withCode(new QCodeReference(SyncOrderStep.class))) + .withSupplementalMetaData(new EsbProcessMetaData() + .withTrigger(new EsbTrigger().withDestinationName("orderEvents")))); + + EsbInstanceMetaData.of(instance).withInstanceName("qqqAll") + .withProvider(new QEsbProviderMetaData().withName("artemis") + .withType(EsbProviderType.ACTIVEMQ_ARTEMIS).withUrl(brokerUrl)) + .withDestination(new QEsbDestinationMetaData().withName("orderEvents") + .withType(EsbDestinationType.TOPIC).withProviderName("artemis") + .withDestinationName("qqq.all.orderEvents")); + instance.withRuntimeService(new QCodeReference(CoreEsbRuntimeService.class)); + + instance.withSupplementalMetaData(new HealthCheckMetaData().withEnabled(true) + .withEndpointPath("/health").withIndicators(List.of(new MemoryHealthIndicator().withThreshold(99)))); + QJavalinMetaData.ofOrWithNew(instance) + .withAdditionalRouteProviderReference(new QCodeReference(JavalinHealthRouteProvider.class)); + return instance; + } + + /** A visible effect for the demo and for the ESB integration test. */ + public static final class SyncOrderStep implements BackendStep + { + private static final AtomicInteger RUN_COUNT = new AtomicInteger(); + + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + RUN_COUNT.incrementAndGet(); + } + + public static Integer getRunCount() + { + return RUN_COUNT.get(); + } + + public static void reset() + { + RUN_COUNT.set(0); + } + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java new file mode 100644 index 0000000..de521f7 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreApplicationTest.java @@ -0,0 +1,102 @@ +/* + * 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.time.Duration; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +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.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; +import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule; +import com.kingsrook.qqq.esb.runtime.EsbTriggerState; +import com.kingsrook.qqq.esb.runtime.QEsbRuntime; +import org.junit.jupiter.api.Test; +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.assertTrue; + +/** Exercises the assembled core application, including its real broker and backends. */ +class CoreApplicationTest +{ + @TempDir + Path dataDirectory; + + @Test + void bootsQueriesEveryTableAndProcessesOrderEvents() throws Exception + { + Integer port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + + QqqAllApplication.SyncOrderStep.reset(); + try(CoreRuntime runtime = CoreRuntime.launch(dataDirectory, port)) + { + HttpClient client = HttpClient.newHttpClient(); + HttpResponse health = client.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/health")).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, health.statusCode()); + assertTrue(health.body().contains("\"UP\"")); + + HttpResponse dashboard = client.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/")).build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, dashboard.statusCode()); + assertTrue(dashboard.body().contains("html")); + + QContext.init(runtime.getLauncher().getQInstance(), new QSystemUserSession()); + try + { + MockAuthenticationModule mockAuth = new MockAuthenticationModule(); + assertEquals("core:admin", mockAuth.createSession(runtime.getLauncher().getQInstance(), + Map.of("sessionId", DemoUsers.ADMIN_SESSION)).getUser().getIdReference()); + assertEquals("core:demo", mockAuth.createSession(runtime.getLauncher().getQInstance(), + Map.of("sessionId", DemoUsers.DEMO_SESSION)).getUser().getIdReference()); + + for(String table : List.of("customer", "order", "orderLine", "product")) + { + assertFalse(new QueryAction().execute(new QueryInput(table)).getRecords().isEmpty(), table); + } + + waitFor(() -> QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents") != null + && QEsbRuntime.getInstance().getRunner("syncOrder.orderEvents").getState() == EsbTriggerState.RUNNING); + new InsertAction().execute(new InsertInput("order").withRecords(List.of( + new QRecord().withValue("orderNo", "ORD-1002") + .withValue("customerId", 2).withValue("status", "NEW")))); + waitFor(() -> QqqAllApplication.SyncOrderStep.getRunCount() > 0); + } + finally + { + QContext.clear(); + } + } + } + + private static void waitFor(java.util.function.BooleanSupplier condition) throws Exception + { + long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + while(!condition.getAsBoolean() && System.nanoTime() < deadline) + { + Thread.sleep(50); + } + assertTrue(condition.getAsBoolean()); + } +} diff --git a/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java new file mode 100644 index 0000000..1724904 --- /dev/null +++ b/qqq-all-app/src/test/java/com/kingsrook/qqq/all/CoreJarIT.java @@ -0,0 +1,92 @@ +/* + * 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.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Exercises the shaded entry point and its HTTP API after packaging. */ +class CoreJarIT +{ + @TempDir + Path dataDirectory; + + @Test + void packagedApplicationServesSeededData() throws Exception + { + Integer port; + try(ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + + Path jar = Path.of(System.getProperty("qqqAllJar")); + 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_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 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 customers = client.send(query, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, customers.statusCode(), customers.body()); + assertTrue(customers.body().contains("Ada Lovelace")); + } + finally + { + process.destroy(); + if(!process.waitFor(5, TimeUnit.SECONDS)) + { + process.destroyForcibly(); + process.waitFor(); + } + } + } + + private static HttpResponse awaitHealth(HttpClient client, URI uri, Process process, Path log) throws Exception + { + long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos(); + while(System.nanoTime() < deadline && process.isAlive()) + { + try + { + return client.send(HttpRequest.newBuilder().uri(uri).build(), HttpResponse.BodyHandlers.ofString()); + } + catch(java.net.ConnectException e) + { + Thread.sleep(100); + } + } + throw new AssertionError("Packaged app did not become healthy. Output:\n" + Files.readString(log)); + } +}