fix(metadata): dropdowns for enum fields; manual Tested Pages insert works #9

Merged
KofTwentyTwo merged 1 commits from fix/enum-dropdowns-tested-pages into develop 2026-10-14 15:50:31 +00:00
6 changed files with 356 additions and 26 deletions

No files matched your search

+4
View File
@@ -4,5 +4,9 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Fixed
- Use dropdowns for tier, scope, source, strategy, metric, comparator and severity, preserving existing stored values.
- Allow manual Tested Pages inserts with a URL. Validate absolute HTTPS URLs and fill in the site, path and Manual source automatically; validate URL edits too.
### Added
- First implementation; see README.md for the tables, permissions and configuration.
+7 -1
View File
@@ -44,11 +44,17 @@ The host owns the database. Include the QBit's changelog from the host's master
- include: { file: db/liquibase/qbit-web-performance/web_performance.changelog.sql }
```
## Tested pages and dropdowns
Add a manual tested page with an absolute HTTPS URL, an optional label, a tier and an enabled flag. The QBit fills in its site key, path and Manual source. URLs cannot contain credentials or fragments; URL edits use the same validation and update the path. Sitemap sync remains available for automatic discovery.
Tier, Scope, Source, Strategy, Metric, Comparator and Severity use dropdowns with plain labels and the existing stored values. Each table uses its own applicable choices. Existing seeded budgets need no migration.
## Tables
| Table | Written by | Notes |
|---|---|---|
| `web_performance_target` (Tested Pages) | sitemap sync, people | `label`, `tier` and `enabled` are editable; field-audited |
| `web_performance_target` (Tested Pages) | sitemap sync, people | `url`, `label`, `tier` and `enabled` are editable; field-audited |
| `web_performance_lab_run` (Lab Runs) | the QBit | read-only; one Lighthouse run |
| `web_performance_lab_finding` (Findings) | the QBit | read-only; keyed by `(lighthouse_version, audit_id)` |
| `web_performance_lab_report` (Lighthouse Reports) | the QBit | read-only; the gzipped PSI response, kept forever by default |
@@ -0,0 +1,113 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webperf.metadata;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
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.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate manually entered URLs and derive the fields used by sync and tests.
*******************************************************************************/
public class TargetCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Derive site identity before the unique site/URL check.
*******************************************************************************/
@Override
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput input, boolean isPreview)
{
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
}
/*******************************************************************************
** Manual pages need only a URL, label, tier and enabled flag from the user.
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview)
{
validate(input.getTableName(), records, true);
return (records);
}
/*******************************************************************************
** Keep URL edits subject to the same validation and refresh their path.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecords)
{
validate(input.getTableName(), records, false);
return (records);
}
/*******************************************************************************
** Accept absolute HTTPS URLs without embedded credentials or fragments.
*******************************************************************************/
private void validate(String tableName, List<QRecord> records, boolean inserting)
{
String prefix = tableName.substring(0, tableName.length() - WebPerfSchema.TARGET.length());
WebPerfRuntime runtime = WebPerfRuntime.of(QContext.getQInstance(), prefix);
for(QRecord record : records)
{
if(!inserting && !record.getValues().containsKey("url"))
{
continue;
}
String url = record.getValueString("url");
try
{
URI uri = URI.create(url == null ? "" : url);
if(!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null)
{
throw (new IllegalArgumentException("Invalid page URL"));
}
record.setValue("path", uri.getPath().isEmpty() ? "/" : uri.getPath());
if(inserting)
{
record.setValue("siteKey", runtime.service().siteKey());
record.setValue("source", "MANUAL");
record.setValue("inSitemap", false);
}
}
catch(IllegalArgumentException e)
{
record.addError(new BadInputStatusMessage("URL must be an absolute HTTPS URL without credentials or a fragment."));
}
}
}
}
@@ -31,6 +31,7 @@ import com.kingsrook.qbits.webperf.WebPerfRuntime;
import com.kingsrook.qbits.webperf.WebPerfSchema;
import com.kingsrook.qbits.webperf.processes.WebPerfProcesses;
import com.kingsrook.qbits.webperf.widgets.WebPerfWidgets;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
@@ -47,7 +48,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
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.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
@@ -65,7 +68,7 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
** comparisons, change events) are written only by the QBit's processes:
** read-only in the UI and not audited.
** - Tested pages and budgets are edited by people (enable a page, change a
** budget): only those fields are editable, and both are field-audited.
** budget): URLs are validated on insert/edit, and both are field-audited.
** Regressions change status only through record actions (dismiss,
** resolve), so they are read-only in the UI but field-audited.
**
@@ -106,6 +109,7 @@ public final class WebPerfMetaData
{
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(config.applyPrefix(base)));
}
addEnumSources(qInstance);
for(QProcessMetaData process : WebPerfProcesses.define(config, qInstance))
{
qInstance.addProcess(process);
@@ -136,6 +140,88 @@ public final class WebPerfMetaData
/*******************************************************************************
** Enumerated values retain the stored IDs used by runners and seeded budgets.
*******************************************************************************/
private void addEnumSources(QInstance qInstance)
{
addEnumSource(qInstance, "tier",
new QPossibleValue<>("KEY", "Key page"),
new QPossibleValue<>("STANDARD", "Standard page"));
addEnumSource(qInstance, "target_source",
new QPossibleValue<>("SITEMAP", "Sitemap"),
new QPossibleValue<>("MANUAL", "Manual"),
new QPossibleValue<>("CONTENT", "Content"));
addEnumSource(qInstance, "strategy",
new QPossibleValue<>("MOBILE", "Mobile"),
new QPossibleValue<>("DESKTOP", "Desktop"));
addEnumSource(qInstance, "strategy_or_form_factor",
new QPossibleValue<>("MOBILE", "Mobile"),
new QPossibleValue<>("DESKTOP", "Desktop"),
new QPossibleValue<>("PHONE", "Phone"),
new QPossibleValue<>("TABLET", "Tablet"),
new QPossibleValue<>("ALL", "All devices"));
addEnumSource(qInstance, "field_source",
new QPossibleValue<>("CRUX_API", "CrUX API"),
new QPossibleValue<>("CRUX_HISTORY", "CrUX history"),
new QPossibleValue<>("PSI", "PageSpeed Insights"));
addEnumSource(qInstance, "field_scope",
new QPossibleValue<>("URL", "URL"),
new QPossibleValue<>("ORIGIN", "Origin"));
addEnumSource(qInstance, "budget_scope",
new QPossibleValue<>("SITE", "Site"),
new QPossibleValue<>("TIER", "Tier"),
new QPossibleValue<>("TARGET", "Page"),
new QPossibleValue<>("CONTENT_TABLE", "Content table"),
new QPossibleValue<>("PATH_PREFIX", "Path prefix"));
addEnumSource(qInstance, "measurement_source",
new QPossibleValue<>("LAB", "Lab"),
new QPossibleValue<>("RUM", "Real users"),
new QPossibleValue<>("CRUX", "Google field data"));
addEnumSource(qInstance, "comparison_source",
new QPossibleValue<>("LAB", "Lab"),
new QPossibleValue<>("RUM", "Real users"));
addEnumSource(qInstance, "comparison_scope",
new QPossibleValue<>("KEY", "Key pages"),
new QPossibleValue<>("TARGET", "Page"),
new QPossibleValue<>("SITE", "Site"));
addEnumSource(qInstance, "change_source",
new QPossibleValue<>("PROBE", "Probe"),
new QPossibleValue<>("WEBHOOK", "Webhook"),
new QPossibleValue<>("QBIT_CDN", "CDN"),
new QPossibleValue<>("USER", "User"));
addEnumSource(qInstance, "metric",
new QPossibleValue<>("LCP", "LCP"),
new QPossibleValue<>("INP", "INP"),
new QPossibleValue<>("CLS", "CLS"),
new QPossibleValue<>("FCP", "FCP"),
new QPossibleValue<>("TTFB", "TTFB"),
new QPossibleValue<>("TBT", "TBT"),
new QPossibleValue<>("PERFORMANCE_SCORE", "Performance score"),
new QPossibleValue<>("ACCESSIBILITY_SCORE", "Accessibility score"),
new QPossibleValue<>("BEST_PRACTICES_SCORE", "Best practices score"),
new QPossibleValue<>("SEO_SCORE", "SEO score"));
addEnumSource(qInstance, "comparator",
new QPossibleValue<>("<=", "At most"),
new QPossibleValue<>(">=", "At least"));
addEnumSource(qInstance, "severity",
new QPossibleValue<>("WARNING", "Warning"),
new QPossibleValue<>("CRITICAL", "Critical"));
}
/*******************************************************************************
** Register an install-scoped dropdown with plain labels.
*******************************************************************************/
private void addEnumSource(QInstance qInstance, String name, QPossibleValue<?>... values)
{
qInstance.addPossibleValueSource(new QPossibleValueSource().withName(config.applyPrefix(name))
.withType(QPossibleValueSourceType.ENUM).withEnumValues(List.of(values)));
}
/*******************************************************************************
** A card widget, full width, carrying the install's prefix so its
** renderer finds the runtime.
@@ -211,11 +297,11 @@ public final class WebPerfMetaData
f("siteKey", QFieldType.STRING, "Site").withMaxLength(100),
f("url", QFieldType.STRING, "URL").withMaxLength(1000).withIsRequired(true),
f("path", QFieldType.STRING, "Path").withMaxLength(500),
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("target_source")).withMaxLength(20),
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
f("contentId", QFieldType.LONG, "Content Id"),
f("label", QFieldType.STRING, "Label").withMaxLength(200),
f("tier", QFieldType.STRING, "Tier").withMaxLength(20).withDefaultValue("STANDARD"),
f("tier", QFieldType.STRING, "Tier").withPossibleValueSourceName(config.applyPrefix("tier")).withMaxLength(20).withDefaultValue("STANDARD"),
f("enabled", QFieldType.BOOLEAN, "Enabled").withDefaultValue(true),
f("inSitemap", QFieldType.BOOLEAN, "In Sitemap"),
f("lastSeenInSitemapAt", QFieldType.DATE_TIME, "Last Seen in Sitemap"),
@@ -226,7 +312,9 @@ public final class WebPerfMetaData
f("openRegressionCount", QFieldType.INTEGER, "Open Regressions")));
QTableMetaData table = table(WebPerfSchema.TARGET, "Tested Pages", "travel_explore", fields, true,
Set.of("label", "tier", "enabled"))
Set.of("url", "label", "tier", "enabled"))
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(TargetCustomizer.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TargetCustomizer.class))
.withRecordLabelFormatAndFields("%s", "url")
.withUniqueKey(new UniqueKey("siteKey", "url"))
.withSection(section("page", "Page", "travel_explore", Tier.T1, "id", "url", "label", "tier", "enabled"))
@@ -249,7 +337,7 @@ public final class WebPerfMetaData
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("url", QFieldType.STRING, "URL").withMaxLength(1000),
f("finalUrl", QFieldType.STRING, "Final URL").withMaxLength(1000),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("runner", QFieldType.STRING, "Runner").withMaxLength(20),
f("triggerType", QFieldType.STRING, "Trigger").withMaxLength(20),
f("triggerRef", QFieldType.STRING, "Trigger Reference").withMaxLength(200),
@@ -300,7 +388,7 @@ public final class WebPerfMetaData
List<QFieldMetaData> fields = withCommon(List.of(
f("runId", QFieldType.LONG, "Run").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.LAB_RUN)),
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("category", QFieldType.STRING, "Category").withMaxLength(20),
f("auditId", QFieldType.STRING, "Audit").withMaxLength(100),
f("lighthouseVersion", QFieldType.STRING, "Lighthouse Version").withMaxLength(20),
@@ -350,8 +438,8 @@ public final class WebPerfMetaData
QTableMetaData fieldSnapshot()
{
List<QFieldMetaData> fields = withCommon(List.of(
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withMaxLength(10),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("field_source")).withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("field_scope")).withMaxLength(10),
f("scopeKey", QFieldType.STRING, "URL or Origin").withMaxLength(1000),
f("formFactor", QFieldType.STRING, "Form Factor").withMaxLength(10),
f("status", QFieldType.STRING, "Status").withMaxLength(10),
@@ -382,14 +470,14 @@ public final class WebPerfMetaData
{
List<QFieldMetaData> fields = withCommon(List.of(
f("name", QFieldType.STRING, "Name").withMaxLength(200).withIsRequired(true),
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("budget_scope")).withMaxLength(20),
f("scopeValue", QFieldType.STRING, "Scope Value").withMaxLength(500),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("comparator", QFieldType.STRING, "Comparator").withMaxLength(5),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("comparator", QFieldType.STRING, "Comparator").withPossibleValueSourceName(config.applyPrefix("comparator")).withMaxLength(5),
f("threshold", QFieldType.DECIMAL, "Threshold"),
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
f("enabled", QFieldType.BOOLEAN, "Enabled"),
f("seeded", QFieldType.BOOLEAN, "Seeded")));
@@ -412,16 +500,16 @@ public final class WebPerfMetaData
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
f("contentId", QFieldType.LONG, "Content Id"),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("baselineValue", QFieldType.DECIMAL, "Baseline"),
f("baselineRef", QFieldType.STRING, "Baseline From").withMaxLength(500),
f("currentValue", QFieldType.DECIMAL, "Current"),
f("currentRef", QFieldType.STRING, "Current From").withMaxLength(500),
f("delta", QFieldType.DECIMAL, "Change"),
f("budgetId", QFieldType.LONG, "Budget").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.BUDGET)),
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
f("status", QFieldType.STRING, "Status").withMaxLength(20),
f("causeChangeEventId", QFieldType.LONG, "Likely Cause").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
f("detectedAt", QFieldType.DATE_TIME, "Detected"),
@@ -451,7 +539,7 @@ public final class WebPerfMetaData
f("fromValue", QFieldType.STRING, "From").withMaxLength(200),
f("toValue", QFieldType.STRING, "To").withMaxLength(200),
f("observedAt", QFieldType.DATE_TIME, "Observed"),
f("source", QFieldType.STRING, "Source").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("change_source")).withMaxLength(20),
f("note", QFieldType.STRING, "Note").withMaxLength(2000)));
return (table(WebPerfSchema.CHANGE_EVENT, "Site Changes", "change_circle", fields, false, Set.of())
@@ -470,10 +558,10 @@ public final class WebPerfMetaData
{
List<QFieldMetaData> fields = withCommon(List.of(
f("changeEventId", QFieldType.LONG, "Change").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
f("source", QFieldType.STRING, "Source").withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("comparison_scope")).withMaxLength(20),
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("comparison_source")).withMaxLength(10),
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
f("segment", QFieldType.STRING, "Segment").withMaxLength(100),
f("beforeValue", QFieldType.DECIMAL, "Before"),
f("beforeN", QFieldType.INTEGER, "Before n"),
@@ -23,12 +23,16 @@ package com.kingsrook.qbits.webperf;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webperf.sources.HttpBuildIdProbe;
import com.kingsrook.qbits.webperf.sources.SitemapUrlSource;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
@@ -110,6 +114,53 @@ class WebPerfQBitProducerTest
/*******************************************************************************
** Every enum field resolves to an install-scoped ENUM with stable stored IDs.
*******************************************************************************/
@Test
void testEnumFields() throws Exception
{
QInstance instance = WebPerfTestSupport.newInstance();
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix("perf_")).produce(instance);
Map<String, List<String>> expected = Map.ofEntries(
Map.entry("tier", List.of("KEY", "STANDARD")),
Map.entry("target_source", List.of("SITEMAP", "MANUAL", "CONTENT")),
Map.entry("strategy", List.of("MOBILE", "DESKTOP")),
Map.entry("strategy_or_form_factor", List.of("MOBILE", "DESKTOP", "PHONE", "TABLET", "ALL")),
Map.entry("field_source", List.of("CRUX_API", "CRUX_HISTORY", "PSI")),
Map.entry("field_scope", List.of("URL", "ORIGIN")),
Map.entry("budget_scope", List.of("SITE", "TIER", "TARGET", "CONTENT_TABLE", "PATH_PREFIX")),
Map.entry("measurement_source", List.of("LAB", "RUM", "CRUX")),
Map.entry("comparison_source", List.of("LAB", "RUM")),
Map.entry("comparison_scope", List.of("KEY", "TARGET", "SITE")),
Map.entry("change_source", List.of("PROBE", "WEBHOOK", "QBIT_CDN", "USER")),
Map.entry("metric", List.of("LCP", "INP", "CLS", "FCP", "TTFB", "TBT", "PERFORMANCE_SCORE", "ACCESSIBILITY_SCORE", "BEST_PRACTICES_SCORE", "SEO_SCORE")),
Map.entry("comparator", List.of("<=", ">=")),
Map.entry("severity", List.of("WARNING", "CRITICAL")));
int count = 0;
for(QTableMetaData table : instance.getTables().values())
{
for(QFieldMetaData field : table.getFields().values())
{
if(List.of("tier", "scope", "source", "strategy", "metric", "comparator", "severity").contains(field.getName()))
{
count++;
assertThat(field.getPossibleValueSourceName()).as(table.getName() + "." + field.getName()).startsWith("perf_");
QPossibleValueSource source = instance.getPossibleValueSource(field.getPossibleValueSourceName());
assertEquals(QPossibleValueSourceType.ENUM, source.getType());
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).containsExactlyElementsOf(expected.get(source.getName().substring(5)));
assertThat(source.getEnumValues()).allMatch(v -> v.getLabel() != null && !v.getLabel().isBlank());
}
}
}
assertEquals(21, count);
assertEquals("Key page", instance.getPossibleValueSource("perf_tier").getEnumValues().get(0).getLabel());
assertTrue(instance.getTable("perf_target").getField("url").getIsEditable());
assertTrue(instance.getTable("perf_target").isCapabilityEnabled(instance.getBackend(WebPerfTestSupport.BACKEND), Capability.TABLE_INSERT));
}
/*******************************************************************************
** The app nests under a host parent app (kof22: CMS, D5).
*******************************************************************************/
@@ -31,6 +31,9 @@ import java.util.Locale;
import java.util.Set;
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.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
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.insert.InsertOutput;
@@ -41,6 +44,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.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.AfterEach;
@@ -130,6 +134,18 @@ class WebPerfSchemaTest
assertEquals(13, budgets.size());
assertThat(budgets).allMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("seeded")) && Boolean.TRUE.equals(r.getValueBoolean("enabled")));
for(QRecord budget : budgets)
{
for(String field : List.of("scope", "source", "strategy", "metric", "comparator", "severity"))
{
if(budget.getValue(field) != null)
{
QPossibleValueSource source = qInstance.getPossibleValueSource(qInstance.getTable("web_performance_budget").getField(field).getPossibleValueSourceName());
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).contains(budget.getValueString(field));
}
}
}
QRecord inp = budgets.stream().filter(r -> "INP good (real users, p75)".equals(r.getValueString("name"))).findFirst().orElseThrow();
assertEquals("RUM", inp.getValueString("source"));
assertEquals(0, new BigDecimal("200").compareTo(inp.getValueBigDecimal("threshold")));
@@ -151,16 +167,68 @@ class WebPerfSchemaTest
{
installed("perf_");
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about").withValue("path", "/about")
.withValue("source", "MANUAL").withValue("tier", "KEY").withValue("enabled", true))));
new QRecord().withValue("url", "https://www.example.com/about").withValue("label", "About")
.withValue("tier", "KEY").withValue("enabled", true))));
assertThat(output.getRecords().get(0).getErrors()).isEmpty();
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
assertEquals("https://www.example.com/about", stored.getValueString("url"));
assertEquals("KEY", stored.getValueString("tier"));
assertEquals("host:www.example.com", stored.getValueString("siteKey"));
assertEquals("/about", stored.getValueString("path"));
assertEquals("MANUAL", stored.getValueString("source"));
assertEquals(false, stored.getValueBoolean("inSitemap"));
InsertOutput duplicate = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about"))));
assertThat(duplicate.getRecords().get(0).getErrors()).isNotEmpty();
}
/*******************************************************************************
** Invalid manual URLs return a clear error and never become tested pages.
*******************************************************************************/
@Test
void testManualPageUrlValidation() throws Exception
{
installed("perf_");
for(String url : List.of("", "/about", "http://www.example.com/about", "https:///about", "https://user:pass@example.com/", "https://www.example.com/#section", "not a URL"))
{
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(new QRecord().withValue("url", url))));
assertThat(output.getRecords().get(0).getErrors()).isNotEmpty();
assertThat(output.getRecords().get(0).getErrors().toString()).contains("absolute HTTPS URL");
}
assertThat(new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords()).isEmpty();
}
/*******************************************************************************
** Editing a URL refreshes its path; invalid edits leave the stored URL intact.
*******************************************************************************/
@Test
void testManualPageUrlUpdates() throws Exception
{
installed("perf_");
QRecord inserted = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
new QRecord().withValue("url", "https://www.example.com")))).getRecords().get(0);
assertThat(inserted.getErrors()).isEmpty();
assertEquals("/", inserted.getValueString("path"));
UpdateOutput valid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "https://www.example.com/new"))));
assertThat(valid.getRecords().get(0).getErrors()).isEmpty();
UpdateOutput invalid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "/bad"))));
assertThat(invalid.getRecords().get(0).getErrors()).isNotEmpty();
UpdateOutput labelOnly = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
new QRecord().withValue("id", inserted.getValue("id")).withValue("label", "New page"))));
assertThat(labelOnly.getRecords().get(0).getErrors()).isEmpty();
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
assertEquals("https://www.example.com/new", stored.getValueString("url"));
assertEquals("/new", stored.getValueString("path"));
assertEquals("New page", stored.getValueString("label"));
}
}