Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4d5029691
|
||
|
|
8ef66f6efe | ||
|
|
1705c73b06
|
||
|
|
ae5c00c589 | ||
|
|
87768d3dd3 | ||
|
|
832c628298 | ||
|
|
b4c7ba4697
|
||
|
|
6af1362668
|
||
|
|
af9eefe311
|
||
|
|
a9550d82b4
|
||
|
|
3f7e67df4b | ||
|
|
a6887f7ea2
|
||
|
|
bce0f7ff53 |
No files matched your search
+9
-1
@@ -2,14 +2,22 @@
|
||||
|
||||
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
|
||||
|
||||
## Release note correction (1.1.0-RC.11)
|
||||
|
||||
1.1.0-RC.11 (Maven Central, 2026-10-15 13:05Z) was built from release/1.1 `2e2501d` and contains #61 only. The `v1.1.0-RC.11` tag mistakenly points at `6028a66`; use `2e2501d` as RC.11's source. Cluster caches (#62) first ship in 1.1.0-RC.12.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Requires QQQ 4.1.0-RC.8 or later (central health checks, help bundles and the spec-managed UI API extension contract).
|
||||
Requires QQQ 4.1.0-RC.15 or later (central health checks, help bundles, the spec-managed UI API extension contract, and `ClusterCaches` for multi-node deployments).
|
||||
|
||||
### Fixed
|
||||
- Priming config records at startup, and recording diagnostic events, build their system session with the instance, so it has its all-access keys. Startup logged "QInstance was not supplied when creating a QSystemUserSession" once per node.
|
||||
- Multi-node deployments: a Quick Search config change (an index row, its fields or the settings row, from the admin UI or a config process) now clears the cached config on every node, not only the node that made the change. The runtime's config cache (the config store snapshot and settings, and the per-table enabled flags) is registered with QQQ `ClusterCaches` as `qbit-quick-search.config`, and `QuickSearchRuntime.invalidateConfig()` publishes a cluster invalidation. The 30 s and 60 s TTLs remain as the safety net. `QuickSearchFieldRules`' warn-once table set is registered as node-local. `QuickSearchClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
|
||||
- Indexing bookkeeping is no longer record-audited: the `quick_search_index` runtime fields written by basepull, full reindex and real-time indexing (status, last basepull time, document count, last run, real-time error count) and the settings row's priming outcome (`lastPrimedAt`, summary) are written with `omitDmlAudit`. On a production host these wrote about 470 "Record was Edited during process: Update search index" audit rows an hour. Admin edits to an index, its fields or the settings are still audited.
|
||||
- Real-time indexing failures now increase the table's error count by the number of failed events. Successful basepulls and full reindexes reset the count. Counter update failures are logged without failing the source write.
|
||||
|
||||
### Added
|
||||
- UI API extension quick-search 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated QuickSearchErrorV1 schema. Existing operationIds and success payloads are preserved.
|
||||
- Central health checks `quick_search.opensearch`, `quick_search.backlog` and the dynamic `quick_search.index.<indexName>` supplier, all SEARCH/DEPENDENCY checks every 60 seconds. Reuses cached diagnostics with deadline-bounded waits and safe count/flag details. Includes registration, state, deadline and credential-sentinel tests.
|
||||
- Host-neutral `help/qbit-quick-search.yaml` bundle, registered through QQQ help bundles, with coverage and no-help-table tests. Replaces the temporary inline help loader; hosts can override entries with a HOST bundle.
|
||||
- Every `quick_search_*` table and the Quick Search Admin app carry help that says what the table is for and how its rows get created (`help/quick-search.yaml`, QQQ help-bundle format, applied by `QuickSearchHelp` as a stand-in for core help bundles; host help is never overridden). `QuickSearchTablesGuardrailTest` fails when a table is unused by runtime code, neither primed nor listed as starting empty, or has no such help; `QuickSearchSeededDefaultsTest` proves priming populates the config tables on a fresh backend, is idempotent and keeps admin edits.
|
||||
|
||||
@@ -358,7 +358,7 @@ String homeHref = status.homeHref();
|
||||
```
|
||||
|
||||
UI clients use **`GET /qqq/v1/qbit/quick-search/status`**, operationId
|
||||
`getQuickSearchStatus`, extension `quick-search` version `1.0.0`. The endpoint returns
|
||||
`getQuickSearchStatus`, extension `quick-search` version `1.1.0` (requires QQQ `4.1.0-RC.15`, UI API `1.6.0`). The endpoint returns
|
||||
that same immutable value as JSON; it requires the Quick Search app's configured
|
||||
`adminPermissionRules` (default `quick_search_admin.hasAccess`). Permission is checked
|
||||
before every read, including cache hits. No additional source-table permissions are
|
||||
|
||||
@@ -34,7 +34,7 @@ group has `tableName`, `tableLabel`, `hasMore` and `items` of `recordId`, `label
|
||||
and nullable `subtitle`. See the [API details](../README.md#typeahead-suggestions)
|
||||
for normalization, bounded-batch semantics and empty responses during outages.
|
||||
|
||||
Extension: `quick-search`, version `1.0.0`; core dependency: `4.1.0-RC.6`.
|
||||
Extension: `quick-search`, version `1.1.0`; core dependency: `4.1.0-RC.15` (UI API `1.6.0`).
|
||||
The requested `/quick-search` prefix cannot validate on the supplied RC.6 baseline:
|
||||
`MiddlewareSpecExtensionsMetaData.ESTABLISHED_PREFIXES` only admits esb, alerts,
|
||||
assistant, and sql-workbench. We use the default namespace without modifying core.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# TODO
|
||||
|
||||
## UI API 1.6 migration (2026-10-15)
|
||||
|
||||
- [x] Bump `quick-search` extension to 1.1.0, retain deprecated `QuickSearchErrorV1`, and use core `BasicErrorResponseV1` for errors; preserve operation IDs and success payloads.
|
||||
- [x] Regenerate the two-operation contract with `-Dquicksearch.openapi.update=true`, then pass the contract test without regeneration; no dangling references or separate baseline were found.
|
||||
- [x] Set the release BOM default to QQQ `4.1.0-RC.15`; validate offline against the locally installed `4.1.99-SNAPSHOT` using `./mvnw.cmd -o test -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps` (540 tests, no failures/errors/skips). The command-line exception is needed only because the requested local core is a SNAPSHOT; other guardrails remain enabled.
|
||||
- [x] Verify strengthened HTTP error-body assertions: 8 status/HTTP tests passed after the full suite.
|
||||
- [ ] Run Docker integration tests through `verify` in CI; this migration ran the requested `test` lifecycle.
|
||||
|
||||
|
||||
## In-app help bundle (2026-10-14)
|
||||
|
||||
- [x] Pin QQQ RC.7, register the host-neutral QBIT bundle, replace the temporary loader, document HOST overrides, and add coverage/no-help-table checks.
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -23,6 +23,8 @@ import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
@@ -34,6 +36,7 @@ import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.SynchronousIndexEventPublisher;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -70,6 +73,17 @@ public class QuickSearchRuntime
|
||||
|
||||
private static final long ENABLED_CACHE_TTL_MILLIS = 60_000L;
|
||||
|
||||
/*******************************************************************************
|
||||
** Cluster cache name for every runtime's config cache (the config store's
|
||||
** snapshot and settings, and the enabled flags). A config row edited on
|
||||
** one node clears it on every node; the TTLs remain the safety net.
|
||||
*******************************************************************************/
|
||||
public static final String CONFIG_CACHE_NAME = "qbit-quick-search.config";
|
||||
|
||||
private static final Set<QuickSearchRuntime> RUNTIMES = ClusterCaches.shared(CONFIG_CACHE_NAME,
|
||||
Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())),
|
||||
QuickSearchRuntime::invalidateAllLocal, null);
|
||||
|
||||
public static final long RECORD_SEARCH_BACKOFF_MILLIS = 30_000L;
|
||||
|
||||
private final StatusCache statusCache = new StatusCache();
|
||||
@@ -128,6 +142,7 @@ public class QuickSearchRuntime
|
||||
{
|
||||
this.config = config;
|
||||
this.configStore = new QuickSearchConfigStore(this);
|
||||
RUNTIMES.add(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -261,9 +276,21 @@ public class QuickSearchRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop every cached config (a config row changed).
|
||||
** Drop every cached config (a config row changed): on this node now, and
|
||||
** on the other nodes through the cluster bus.
|
||||
*******************************************************************************/
|
||||
public void invalidateConfig()
|
||||
{
|
||||
invalidateConfigLocal();
|
||||
ClusterCaches.invalidate(CONFIG_CACHE_NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop this runtime's cached config on this node only.
|
||||
*******************************************************************************/
|
||||
void invalidateConfigLocal()
|
||||
{
|
||||
configStore.invalidate();
|
||||
enabledCache.clear();
|
||||
@@ -271,6 +298,25 @@ public class QuickSearchRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cached config of every live runtime on this node (a cluster
|
||||
** invalidation arrived).
|
||||
*******************************************************************************/
|
||||
private static void invalidateAllLocal()
|
||||
{
|
||||
List<QuickSearchRuntime> runtimes;
|
||||
synchronized(RUNTIMES)
|
||||
{
|
||||
runtimes = new ArrayList<>(RUNTIMES);
|
||||
}
|
||||
for(QuickSearchRuntime runtime : runtimes)
|
||||
{
|
||||
runtime.invalidateConfigLocal();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return the OpenSearch client, building it on first use. Building the
|
||||
** client does not contact the cluster; {@link #ensureIndexReady()} does.
|
||||
@@ -422,6 +468,16 @@ public class QuickSearchRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many enabled flags are cached (for tests).
|
||||
*******************************************************************************/
|
||||
int getCachedEnabledFlagCount()
|
||||
{
|
||||
return (enabledCache.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget cached enabled flags (after an operator toggles a table).
|
||||
*******************************************************************************/
|
||||
@@ -702,7 +758,7 @@ public class QuickSearchRuntime
|
||||
{
|
||||
return;
|
||||
}
|
||||
QContext.init(qInstance, new QSystemUserSession());
|
||||
QContext.init(qInstance, new QSystemUserSession(qInstance));
|
||||
try
|
||||
{
|
||||
configStore.tryPrime();
|
||||
@@ -741,6 +797,7 @@ public class QuickSearchRuntime
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
RUNTIMES.remove(this);
|
||||
statusCache.close();
|
||||
|
||||
if(publisher != null)
|
||||
|
||||
@@ -48,7 +48,7 @@ public final class QuickSearchApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.1.0")
|
||||
.withDescription("Quick Search administration and suggestions").withSpec(QuickSearchStatusSpec.class).withSpec(QuickSearchSuggestSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -103,9 +104,9 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", "QuickSearchErrorV1")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +148,8 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
|
||||
row.put("lastError", field(Type.STRING, "Persisted indexing error; null when absent.", true));
|
||||
row.put("realTimeErrorCount", field(Type.INTEGER, "Persisted real-time indexing error count.", true));
|
||||
return (Map.of("QuickSearchStatusV1", object(status), "QuickSearchIndexStatusV1", object(row),
|
||||
"QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false)))));
|
||||
"QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false)))
|
||||
.withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
@@ -115,9 +116,9 @@ public class QuickSearchSuggestSpec extends AbstractJsonEndpointSpec
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", "QuickSearchErrorV1")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -412,8 +412,12 @@ public class QuickSearchConfigPrimer
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Only the priming outcome (lastPrimedAt, summary) changes here, on //
|
||||
// every startup: bookkeeping, not a settings change, so not audited. //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
values.withValue("id", rows.get(0).getValue("id"));
|
||||
updateAll(settingsTable, List.of(values));
|
||||
updateAll(settingsTable, List.of(values), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +481,17 @@ public class QuickSearchConfigPrimer
|
||||
** Update rows, failing on any record error.
|
||||
*******************************************************************************/
|
||||
public static void updateAll(String tableName, List<QRecord> records) throws QException
|
||||
{
|
||||
updateAll(tableName, records, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update rows, failing on any record error; omitAudit skips the DML audit
|
||||
** for runtime bookkeeping writes.
|
||||
*******************************************************************************/
|
||||
public static void updateAll(String tableName, List<QRecord> records, boolean omitAudit) throws QException
|
||||
{
|
||||
if(records.isEmpty())
|
||||
{
|
||||
@@ -485,6 +500,7 @@ public class QuickSearchConfigPrimer
|
||||
UpdateInput updateInput = new UpdateInput();
|
||||
updateInput.setTableName(tableName);
|
||||
updateInput.setRecords(records);
|
||||
updateInput.setOmitDmlAudit(omitAudit);
|
||||
for(QRecord record : CollectionUtils.nonNullList(new UpdateAction().execute(updateInput).getRecords()))
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
|
||||
@@ -60,8 +60,9 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** before 1.1.0-RC.3.
|
||||
**
|
||||
** Reads are cached for CACHE_TTL_MILLIS and dropped at once by the config
|
||||
** tables' customizers when a row changes on this server; other servers pick
|
||||
** the change up when their cache expires.
|
||||
** tables' customizers when a row changes, on every server: the runtime
|
||||
** publishes a cluster cache invalidation (QuickSearchRuntime.CONFIG_CACHE_NAME).
|
||||
** The TTL remains the safety net when the cluster bus is down.
|
||||
*******************************************************************************/
|
||||
public class QuickSearchConfigStore
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
@@ -59,7 +60,8 @@ import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldRole;
|
||||
public final class QuickSearchFieldRules
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QuickSearchFieldRules.class);
|
||||
private static final WeakHashMap<QTableMetaData, Boolean> WARNED_TABLES = new WeakHashMap<>();
|
||||
private static final WeakHashMap<QTableMetaData, Boolean> WARNED_TABLES = ClusterCaches.nodeLocal(new WeakHashMap<>(),
|
||||
"warn-once log de-duplication for this node's table metadata objects");
|
||||
|
||||
public static final int MAX_DEFAULT_FIELDS = 20;
|
||||
public static final int TITLE_WEIGHT = 3;
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ public class DiagnosticEventRecorder implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private void insert(QInstance qInstance, String tableName, QuickSearchDiagnosticEvent event)
|
||||
{
|
||||
QSession session = new QSystemUserSession();
|
||||
QSession session = new QSystemUserSession(qInstance);
|
||||
try
|
||||
{
|
||||
QContext.init(qInstance, session);
|
||||
|
||||
+2
@@ -334,6 +334,8 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
|
||||
updateInput.setTableName(indexTable);
|
||||
updateInput.setRecords(List.of(new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("realTimeErrorCount", Math.addExact(previous == null ? 0 : previous, entry.getValue()))));
|
||||
// A runtime counter, not a configuration change: not record-audited.
|
||||
updateInput.setOmitDmlAudit(true);
|
||||
List<QRecord> updated = QuickSearchConfigWrites.asSystem(() -> new UpdateAction().execute(updateInput)).getRecords();
|
||||
if(CollectionUtils.nullSafeIsEmpty(updated) || CollectionUtils.nullSafeHasContents(updated.get(0).getErrors()))
|
||||
{
|
||||
|
||||
@@ -493,7 +493,10 @@ public abstract class AbstractIndexingStep implements BackendStep
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update fields on a quick_search_index row.
|
||||
** Update runtime state on a quick_search_index row (status, last run, last
|
||||
** basepull time, document count). Indexing bookkeeping, not a configuration
|
||||
** change: it is not record-audited, or every basepull tick would write an
|
||||
** audit row. Admin edits to the row go through the table and stay audited.
|
||||
*******************************************************************************/
|
||||
protected void updateIndexRow(Integer indexId, Map<String, ? extends Serializable> values) throws QException
|
||||
{
|
||||
@@ -506,6 +509,7 @@ public abstract class AbstractIndexingStep implements BackendStep
|
||||
UpdateInput updateInput = new UpdateInput();
|
||||
updateInput.setTableName(getConfig().getQuickSearchIndexTableName());
|
||||
updateInput.setRecords(List.of(record));
|
||||
updateInput.setOmitDmlAudit(true);
|
||||
QuickSearchConfigWrites.asSystem(() -> new UpdateAction().execute(updateInput));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
components:
|
||||
schemas:
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
QuickSearchErrorV1:
|
||||
description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
|
||||
properties:
|
||||
error:
|
||||
description: "Framework error message."
|
||||
@@ -194,15 +234,48 @@ components:
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Quick Search administration and suggestions"
|
||||
title: "QQQ Middleware API extension: quick-search"
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/quick-search/status:
|
||||
@@ -217,19 +290,19 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Diagnostic rate limit exceeded"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Quick Search admin permission required"
|
||||
200:
|
||||
content:
|
||||
@@ -237,10 +310,37 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchStatusV1"
|
||||
description: "Successful Response"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Get Quick Search status"
|
||||
tags:
|
||||
- "quick-search"
|
||||
/qqq/v1/qbit/quick-search/suggest:
|
||||
get:
|
||||
description: "Requires a signed-in session, not Quick Search admin permission.\
|
||||
@@ -290,20 +390,20 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Missing q, normalized term longer than 100 characters, or\
|
||||
\ invalid limits"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Session permission denied"
|
||||
200:
|
||||
content:
|
||||
@@ -311,7 +411,40 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchSuggestV1"
|
||||
description: "Successful Response"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found, or not visible to this user (code NOT_FOUND)."
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Get Quick Search suggestions"
|
||||
tags:
|
||||
- "quick-search"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the quick-search extension."
|
||||
name: "quick-search"
|
||||
@@ -53,11 +53,14 @@ class QuickSearchApiContractTest
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"mvnw.cmd -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch"));
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch") || name.equals("BasicErrorResponseV1"));
|
||||
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder("/qqq/v1/qbit/quick-search/status", "/qqq/v1/qbit/quick-search/suggest");
|
||||
assertThat(extension.getSpecs()).hasSize(2);
|
||||
assertThat(generated).contains("getQuickSearchSuggestions", "QuickSearchSuggestV1", "QuickSearchSuggestGroupV1", "QuickSearchSuggestItemV1");
|
||||
var schemas = model.getComponents().getSchemas();
|
||||
assertThat(schemas.get("BasicErrorResponseV1").getProperties()).containsOnlyKeys("error", "code", "message", "fieldErrors", "traceId");
|
||||
assertThat(schemas.get("QuickSearchErrorV1").getDescription()).isEqualTo("Deprecated since 1.1.0: errors use BasicErrorResponseV1.");
|
||||
assertThat(generated).contains("getQuickSearchStatus").doesNotContain("#/components/schemas/QuickSearchErrorV1");
|
||||
assertThat(schemas.get("QuickSearchSuggestV1").getProperties()).containsOnlyKeys("query", "groups", "truncated");
|
||||
assertThat(schemas.get("QuickSearchSuggestGroupV1").getProperties()).containsOnlyKeys("tableName", "tableLabel", "hasMore", "items");
|
||||
assertThat(schemas.get("QuickSearchSuggestItemV1").getProperties()).containsOnlyKeys("recordId", "label", "subtitle");
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.quicksearch;
|
||||
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBusInterface;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterBusMetaData;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterCacheAudit;
|
||||
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
|
||||
import com.kingsrook.qqq.backend.core.cluster.QNodeRoles;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexField;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Quick Search caches are correct on several nodes: every static cache is
|
||||
** registered with ClusterCaches, and a config change on one node clears the
|
||||
** config cache on the others.
|
||||
*******************************************************************************/
|
||||
class QuickSearchClusterCachesTest extends BaseQuickSearchTest
|
||||
{
|
||||
/** the "wire": events every node's transport sees */
|
||||
static final List<Consumer<ClusterEvent>> WIRE = new CopyOnWriteArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A transport over the static WIRE, so a "remote" publish reaches us.
|
||||
*******************************************************************************/
|
||||
public static class WireBus implements ClusterBusInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void publish(ClusterEvent event)
|
||||
{
|
||||
WIRE.forEach(listener -> listener.accept(event));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AutoCloseable subscribe(Consumer<ClusterEvent> listener)
|
||||
{
|
||||
WIRE.add(listener);
|
||||
return (() -> WIRE.remove(listener));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void afterEach()
|
||||
{
|
||||
ClusterBus.close(QContext.getQInstance());
|
||||
WIRE.clear();
|
||||
QNodeRoles.set(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Connect the current instance to the wire as node-a.
|
||||
*******************************************************************************/
|
||||
private static void connectAsNodeA()
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
instance.withSupplementalMetaData(new ClusterBusMetaData().withBus(new QCodeReference(WireBus.class)));
|
||||
QNodeRoles.set(new QNodeRoles.Settings(EnumSet.allOf(QNodeRoles.Role.class), true, true, true, null, "node-a"));
|
||||
ClusterBus.connect(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A runtime with one code-declared table.
|
||||
*******************************************************************************/
|
||||
private static QuickSearchRuntime newRuntime()
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// without the field table the config comes from code, so the enabled //
|
||||
// flag is read from the index row and cached //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
QContext.getQInstance().getTables().remove(QuickSearchIndexField.TABLE_NAME);
|
||||
QContext.getQInstance().getTable(QuickSearchIndex.TABLE_NAME).getAssociations().clear();
|
||||
|
||||
QuickSearchRuntime runtime = new QuickSearchRuntime(new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withOpensearchHost("localhost").withOpensearchPort(9200).withOpensearchIndexName("t"));
|
||||
runtime.setDiscoveredTables(List.of(new QuickSearchableTableConfig().withTableName(TEST_ENTITY_TABLE).withPrimaryKeyField("id").withSearchableFields(List.of("name"))));
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every static Memoization in the QBit is registered (shared or node-local).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNoUnregisteredStaticCaches() throws Exception
|
||||
{
|
||||
assertThat(ClusterCacheAudit.unregisteredStaticCaches("com.kingsrook.qbits.quicksearch")).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Another node's config invalidation clears this node's cached config.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRemoteInvalidationClearsConfigCache()
|
||||
{
|
||||
connectAsNodeA();
|
||||
QuickSearchRuntime runtime = newRuntime();
|
||||
try
|
||||
{
|
||||
runtime.isTableEnabled(TEST_ENTITY_TABLE);
|
||||
assertThat(runtime.getCachedEnabledFlagCount()).isEqualTo(1);
|
||||
|
||||
ClusterEvent remote = new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, QuickSearchRuntime.CONFIG_CACHE_NAME, null);
|
||||
remote.setOriginNodeId("node-b");
|
||||
WIRE.forEach(listener -> listener.accept(remote));
|
||||
|
||||
assertThat(runtime.getCachedEnabledFlagCount()).isZero();
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A config change here clears the cache here and tells the other nodes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testInvalidateConfigPublishesToOtherNodes()
|
||||
{
|
||||
connectAsNodeA();
|
||||
List<ClusterEvent> sent = new CopyOnWriteArrayList<>();
|
||||
WIRE.add(sent::add);
|
||||
|
||||
QuickSearchRuntime runtime = newRuntime();
|
||||
try
|
||||
{
|
||||
runtime.isTableEnabled(TEST_ENTITY_TABLE);
|
||||
runtime.invalidateConfig();
|
||||
|
||||
assertThat(runtime.getCachedEnabledFlagCount()).isZero();
|
||||
assertThat(sent).anySatisfy(event ->
|
||||
{
|
||||
assertThat(event.getType()).isEqualTo(ClusterEvent.TYPE_MEMO_INVALIDATE);
|
||||
assertThat(event.getName()).isEqualTo(QuickSearchRuntime.CONFIG_CACHE_NAME);
|
||||
assertThat(event.getOriginNodeId()).isEqualTo("node-a");
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ 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.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexField;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
|
||||
@@ -223,4 +224,31 @@ class QuickSearchRuntimeTest extends BaseQuickSearchTest
|
||||
failFast.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Priming at startup without a QContext builds the system session with the
|
||||
** start instance (so it has the all-access keys), not from the empty context.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void primeConfigWithoutAContextGivesTheSystemSessionItsInstance()
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QuickSearchRuntime runtime = new QuickSearchRuntime(new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withOpensearchHost("127.0.0.1").withOpensearchPort(1).withOpensearchIndexName("t"));
|
||||
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
|
||||
try
|
||||
{
|
||||
runtime.setStartInstance(qInstance);
|
||||
QContext.clear();
|
||||
runtime.primeConfig();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
|
||||
QContext.init(qInstance, new QSystemUserSession(qInstance));
|
||||
runtime.close();
|
||||
}
|
||||
assertThat(collectingLogger.getCollectedMessages()).noneSatisfy(m -> assertThat(m.getMessage()).contains("QInstance was not supplied"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -362,6 +362,14 @@ class QuickSearchStatusTest
|
||||
assertThat(anonymous.statusCode()).as(anonymous.body()).isEqualTo(403);
|
||||
var denied = client.send(HttpRequest.newBuilder(URI.create(url)).header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(denied.statusCode()).as(denied.body()).isEqualTo(403);
|
||||
for(var forbidden : List.of(anonymous, denied))
|
||||
{
|
||||
var error = new ObjectMapper().readTree(forbidden.body());
|
||||
assertThat(error.path("error").asText()).isNotBlank();
|
||||
assertThat(error.path("message")).isEqualTo(error.path("error"));
|
||||
assertThat(error.path("code").asText()).isEqualTo("PERMISSION_DENIED");
|
||||
assertThat(error.path("traceId").asText()).isNotBlank();
|
||||
}
|
||||
String suggestUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/quick-search/suggest";
|
||||
for(String query : List.of("?q=x", "?q=%20x%20", "?q=wi&tables=unknown", "?q=wi&tables=", "?q=wi"))
|
||||
{
|
||||
@@ -378,7 +386,11 @@ class QuickSearchStatusTest
|
||||
var invalid = client.send(HttpRequest.newBuilder(URI.create(suggestUrl + query))
|
||||
.header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(invalid.statusCode()).as(invalid.body()).isEqualTo(400);
|
||||
assertThat(new ObjectMapper().readTree(invalid.body()).path("error").isTextual()).isTrue();
|
||||
var error = new ObjectMapper().readTree(invalid.body());
|
||||
assertThat(error.path("error").asText()).isNotBlank();
|
||||
assertThat(error.path("message")).isEqualTo(error.path("error"));
|
||||
assertThat(error.path("code").asText()).isEqualTo("BAD_REQUEST");
|
||||
assertThat(error.path("traceId").asText()).isNotBlank();
|
||||
}
|
||||
var openapi = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/qqq/v1/openapi.yaml")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(openapi.body()).contains("/qqq/v1/qbit/quick-search/status", "QuickSearchStatusV1",
|
||||
|
||||
@@ -21,7 +21,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
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.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -77,6 +79,38 @@ class QuickSearchConfigPrimerTest extends BaseDataDrivenTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-priming only records the priming outcome on the settings row: that is
|
||||
** not audited. An admin settings edit still is.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRepriming_settingsBookkeepingIsNotAudited() throws QException
|
||||
{
|
||||
new AuditsMetaDataProvider().defineAll(QContext.getQInstance(), TEST_BACKEND_NAME, null);
|
||||
new QuickSearchConfigPrimer(runtime).prime();
|
||||
long afterFirst = settingsEditAudits();
|
||||
new QuickSearchConfigPrimer(runtime).prime();
|
||||
assertThat(settingsEditAudits()).as("re-priming is not audited").isEqualTo(afterFirst);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput().withTableName(QuickSearchSettings.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", QuickSearchSettings.SETTINGS_ID).withValue("indexingPaused", true)));
|
||||
assertThat(settingsEditAudits()).as("an admin settings edit is audited").isEqualTo(afterFirst + 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit rows recording an edit of the settings row.
|
||||
*******************************************************************************/
|
||||
private long settingsEditAudits() throws QException
|
||||
{
|
||||
return (rows(AuditsMetaDataProvider.TABLE_NAME_AUDIT).stream()
|
||||
.filter(audit -> audit.getValueString("message") != null && audit.getValueString("message").contains("Edited"))
|
||||
.count());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code defaults update rows nobody edited, and never rows an owner edited.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.model.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -324,6 +325,45 @@ class BasepullIndexStepTest extends BaseQuickSearchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Basepull bookkeeping on the index row (status, lastBasepullTime,
|
||||
** documentCount, last run) writes no audit; an admin edit of the same row
|
||||
** is still audited.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testBasepullBookkeeping_isNotAudited_adminEditIs() throws QException
|
||||
{
|
||||
if(QContext.getQInstance().getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT) == null)
|
||||
{
|
||||
new AuditsMetaDataProvider().defineAll(QContext.getQInstance(), TEST_BACKEND_NAME, null);
|
||||
}
|
||||
insertTestEntities(3);
|
||||
QRecord row = insertIndexRow(TEST_ENTITY_TABLE, true, null, 5);
|
||||
when(mockClient.countDocumentsForTable(TEST_ENTITY_TABLE)).thenReturn(3L);
|
||||
|
||||
new BasepullIndexStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
assertThat(queryAll(QuickSearchIndex.TABLE_NAME).get(0).getValueInteger("documentCount")).isEqualTo(3);
|
||||
assertThat(indexRowEditAudits()).as("basepull bookkeeping is not audited").isZero();
|
||||
|
||||
new UpdateAction().execute(new UpdateInput().withTableName(QuickSearchIndex.TABLE_NAME)
|
||||
.withRecord(new QRecord().withValue("id", row.getValue("id")).withValue("basepullIntervalMinutes", 15)));
|
||||
assertThat(indexRowEditAudits()).as("an admin configuration edit is audited").isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit rows recording an edit of a quick_search_index row.
|
||||
*******************************************************************************/
|
||||
private long indexRowEditAudits() throws QException
|
||||
{
|
||||
return (queryAll(AuditsMetaDataProvider.TABLE_NAME_AUDIT).stream()
|
||||
.filter(audit -> audit.getValueString("message") != null && audit.getValueString("message").contains("Edited"))
|
||||
.count());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test: no discovered tables, no quickSearchIndex rows - step completes
|
||||
** without calling the OpenSearch client.
|
||||
|
||||
Reference in new issue
Block a user