From 54b7d0b39f6b462eeaa70fc7635783cbe5a1d80a Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:43:01 -0500 Subject: [PATCH] fix(metadata): dropdowns for enum fields; manual Tested Pages insert Tier, scope, source, strategy, metric, comparator and severity were free text, so a typo such as "key" for KEY silently dropped a page from the daily key-page tests. Each now uses an enum possible value source with plain labels and the stored values the code already compares against (no data migration). Tested Pages offered Insert while URL was not editable but required, so a manual insert could never succeed. URL is now editable and validated (absolute https, no credentials or fragment) by TargetCustomizer, which also fills path, site, source MANUAL and inSitemap false. Refs #8 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 8 +- .../webperf/metadata/TargetCustomizer.java | 113 +++++++++++++++ .../webperf/metadata/WebPerfMetaData.java | 134 +++++++++++++++--- .../webperf/WebPerfQBitProducerTest.java | 51 +++++++ .../qbits/webperf/WebPerfSchemaTest.java | 72 +++++++++- 6 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/webperf/metadata/TargetCustomizer.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8b84d..a87b479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f981f6a..f2534fd 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/main/java/com/kingsrook/qbits/webperf/metadata/TargetCustomizer.java b/src/main/java/com/kingsrook/qbits/webperf/metadata/TargetCustomizer.java new file mode 100644 index 0000000..0937ad3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/webperf/metadata/TargetCustomizer.java @@ -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 preInsert(InsertInput input, List 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 preUpdate(UpdateInput input, List records, boolean isPreview, Optional> oldRecords) + { + validate(input.getTableName(), records, false); + return (records); + } + + + + /******************************************************************************* + ** Accept absolute HTTPS URLs without embedded credentials or fragments. + *******************************************************************************/ + private void validate(String tableName, List 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.")); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/webperf/metadata/WebPerfMetaData.java b/src/main/java/com/kingsrook/qbits/webperf/metadata/WebPerfMetaData.java index a002525..70e80c9 100644 --- a/src/main/java/com/kingsrook/qbits/webperf/metadata/WebPerfMetaData.java +++ b/src/main/java/com/kingsrook/qbits/webperf/metadata/WebPerfMetaData.java @@ -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 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 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 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 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"), diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfQBitProducerTest.java index 7adbc93..094d159 100644 --- a/src/test/java/com/kingsrook/qbits/webperf/WebPerfQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfQBitProducerTest.java @@ -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> 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). *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/webperf/WebPerfSchemaTest.java b/src/test/java/com/kingsrook/qbits/webperf/WebPerfSchemaTest.java index 67cb238..da171dd 100644 --- a/src/test/java/com/kingsrook/qbits/webperf/WebPerfSchemaTest.java +++ b/src/test/java/com/kingsrook/qbits/webperf/WebPerfSchemaTest.java @@ -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")); + } + } -- 2.54.0