Compare commits

..
Author SHA1 Message Date
james.maesandCodex d2c2f8a9b8 ci: centralize QBit source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:20:46 -05:00
james.maesandCodex e422092fd4 ci: adopt central orb 0.7.12 and stable gates (#28)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:53:07 -05:00
james.maesandCodex 70f841c256 ci: centralize rc release completion in qqq orb (#27)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:25:01 -05:00
james.maesandCodex 730bb68428 ci: adopt shared orb 0.7.11 (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:56:34 -05:00
james.maes 62c393374d Merge pull request #25 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:20:28 -05:00
james.maesandCodex c380dce8b6 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:02:42 -05:00
19 changed files with 118 additions and 362 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -14
View File
@@ -1,26 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
-3
View File
@@ -2,9 +2,6 @@
## 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.
-1
View File
@@ -56,7 +56,6 @@ 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.
-17
View File
@@ -1,22 +1,5 @@
# 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,
+2 -4
View File
@@ -565,15 +565,13 @@ 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),
unit_price DECIMAL(20,6) NOT NULL,
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_slug UNIQUE (slug)
CONSTRAINT uq_crm_product_1 UNIQUE (sku)
);
CREATE TABLE IF NOT EXISTS crm_sales_goal (
@@ -41,7 +41,6 @@ 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;
@@ -70,45 +69,12 @@ public class CrmQBitConfig implements QBitConfig
@Override
public void validate(com.kingsrook.qqq.backend.core.model.metadata.QInstance qInstance, List<String> 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
*******************************************************************************/
@@ -208,10 +208,6 @@ public class CrmQBitProducer implements QBitMetaDataProducer<CrmQBitConfig>
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)
{
@@ -87,8 +87,7 @@ public class Product extends QRecordEntity
.withRecordLabelFormat("%s")
.withRecordLabelFields("name")
.withUniqueKey(new UniqueKey("sku"))
.withUniqueKey(new UniqueKey("slug"))
.withSection(SectionFactory.defaultT1("id", "name", "slug", "sku", "isActive"))
.withSection(SectionFactory.defaultT1("id", "name", "sku", "isActive"))
.withSection(new QFieldSection("pricing", new QIcon("payments"), Tier.T2,
List.of("unitPrice", "currencyCode")))
.withSection(SectionFactory.defaultT2("description"))
@@ -146,13 +145,10 @@ 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()
@QField(isRequired = true)
private BigDecimal unitPrice;
@QField(maxLength = 3, valueTooLongBehavior = ValueTooLongBehavior.ERROR, defaultValue = "USD")
@@ -188,37 +184,6 @@ 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
*******************************************************************************/
@@ -1,7 +0,0 @@
--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;
+2 -4
View File
@@ -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**, 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.
**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.
- key: table:crm_product;section:identity
summary: "The identifying details for this record."
- key: table:crm_product;section:pricing
@@ -545,9 +545,7 @@ entries:
- key: table:crm_product;field:modifyDate
summary: "When this record was last changed."
- key: table:crm_product;field:unitPrice
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."
summary: "The price for one unit of the product."
- key: table:crm_product;field:sku
summary: "The product code used to identify the item."
- key: table:crm_deal_stage_history;slot:help
@@ -37,52 +37,6 @@ 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.
*******************************************************************************/
@@ -41,22 +41,6 @@ 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<String> errors = new ArrayList<>();
config.validate(new QInstance(), errors);
assertThat(errors).contains("CrmQBitConfig.productPermissionBase is required");
}
/*******************************************************************************
** Test all fluent setters and corresponding getters.
*******************************************************************************/
@@ -281,7 +281,6 @@ 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"));
}
@@ -52,25 +52,6 @@ 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.
*******************************************************************************/
@@ -1,76 +0,0 @@
/*
* 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();
}
}
}
}
}
@@ -54,28 +54,6 @@ 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.
*******************************************************************************/