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")); + } + }