From 98e3771c27ca1a37b694fda1b58197e0831239d4 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 19 Oct 2026 08:06:32 -0500 Subject: [PATCH] feat(crm): support a shared product catalog Add optional stable slugs and catalog pricing, plus opt-in product permissions without broadening other CRM access. Include additive schema migration and upgrade tests. Co-Authored-By: Codex Signed-off-by: James Maes --- CHANGELOG.md | 3 + README.md | 1 + UPGRADE.md | 17 +++++ docs/ddl/postgres.sql | 6 +- .../kingsrook/qbits/crm/CrmQBitConfig.java | 34 +++++++++ .../kingsrook/qbits/crm/CrmQBitProducer.java | 4 + .../qbits/crm/deals/model/Product.java | 39 +++++++++- .../crm-product-catalog.changelog.sql | 7 ++ src/main/resources/help/qbit-crm.yaml | 6 +- .../qbits/crm/CrmPermissionsTest.java | 46 +++++++++++ .../qbits/crm/CrmQBitConfigTest.java | 16 ++++ .../com/kingsrook/qbits/crm/CrmRdbmsTest.java | 1 + .../qbits/crm/deals/DealProductTest.java | 19 +++++ .../deals/ProductCatalogMigrationTest.java | 76 +++++++++++++++++++ .../qbits/crm/deals/ProductTest.java | 22 ++++++ 15 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql create mode 100644 src/test/java/com/kingsrook/qbits/crm/deals/ProductCatalogMigrationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index fa157a9..4203cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased / 0.2.0 +- Add an optional unique catalog slug and allow absent catalog prices without changing required deal-line prices. Ship the additive `crm-product-catalog.changelog.sql`; hosts must include it before adopting the metadata. +- Add opt-in `productPermissionBase` configuration for native catalog-only read/write permissions. Existing hosts retain `crm.manage` behavior; opting into `crm.product` requires the role migration documented in UPGRADE.md. + - Read the CRM QBit metadata version from the Maven-filtered artifact version so release metadata matches the build. - Adopt QQQ 4.1.0-RC.7 native help bundles, replacing the RC.6 stand-in loader with host-neutral CRM help, live references and the core coverage gate. diff --git a/README.md b/README.md index 6c272b1..d0da3e3 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ qInstance.setEnforcePermissionsInCoreActions(true); - `defaultBackendNameForTables`: required backend name. - `inferBackendNames`: defaults to true; sets snake_case SQL column names while Java/QQQ field names remain camelCase. SQL table names equal the QQQ table names. Set false only for an explicit host column mapping. +- `withProductPermissionBase("crm.product")`: opt into catalog-only `crm.product.read/write` permissions. The default remains `crm.manage` for existing hosts. Before opting in, preserve existing catalog access by adding the corresponding product grants to roles that already hold `crm.manage.read/write`; Marketing catalog readers receive only `crm.product.read`. This does not grant CRM app/settings access or product writes to Marketing. See UPGRADE.md for the required schema and role migration. - `tableMetaDataCustomizer`: optional host table customizer. - `withTenantSecurityKeyType("tenant")`: enables nullable `tenantKey` fields and record locks. It defaults to null (off). `withTenantAllAccessKeyName("allTenants")` names the host's all-access key. Existing host key types are preserved. - Tenant-enabled hosts separately include `src/main/resources/db/liquibase/qbit-crm/crm-tenancy.changelog.sql` **after** the base changelog and explicitly select the `crm-tenancy` context. Single-tenant hosts omit that file. Child records use parent join locks; polymorphic external mappings and internal job checkpoints carry direct tenant keys. Hosts must supply tenant keys on new direct-key records. diff --git a/UPGRADE.md b/UPGRADE.md index 6d03e74..6127dde 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,22 @@ # Upgrading +## Shared product catalog (unreleased) + +`crm_product` now supports an optional unique `slug` (64 characters) and an absent catalog `unit_price` for informational products. No price is invented. Deal-line `unit_price` remains required; existing prices and amounts are unchanged. + +Before running the updated metadata against an existing schema, include `db/liquibase/qbit-crm/crm-product-catalog.changelog.sql` after the base CRM schema. This additive migration also works for hosts that copied the old CRM DDL; do not replay the base changelog against an already-created schema. Fresh installs using `crm.changelog.sql` must include the catalog changelog too. `docs/ddl/postgres.sql` describes the final schema, so hosts creating tables directly from that file must not then replay the additive migration. + +The migration adds nullable `slug`, its unique constraint, and drops only the catalog price NOT NULL constraint. It preserves product IDs, timestamps, prices and tenant keys. It does not migrate Marketing data or alter any deal-line price. Roll back application code only after checking schema compatibility; do not drop populated slugs or force null prices to zero. + +Catalog permissions stay on `crm.manage.read/write` unless the host opts into `withProductPermissionBase("crm.product")`. Opt-in requires an explicit role migration in the same adoption rollout: + +- Existing `crm.manage.read` holders retain that grant and gain `crm.product.read`. +- Existing `crm.manage.write` holders retain that grant and gain `crm.product.write` (preserve their existing read grant separately). +- Marketing roles needing catalog selection receive `crm.product.read` only. Their marketing permissions remain unchanged. +- Do not grant `crm.manage` to Marketing users or infer permissions from navigation placement. + +The native QQQ permission checks enforce both API writes and UI operation checks. Tenant locks continue to apply. Marketing integration and host data migration must be adopted separately before retiring `marketing_product`. + ## Starter defaults (Unreleased / 0.2.0) The CRM now ships `db/liquibase/qbit-crm/crm-defaults.changelog.sql`, a separate insert-only changelog. Without it, diff --git a/docs/ddl/postgres.sql b/docs/ddl/postgres.sql index 5f9d132..092b256 100644 --- a/docs/ddl/postgres.sql +++ b/docs/ddl/postgres.sql @@ -565,13 +565,15 @@ CREATE TABLE IF NOT EXISTS crm_product ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(250) NOT NULL, sku VARCHAR(100), + slug VARCHAR(64), description TEXT, - unit_price DECIMAL(20,6) NOT NULL, + unit_price DECIMAL(20,6), currency_code VARCHAR(3) DEFAULT 'USD', is_active BOOLEAN DEFAULT TRUE NOT NULL, create_date TIMESTAMP WITHOUT TIME ZONE, modify_date TIMESTAMP WITHOUT TIME ZONE, - CONSTRAINT uq_crm_product_1 UNIQUE (sku) + CONSTRAINT uq_crm_product_1 UNIQUE (sku), + CONSTRAINT uq_crm_product_slug UNIQUE (slug) ); CREATE TABLE IF NOT EXISTS crm_sales_goal ( diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java index adb75e4..da7efb1 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitConfig.java @@ -41,6 +41,7 @@ public class CrmQBitConfig implements QBitConfig { public static final CrmQBitConfig DEFAULT_CONFIG = new CrmQBitConfig(); + private String productPermissionBase = "crm.manage"; private String tenantSecurityKeyType; private String tenantAllAccessKeyName; private String apiName; @@ -69,12 +70,45 @@ public class CrmQBitConfig implements QBitConfig @Override public void validate(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance, List errors) { + assertCondition(StringUtils.hasContent(productPermissionBase), "CrmQBitConfig.productPermissionBase is required", errors); assertCondition(StringUtils.hasContent(defaultBackendNameForTables), "CrmQBitConfig.defaultBackendNameForTables is required", errors); } + /******************************************************************************* + ** Catalog permission base. Defaults to crm.manage for existing hosts. + ** Shared-catalog hosts may use crm.product after migrating role grants. + *******************************************************************************/ + public String getProductPermissionBase() + { + return (productPermissionBase); + } + + + + /******************************************************************************* + ** Set the catalog's native read/write permission base. + *******************************************************************************/ + public void setProductPermissionBase(String productPermissionBase) + { + this.productPermissionBase = productPermissionBase; + } + + + + /******************************************************************************* + ** Set the catalog's native read/write permission base fluently. + *******************************************************************************/ + public CrmQBitConfig withProductPermissionBase(String productPermissionBase) + { + setProductPermissionBase(productPermissionBase); + return (this); + } + + + /******************************************************************************* ** Getter for defaultBackendNameForTables *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index 0d07c9c..92fb3d3 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -208,6 +208,10 @@ public class CrmQBitProducer implements QBitMetaDataProducer addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(CrmRelationshipValidator.class)); } table.setPermissionRules(CrmPermissions.tableRules(tableName)); + if("crm_product".equals(tableName)) + { + table.getPermissionRules().setPermissionBaseName(qBitConfig.getProductPermissionBase()); + } table.setSourceQBitName(getQBitMetaData().getName()); if(table.getSections() != null) { diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java index 88a002f..a4c044e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java @@ -87,7 +87,8 @@ public class Product extends QRecordEntity .withRecordLabelFormat("%s") .withRecordLabelFields("name") .withUniqueKey(new UniqueKey("sku")) - .withSection(SectionFactory.defaultT1("id", "name", "sku", "isActive")) + .withUniqueKey(new UniqueKey("slug")) + .withSection(SectionFactory.defaultT1("id", "name", "slug", "sku", "isActive")) .withSection(new QFieldSection("pricing", new QIcon("payments"), Tier.T2, List.of("unitPrice", "currencyCode"))) .withSection(SectionFactory.defaultT2("description")) @@ -145,10 +146,13 @@ public class Product extends QRecordEntity @QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR) private String sku; + @QField(maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.ERROR) + private String slug; + @QField() private String description; - @QField(isRequired = true) + @QField() private BigDecimal unitPrice; @QField(maxLength = 3, valueTooLongBehavior = ValueTooLongBehavior.ERROR, defaultValue = "USD") @@ -184,6 +188,37 @@ public class Product extends QRecordEntity + /******************************************************************************* + ** Optional stable catalog slug for cross-module references. + *******************************************************************************/ + public String getSlug() + { + return (slug); + } + + + + /******************************************************************************* + ** Set the catalog slug. + *******************************************************************************/ + public void setSlug(String slug) + { + this.slug = slug; + } + + + + /******************************************************************************* + ** Set the catalog slug fluently. + *******************************************************************************/ + public Product withSlug(String slug) + { + setSlug(slug); + return (this); + } + + + /******************************************************************************* ** Getter for id *******************************************************************************/ diff --git a/src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql b/src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql new file mode 100644 index 0000000..67b726d --- /dev/null +++ b/src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +-- Run after the existing CRM base schema, before deploying shared catalog metadata. +-- Existing product identities, prices, deal-line amounts and tenant keys are preserved. +--changeset qbit-crm:crm_product_shared_catalog +ALTER TABLE crm_product ADD COLUMN slug VARCHAR(64); +ALTER TABLE crm_product ADD CONSTRAINT uq_crm_product_slug UNIQUE (slug); +ALTER TABLE crm_product ALTER COLUMN unit_price DROP NOT NULL; diff --git a/src/main/resources/help/qbit-crm.yaml b/src/main/resources/help/qbit-crm.yaml index 8ed3c0c..b3c4fb5 100644 --- a/src/main/resources/help/qbit-crm.yaml +++ b/src/main/resources/help/qbit-crm.yaml @@ -521,7 +521,7 @@ entries: content: | Use **{{label:crm_product}}** to review products and services available as deal line items. - **How rows get created:** Add your products or services with **New**, including their prices and currencies. You can then select them on deal product lines. + **How rows get created:** Add your products or services with **New**, with a price and currency when known. Informational products may leave the catalog price blank; a deal line still requires its own agreed unit price. - key: table:crm_product;section:identity summary: "The identifying details for this record." - key: table:crm_product;section:pricing @@ -545,7 +545,9 @@ entries: - key: table:crm_product;field:modifyDate summary: "When this record was last changed." - key: table:crm_product;field:unitPrice - summary: "The price for one unit of the product." + summary: "Optional catalog price per unit. Leave blank when not priced; deal lines require an agreed price." + - key: table:crm_product;field:slug + summary: "Optional unique stable identifier for this shared catalog product, up to 64 characters." - key: table:crm_product;field:sku summary: "The product code used to identify the item." - key: table:crm_deal_stage_history;slot:help diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmPermissionsTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmPermissionsTest.java index b8338d0..8d32a67 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmPermissionsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmPermissionsTest.java @@ -37,6 +37,52 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class CrmPermissionsTest extends BaseTest { + /******************************************************************************* + ** Legacy hosts retain management access; the shared catalog is opt-in. + *******************************************************************************/ + @Test + void testDefaultCatalogPermissions() + { + QueryInput input = new QueryInput("crm_product"); + QContext.getQSession().setPermissions(Set.of(CrmPermissions.MANAGE_READ, CrmPermissions.MANAGE_WRITE)); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isTrue(); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.EDIT)).isTrue(); + QContext.getQSession().setPermissions(Set.of("crm.product.read")); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isFalse(); + } + + + + /******************************************************************************* + ** Native catalog read grants cannot write or open unrelated CRM settings. + *******************************************************************************/ + @Test + void testSharedCatalogPermissions() throws Exception + { + com.kingsrook.qqq.backend.core.model.metadata.QInstance instance = new com.kingsrook.qqq.backend.core.model.metadata.QInstance(); + instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData() + .withName("anonymous").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS)); + instance.addBackend(QContext.getQInstance().getBackend(BACKEND_NAME)); + new CrmQBitProducer().withQBitConfig(new CrmQBitConfig().withDefaultBackendNameForTables(BACKEND_NAME) + .withProductPermissionBase("crm.product")).produce(instance).addSelfToInstance(instance); + QContext.init(instance, QContext.getQSession()); + QueryInput input = new QueryInput("crm_product"); + QContext.getQSession().setPermissions(Set.of("crm.product.read")); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.READ)).isTrue(); + for(TablePermissionSubType write : Set.of(TablePermissionSubType.INSERT, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE)) + { + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", write)).isFalse(); + } + assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.READ)).isFalse(); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_contact", TablePermissionSubType.READ)).isFalse(); + assertThat(PermissionsHelper.hasProcessPermission(input, "crm_merge_contacts")).isFalse(); + QContext.getQSession().setPermissions(Set.of(CrmPermissions.MANAGE_READ, CrmPermissions.MANAGE_WRITE, "crm.product.read", "crm.product.write")); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_product", TablePermissionSubType.EDIT)).isTrue(); + assertThat(PermissionsHelper.hasTablePermission(input, "crm_pipeline", TablePermissionSubType.EDIT)).isTrue(); + } + + + /******************************************************************************* ** A read grant does not allow writes or settings access. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java index 4a3c211..6e41982 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmQBitConfigTest.java @@ -41,6 +41,22 @@ import static org.assertj.core.api.Assertions.assertThat; class CrmQBitConfigTest { + /******************************************************************************* + ** Catalog permission bases must be explicit; legacy behavior is the default. + *******************************************************************************/ + @Test + void testCatalogPermissionBase() + { + CrmQBitConfig config = new CrmQBitConfig().withDefaultBackendNameForTables("memory"); + assertThat(config.getProductPermissionBase()).isEqualTo("crm.manage"); + config.setProductPermissionBase(" "); + List errors = new ArrayList<>(); + config.validate(new QInstance(), errors); + assertThat(errors).contains("CrmQBitConfig.productPermissionBase is required"); + } + + + /******************************************************************************* ** Test all fluent setters and corresponding getters. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java index 7cfe005..12df99c 100644 --- a/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/CrmRdbmsTest.java @@ -281,6 +281,7 @@ class CrmRdbmsTest var freshColumns = schemaColumns(); executeSql("DROP ALL OBJECTS;"); executeSql(changelog); + executeSql(Files.readString(Path.of("src/main/resources/db/liquibase/qbit-crm/crm-product-catalog.changelog.sql"))); assertThat(schemaColumns()).isEqualTo(freshColumns); insert("crm_contact", new QRecord().withValue("email", "email-only@example.test")); } diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/DealProductTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/DealProductTest.java index 81cebf2..ee9aab1 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/DealProductTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/DealProductTest.java @@ -52,6 +52,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; class DealProductTest extends BaseTest { + /******************************************************************************* + ** Optional catalog pricing does not make the agreed line price optional. + *******************************************************************************/ + @Test + void testUnpricedCatalogStillRequiresLinePrice() throws QException + { + Integer productId = insertProduct("Unpriced service", "UNPRICED", null); + InsertOutput missing = new InsertAction().execute(new InsertInput(DealProduct.TABLE_NAME) + .withRecordEntity(new DealProduct().withDealId(1).withProductId(productId).withQuantity(BigDecimal.ONE))); + assertThat(missing.getRecords().get(0).getErrors()).isNotEmpty(); + InsertOutput agreed = new InsertAction().execute(new InsertInput(DealProduct.TABLE_NAME) + .withRecordEntity(new DealProduct().withDealId(1).withProductId(productId).withQuantity(new BigDecimal("2")) + .withUnitPrice(new BigDecimal("12.50")))); + assertThat(agreed.getRecords().get(0).getErrors()).isEmpty(); + assertThat(new QueryAction().execute(new QueryInput(DealProduct.TABLE_NAME)).getRecords().get(0).getValueBigDecimal("totalAmount")).isEqualByComparingTo("25.00"); + } + + + /******************************************************************************* ** Verify the table is registered with the correct icon. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/ProductCatalogMigrationTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/ProductCatalogMigrationTest.java new file mode 100644 index 0000000..008d837 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/deals/ProductCatalogMigrationTest.java @@ -0,0 +1,76 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2026. 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.crm.deals; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.UUID; +import liquibase.Liquibase; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Upgrade an existing catalog without changing prices or product identities. + *******************************************************************************/ +class ProductCatalogMigrationTest +{ + /******************************************************************************* + ** The real additive changeset preserves priced rows and permits unpriced + ** products while enforcing non-null slug uniqueness. + *******************************************************************************/ + @Test + void upgradesPopulatedCatalog() throws Exception + { + try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:catalog_" + UUID.randomUUID() + ";MODE=PostgreSQL")) + { + connection.createStatement().execute("CREATE TABLE crm_product (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(250) NOT NULL, unit_price DECIMAL(20,6) NOT NULL)"); + connection.createStatement().execute("INSERT INTO crm_product(id,name,unit_price) VALUES (42,'Existing product',77.25)"); + try(Liquibase liquibase = new Liquibase("db/liquibase/qbit-crm/crm-product-catalog.changelog.sql", + new ClassLoaderResourceAccessor(), new JdbcConnection(connection))) + { + liquibase.update(); + liquibase.update(); + try(var result = connection.createStatement().executeQuery("SELECT id,unit_price FROM crm_product")) + { + assertThat(result.next()).isTrue(); + assertThat(result.getInt("id")).isEqualTo(42); + assertThat(result.getBigDecimal("unit_price")).isEqualByComparingTo("77.25"); + } + connection.createStatement().execute("INSERT INTO crm_product(name,slug) VALUES ('Informational','info')"); + connection.createStatement().execute("INSERT INTO crm_product(name) VALUES ('No slug one'),('No slug two')"); + assertThatThrownBy(() -> connection.createStatement().execute("INSERT INTO crm_product(name,slug) VALUES ('Duplicate','info')")) + .isInstanceOf(SQLException.class); + try(var result = connection.createStatement().executeQuery("SELECT unit_price FROM crm_product WHERE slug='info'")) + { + assertThat(result.next()).isTrue(); + assertThat(result.getBigDecimal(1)).isNull(); + } + } + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/ProductTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/ProductTest.java index 2481816..8af6293 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/ProductTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/ProductTest.java @@ -54,6 +54,28 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ProductTest extends BaseTest { + /******************************************************************************* + ** An informational catalog product has no invented price; slugs are unique. + *******************************************************************************/ + @Test + void testUnpricedCatalogProductAndSlug() throws QException + { + InsertOutput first = new InsertAction().execute(new InsertInput(Product.TABLE_NAME) + .withRecordEntity(new Product().withName("Documentation toolkit").withSlug("toolkit"))); + assertThat(first.getRecords().get(0).getErrors()).isEmpty(); + Product stored = new Product(new QueryAction().execute(new QueryInput(Product.TABLE_NAME)).getRecords().get(0)); + assertThat(stored.getSlug()).isEqualTo("toolkit"); + assertThat(stored.getUnitPrice()).isNull(); + InsertOutput duplicate = new InsertAction().execute(new InsertInput(Product.TABLE_NAME) + .withRecordEntity(new Product().withName("Other toolkit").withSlug("toolkit"))); + assertThat(duplicate.getRecords().get(0).getErrors()).isNotEmpty(); + InsertOutput optional = new InsertAction().execute(new InsertInput(Product.TABLE_NAME) + .withRecords(List.of(new Product().withName("No slug one").toQRecord(), new Product().withName("No slug two").toQRecord()))); + assertThat(optional.getRecords()).allSatisfy(record -> assertThat(record.getErrors()).isEmpty()); + } + + + /******************************************************************************* ** Verify the table and PVS are registered. *******************************************************************************/ -- 2.54.0