diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd5b225..a5e78ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,8 @@ -# ============================================================ -# CI — Continuous Integration -# -# Triggered on: -# • push → main (every merge — post-merge validation) -# • pull_request → main (every PR — pre-merge validation) -# -# Restricting push to main avoids duplicate job runs: when a -# feature branch has an open PR, GitHub fires both push and -# pull_request events. Limiting push to main means each commit -# is evaluated exactly once. -# -# Two independent jobs run in parallel: -# -# lint → Spotless code-style check -# build → Compile + tests (unit + Testcontainers) + JaCoCo -# -# A synthetic `ci-success` job aggregates the two results so -# branch-protection rules only need to track one required check. -# ============================================================ name: CI on: push: - branches: [ main ] # feature branches are covered by pull_request — avoids duplicate runs + branches: [ main ] paths-ignore: - "**.md" - "LICENSE" @@ -37,284 +17,28 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/pull_request_template.md" - ".github/SECURITY.md" + workflow_dispatch: + inputs: + run-integration-tests: + description: "Run integration tests (manual opt-in, useful for PR branches)" + type: boolean + default: true -# Cancel in-progress runs for the same branch/PR (keep only the latest) concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -env: - JAVA_VERSION: "25" - MAVEN_OPTS: >- - -Xmx2048m - -XX:+EnableDynamicAgentLoading - -Dmaven.repo.local=${{ github.workspace }}/.m2/repository - -# Minimal default permissions — each job declares only what it needs permissions: contents: read -# ────────────────────────────────────────────────────────────── jobs: - - # ── 1. Code Style ───────────────────────────────────────── - lint: - name: 🎨 Code Style (Spotless) - runs-on: ubuntu-latest - permissions: - contents: read # checkout only - - steps: - - name: Checkout - uses: actions/checkout@v7 # v6: credentials stored in $RUNNER_TEMP, not .git/config - - - name: Set up Java ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - cache: maven - - - name: Spotless — check formatting - run: mvn spotless:check --no-transfer-progress - - # ── 2. Build, Test & Coverage ──────────────────────────── - build: - name: 🔨 Build, Test & Coverage - runs-on: ubuntu-latest - permissions: - contents: read # checkout only - pull-requests: write # post / update the sticky CI report comment - - steps: - - name: Checkout - uses: actions/checkout@v7 # v6: credentials stored in $RUNNER_TEMP, not .git/config - - - name: Set up Java ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: ${{ env.JAVA_VERSION }} - cache: maven - - # Testcontainers: disable Ryuk to avoid permission issues on GH runners - - name: Build, run tests & enforce coverage (≥ 70 %) - run: mvn verify --no-transfer-progress -Dspotless.check.skip=true - env: - TESTCONTAINERS_RYUK_DISABLED: "true" - - # Always upload the HTML + XML JaCoCo report so it is available - # for SonarCloud and for manual inspection - - name: Upload JaCoCo HTML report - if: always() - uses: actions/upload-artifact@v7 - with: - name: jacoco-html-${{ github.run_id }} - path: target/site/jacoco/ - retention-days: 14 - - # Upload the binary .exec — kept for future tooling integration - - name: Upload JaCoCo exec - if: > - github.ref == 'refs/heads/main' || - github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: jacoco-exec-${{ github.run_id }} - path: target/jacoco.exec - retention-days: 7 - - # Upload Surefire XML reports so GitHub can parse test results - - name: Upload Surefire reports - if: always() - uses: actions/upload-artifact@v7 - with: - name: surefire-reports-${{ github.run_id }} - path: target/surefire-reports/ - retention-days: 7 - - # ── Generate CI report ──────────────────────────────── - # Parses Surefire XML + JaCoCo XML and produces a single - # Markdown report written to: - # • $GITHUB_STEP_SUMMARY → visible in the Actions run page - # • /tmp/ci-report.md → picked up by the PR comment step - - name: 📊 Generate CI report (tests + coverage) - if: always() - run: | - python3 << 'PYEOF' - import xml.etree.ElementTree as ET - import glob, os - - # ── Surefire results ───────────────────────────── - # WHY we count elements instead of reading the - # root attribute: - # - # JUnit 5 with @Nested classes causes surefire to write - # *nested* elements inside the root one. - # The root's `tests` attribute then reflects only direct - # (non-nested) test methods, producing a count far below - # what Maven itself reports (e.g. 68 vs 613). - # - # Counting every descendant recursively gives - # the true total regardless of nesting depth. - total = failures = errors = skipped = 0 - failed_list = [] - - for path in sorted(glob.glob('target/surefire-reports/TEST-*.xml')): - try: - root = ET.parse(path).getroot() - suite_name = root.get('name', path) - - for tc in root.findall('.//testcase'): - total += 1 - if tc.find('failure') is not None: - failures += 1 - failed_list.append(f"{tc.get('classname', suite_name)}#{tc.get('name', '?')}") - elif tc.find('error') is not None: - errors += 1 - failed_list.append(f"{tc.get('classname', suite_name)}#{tc.get('name', '?')}") - elif tc.find('skipped') is not None: - skipped += 1 - except Exception as e: - print(f'Warning: could not parse {path}: {e}') - - passed = total - failures - errors - skipped - t_status = '✅' if failures + errors == 0 else '❌' - - # ── JaCoCo coverage ────────────────────────────── - line_pct = branch_pct = method_pct = 0.0 - line_cov = line_tot = branch_cov = branch_tot = method_cov = method_tot = 0 - cov_ok = False - - try: - root = ET.parse('target/site/jacoco/jacoco.xml').getroot() - for counter in root.findall('counter'): - ctype = counter.get('type') - covered = int(counter.get('covered', 0)) - missed = int(counter.get('missed', 0)) - tot = covered + missed - pct = (covered / tot * 100) if tot > 0 else 0.0 - if ctype == 'LINE': line_pct, line_cov, line_tot = pct, covered, tot - elif ctype == 'BRANCH': branch_pct, branch_cov, branch_tot = pct, covered, tot - elif ctype == 'METHOD': method_pct, method_cov, method_tot = pct, covered, tot - cov_ok = True - except Exception as e: - print(f'Warning: could not parse JaCoCo XML: {e}') - - threshold = 70.0 - c_status = '✅' if line_pct >= threshold else '⚠️' - - # ── Build the Markdown report ───────────────────── - lines = [] - lines += [f'## {t_status} Test Results\n'] - lines += ['| | Metric | Count |', '|:---:|:---|---:|'] - lines += [f'| ✅ | Passed | {passed} |'] - lines += [f'| ❌ | Failed | {failures + errors} |'] - lines += [f'| ⏭️ | Skipped | {skipped} |'] - lines += [f'| 📊 | **Total** | **{total}** |'] - - if failed_list: - lines += ['', '### ❌ Failing Tests'] - for t in failed_list[:15]: - lines += [f'- `{t}`'] - if len(failed_list) > 15: - lines += [f'- *… and {len(failed_list) - 15} more*'] - - lines += [''] - - if cov_ok: - lines += [f'## {c_status} Coverage\n'] - lines += ['| | Type | Coverage | Covered / Total |', '|:---:|:---|---:|---:|'] - lines += [f'| 📏 | Lines | **{line_pct:.1f}%** | {line_cov} / {line_tot} |'] - lines += [f'| 🌿 | Branches | {branch_pct:.1f}% | {branch_cov} / {branch_tot} |'] - lines += [f'| 🔧 | Methods | {method_pct:.1f}% | {method_cov} / {method_tot} |'] - if line_pct < threshold: - lines += ['', f'> ⚠️ Line coverage **{line_pct:.1f}%** is below the required **{threshold:.0f}%** threshold'] - else: - lines += ['## ⚠️ Coverage report not available'] - - report = '\n'.join(lines) + '\n' - - # Write to Actions job summary - with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f: - f.write(report) - - # Write to file for the PR comment step below - with open('/tmp/ci-report.md', 'w') as f: - f.write(report) - - print(report) - PYEOF - - # ── Sticky PR comment ───────────────────────────────── - # Posts a single comment that is updated (not duplicated) on - # every push to the same PR using a hidden HTML marker. - # Only runs on pull_request events — skipped on direct pushes. - - name: 💬 Post CI report to PR - if: ${{ always() && github.event_name == 'pull_request' }} - uses: actions/github-script@v9 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - if (!fs.existsSync('/tmp/ci-report.md')) { - console.log('CI report file not found — skipping comment.'); - return; - } - - const marker = ''; - const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; - const content = fs.readFileSync('/tmp/ci-report.md', 'utf8'); - const body = `${marker}\n${content}\n---\n*🔄 [CI run #${process.env.GITHUB_RUN_NUMBER}](${runUrl}) · ${new Date().toUTCString()}*`; - - // Find an existing comment with our marker - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - - const existing = comments.find(c => c.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - console.log(`✅ Updated CI report comment #${existing.id}`); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - console.log('✅ Created new CI report comment'); - } - - # ── 3. Aggregate status (single required check) ─────────── - ci-success: - name: ✅ CI passed - runs-on: ubuntu-latest - needs: [ lint, build ] - if: always() - permissions: - contents: read - steps: - - name: Evaluate results - run: | - lint="${{ needs.lint.result }}" - build="${{ needs.build.result }}" - echo "lint → $lint" - echo "build → $build" - if [[ "$lint" != "success" || "$build" != "success" ]]; then - echo "❌ One or more jobs failed." - exit 1 - fi - echo "✅ All CI jobs passed." + ci: + name: Maven CI + uses: ktestify/.github/.github/workflows/reusable-maven-ci.yml@main + with: + java-version: "25" + run-integration-tests: ${{ github.event_name == 'workflow_dispatch' && inputs.run-integration-tests || true }} + maven-opts: "" diff --git a/pom.xml b/pom.xml index f36d2f0..b90e14b 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.github.ktestify ktestify-parent - 1.0.2 + 1.0.5 @@ -40,7 +40,7 @@ scm:git:git://github.com/ktestify/ktestify-core.git - scm:git:ssh://github.com:ktestify/ktestify-core.git + scm:git:ssh://git@github.com/ktestify/ktestify-core.git https://github.com/ktestify/ktestify-core/tree/main @@ -105,7 +105,7 @@ slf4j-api - + org.apache.logging.log4j log4j-slf4j2-impl @@ -152,7 +152,7 @@ - + org.junit.jupiter junit-jupiter-api @@ -196,34 +196,12 @@ - + + org.jacoco jacoco-maven-plugin - - - jacoco-check - - check - - verify - - - - BUNDLE - - - LINE - COVEREDRATIO - 0.70 - - - - - - - diff --git a/src/main/java/io/github/ktestify/config/KtestifyConfig.java b/src/main/java/io/github/ktestify/config/KtestifyConfig.java index 154f7e3..e41e641 100644 --- a/src/main/java/io/github/ktestify/config/KtestifyConfig.java +++ b/src/main/java/io/github/ktestify/config/KtestifyConfig.java @@ -80,11 +80,11 @@ private KtestifyConfig(Config config) { *

Supported keys and their environment variable overrides: * *

    - *
  • {@code level} / {@code KTESTIFY_LOG_LEVEL} — {@code io.github.ktestify.*} - *
  • {@code root-level} / {@code KTESTIFY_ROOT_LOG_LEVEL} — root logger - *
  • {@code kafka-level} / {@code KTESTIFY_KAFKA_LOG_LEVEL} — {@code org.apache.kafka.*} - *
  • {@code testcontainers-level} / {@code KTESTIFY_TC_LOG_LEVEL} — Testcontainers + Docker Java - *
  • {@code confluent-level} / {@code KTESTIFY_CONFLUENT_LOG_LEVEL} — {@code io.confluent.*} + *
  • {@code level} / {@code KTESTIFY_LOG_LEVEL}: {@code io.github.ktestify.*} + *
  • {@code root-level} / {@code KTESTIFY_ROOT_LOG_LEVEL}: root logger + *
  • {@code kafka-level} / {@code KTESTIFY_KAFKA_LOG_LEVEL}: {@code org.apache.kafka.*} + *
  • {@code testcontainers-level} / {@code KTESTIFY_TC_LOG_LEVEL}: Testcontainers + Docker Java + *
  • {@code confluent-level} / {@code KTESTIFY_CONFLUENT_LOG_LEVEL}: {@code io.confluent.*} *
*/ private static void applyLogLevels(Config config) { @@ -107,7 +107,7 @@ private static void applyLogLevels(Config config) { ctx.updateLoggers(log4jConfig); log.debug( - "Log levels applied — ktestify={} root={} kafka={} confluent={}", + "Log levels applied: ktestify={} root={} kafka={} confluent={}", lc.getString("level"), lc.getString("root-level"), lc.getString("kafka-level"), @@ -145,7 +145,7 @@ private static void setLoggerLevel(Configuration log4jConfig, String loggerName, *
    *
  • {@code location} / {@code KTESTIFY_JVM_TRUSTSTORE_LOCATION} *
  • {@code password} / {@code KTESTIFY_JVM_TRUSTSTORE_PASSWORD} - *
  • {@code type} / {@code KTESTIFY_JVM_TRUSTSTORE_TYPE} — {@code JKS} or {@code PKCS12} + *
  • {@code type} / {@code KTESTIFY_JVM_TRUSTSTORE_TYPE}: {@code JKS} or {@code PKCS12} *
* * @param config the resolved Config object containing {@code ktestify.jvm.truststore} @@ -166,7 +166,7 @@ public static void applyJvmTruststore(Config config) { } log.info( - "JVM truststore applied — location={} type={}", + "JVM truststore applied: location={} type={}", location, config.getString("ktestify.jvm.truststore.type")); } diff --git a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java index 457d0f1..ca1719b 100644 --- a/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java +++ b/src/main/java/io/github/ktestify/config/SchemaRegistryConfig.java @@ -74,7 +74,7 @@ public final class SchemaRegistryConfig { this.sslKeystorePassword = getOptionalString(sslConfig, "keystore-password"); LOG.debug( - "SchemaRegistryConfig loaded — url={}, autoRegister={}, auth.credentialsSource={}, auth.userInfo={}", + "SchemaRegistryConfig loaded: url={}, autoRegister={}, auth.credentialsSource={}, auth.userInfo={}", this.url, this.autoRegisterSchemas, this.basicAuthCredentialsSource.orElse(""), diff --git a/src/main/java/io/github/ktestify/exceptions/ComparisonException.java b/src/main/java/io/github/ktestify/exceptions/ComparisonException.java index 5179be1..cf6fc97 100644 --- a/src/main/java/io/github/ktestify/exceptions/ComparisonException.java +++ b/src/main/java/io/github/ktestify/exceptions/ComparisonException.java @@ -45,4 +45,16 @@ public class ComparisonException extends RuntimeException { public ComparisonException(String message) { super(message); } + + /** + * Constructs a new {@code ComparisonException} with the supplied detail message and cause. + * + * @param message a human-readable description of the comparison failure; shown directly in test reports so it + * should be as specific as possible + * @param cause the underlying exception that caused this comparison failure (e.g. an {@code IOException} from + * reading an expected file) + */ + public ComparisonException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java index c6b79e1..e314da1 100644 --- a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java @@ -15,9 +15,36 @@ */ package io.github.ktestify.exceptions; +/** + * Thrown by the orchestration layer when a consumer operation fails. + * + *

Typically wraps a {@link FetchException} from the transport layer. Also thrown for orchestration-level + * configuration errors such as consuming from an INPUT topic or a null consumer context. + * + *

This is the only exception that test-framework adapters (Cucumber steps, Robot Framework keywords, …) are expected + * to catch and surface as a human-readable assertion failure. + * + * @since 0.3.0 + * @see FetchException + */ public class ConsumerException extends RuntimeException { + /** + * Constructs a new {@code ConsumerException} with the supplied detail message. + * + * @param message a human-readable description of the consumer failure + */ public ConsumerException(String message) { super(message); } + + /** + * Constructs a new {@code ConsumerException} with the supplied detail message and cause. + * + * @param message a human-readable description of the consumer failure + * @param cause the underlying exception that caused this failure + */ + public ConsumerException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/exceptions/FetchException.java b/src/main/java/io/github/ktestify/exceptions/FetchException.java index 6692602..546365f 100644 --- a/src/main/java/io/github/ktestify/exceptions/FetchException.java +++ b/src/main/java/io/github/ktestify/exceptions/FetchException.java @@ -19,8 +19,8 @@ * Thrown when a {@code RecordFetcher} fails to retrieve records from an IO source (timeout, connectivity issue, * authentication failure, etc.). * - *

This exception is transport-agnostic — Kafka, IBM MQ, and any future IO adapter all throw {@code FetchException} - * so higher layers do not need to catch transport-specific exceptions. + *

This exception is transport-agnostic: Kafka, IBM MQ, and any future IO adapter all throw {@code FetchException} so + * higher layers do not need to catch transport-specific exceptions. * * @since 0.3.0 */ diff --git a/src/main/java/io/github/ktestify/exceptions/FetchTimeoutException.java b/src/main/java/io/github/ktestify/exceptions/FetchTimeoutException.java new file mode 100644 index 0000000..b966215 --- /dev/null +++ b/src/main/java/io/github/ktestify/exceptions/FetchTimeoutException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * 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 + * + * http://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 io.github.ktestify.exceptions; + +/** + * Specialized {@link FetchException} signaling that the fetch failed due to timeout budget exhaustion. + * + *

This lets higher layers distinguish expected timeout outcomes from other infra/config failures using type checks + * instead of parsing error messages. + */ +public class FetchTimeoutException extends FetchException { + + public FetchTimeoutException(String message) { + super(message); + } + + public FetchTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/io/github/ktestify/exceptions/PluginException.java b/src/main/java/io/github/ktestify/exceptions/PluginException.java index 761ec96..59c5c2e 100644 --- a/src/main/java/io/github/ktestify/exceptions/PluginException.java +++ b/src/main/java/io/github/ktestify/exceptions/PluginException.java @@ -19,7 +19,7 @@ * Thrown when a {@link io.github.ktestify.plugin.KtestifyPlugin} fails to load or initialize. * *

A {@code PluginException} during - * {@link io.github.ktestify.plugin.PluginRegistry#load(io.github.ktestify.plugin.PluginContext)} is fatal — the run is + * {@link io.github.ktestify.plugin.PluginRegistry#load(io.github.ktestify.plugin.PluginContext)} is fatal: the run is * aborted immediately. This prevents silent partial initialization where some plugins are active and others are not. * * @since 1.1.0 diff --git a/src/main/java/io/github/ktestify/exceptions/ProducerException.java b/src/main/java/io/github/ktestify/exceptions/ProducerException.java index 5db3a01..ec7cedc 100644 --- a/src/main/java/io/github/ktestify/exceptions/ProducerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ProducerException.java @@ -15,8 +15,32 @@ */ package io.github.ktestify.exceptions; +/** + * Thrown when a Kafka producer fails to send a record: e.g. broker unreachable, serialization failure, interrupted + * during send, or schema/payload resolution error. + * + *

This is a {@link RuntimeException} so callers are not forced to declare it in their {@code throws} clause. + * + * @since 0.3.0 + */ public class ProducerException extends RuntimeException { + + /** + * Constructs a new {@code ProducerException} with the supplied detail message. + * + * @param message a human-readable description of the producer failure + */ public ProducerException(String message) { super(message); } + + /** + * Constructs a new {@code ProducerException} with the supplied detail message and cause. + * + * @param message a human-readable description of the producer failure + * @param cause the underlying exception that caused this failure + */ + public ProducerException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java b/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java index 188352a..14f459f 100644 --- a/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java +++ b/src/main/java/io/github/ktestify/exceptions/TopicMismatchException.java @@ -22,7 +22,7 @@ * call) resolves more than one distinct topic, where exactly one is required. * *

This is a guard-rail exception: a DataTable listing several instructions is only allowed to target a single topic - * per call. Mixing topics in one DataTable is almost always an authoring mistake — split it into separate step + * per call. Mixing topics in one DataTable is almost always an authoring mistake: split it into separate step * invocations instead. * * @since 0.4.0 diff --git a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java index 9eac21d..241d600 100644 --- a/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java +++ b/src/main/java/io/github/ktestify/io/core/AbstractSynchronousConsumer.java @@ -93,7 +93,7 @@ protected AbstractSynchronousConsumer( * *

    *
  1. Build the request via {@link #buildRequest()}. - *
  2. Call {@link RequestResponseClient#execute(Object)} — blocks until the response arrives or fails. + *
  3. Call {@link RequestResponseClient#execute(Object)}: blocks until the response arrives or fails. *
  4. Pass the resulting records to {@link RecordMatcher#match(List, MatchContext)}. *
* @@ -113,7 +113,7 @@ public Boolean call() throws ConsumerException { return result.isPassed(); } catch (FetchException e) { - throw new ConsumerException(e.getMessage()); + throw new ConsumerException(e.getMessage(), e); } } } diff --git a/src/main/java/io/github/ktestify/io/core/RecordFetcher.java b/src/main/java/io/github/ktestify/io/core/RecordFetcher.java index 5200689..22d80d6 100644 --- a/src/main/java/io/github/ktestify/io/core/RecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/core/RecordFetcher.java @@ -25,8 +25,8 @@ *

Implementations exist per transport: * *

    - *
  • {@code KafkaRecordFetcher} — Apache Kafka - *
  • {@code IbmMqRecordFetcher} — IBM MQ (future) + *
  • {@code KafkaRecordFetcher}: Apache Kafka + *
  • {@code IbmMqRecordFetcher}: IBM MQ (future) *
* *

The return type {@link ConsumedRecord} is the common currency shared between the transport layer and the assertion @@ -48,7 +48,7 @@ public interface RecordFetcher extends AutoCloseable { List> fetch() throws FetchException; /** - * Releases all resources held by this fetcher (connections, threads, etc.). Idempotent — calling {@code close()} + * Releases all resources held by this fetcher (connections, threads, etc.). Idempotent: calling {@code close()} * more than once must be safe. */ @Override diff --git a/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java b/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java index 4dc4c31..bce4e88 100644 --- a/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java +++ b/src/main/java/io/github/ktestify/io/core/RequestResponseClient.java @@ -32,8 +32,8 @@ *

Implementations exist per transport: * *

    - *
  • {@code HttpRequestResponseClient} — HTTP / HTTPS (ktestify-plugin-http) - *
  • {@code GrpcRequestResponseClient} — gRPC (future) + *
  • {@code HttpRequestResponseClient}: HTTP / HTTPS (ktestify-plugin-http) + *
  • {@code GrpcRequestResponseClient}: gRPC (future) *
* * @param the request type specific to the transport (e.g. an HTTP request spec) diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java index 8735dfd..da00d9e 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java @@ -19,43 +19,91 @@ import io.github.ktestify.io.inputs.types.EnvironmentVariable; import io.github.ktestify.io.inputs.types.RandomVariable; import io.github.ktestify.io.inputs.types.TimestampVariable; -import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.experimental.UtilityClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Thread-safe registry of {@link DynamicVariable} implementations, keyed by variable name. + * + *

Names are case-insensitive: {@code {{env:HOME}}}, {@code {{ENV:HOME}}} and {@code {{Env:HOME}}} all resolve to the + * same {@link EnvironmentVariable}. The built-in variables ({@code date}, {@code timestamp}, {@code random}, + * {@code env}) are registered at class initialization and can be restored with {@link #resetToDefaults()}. + * + * @since 0.1.0 + */ @UtilityClass public class DynamicVariableFactory { - private static final Map variables = new HashMap<>(); + private static final Map variables = new ConcurrentHashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableFactory.class); static { - registerVariable(new DateVariable()); - registerVariable(new TimestampVariable()); - registerVariable(new RandomVariable()); - registerVariable(new EnvironmentVariable()); + resetToDefaults(); } + /** + * Registers a variable under its {@link DynamicVariable#getName() name}, replacing any variable already registered + * under the same name (case-insensitive). + * + * @param variable the variable to register + */ public static void registerVariable(DynamicVariable variable) { LOGGER.debug("Registering variable {}.", variable.getName()); - variables.put(variable.getName(), variable); + variables.put(normalize(variable.getName()), variable); } + /** + * Returns the variable registered under {@code name}. + * + * @param name the variable name, case-insensitive + * @return the variable, or {@code null} if none is registered + */ public static DynamicVariable getVariable(String name) { - return variables.get(name); + return name == null ? null : variables.get(normalize(name)); } + /** + * Returns whether a variable is registered under {@code name}. + * + * @param name the variable name, case-insensitive + * @return {@code true} if a variable is registered + */ public static boolean isRegistered(String name) { - return variables.containsKey(name); + return name != null && variables.containsKey(normalize(name)); } + /** + * Returns an immutable snapshot of the registered (lower-case) variable names. + * + * @return the registered names + */ public static Set getRegisteredVariableNames() { - return variables.keySet(); + return Set.copyOf(variables.keySet()); } + /** Removes every registered variable, including the built-ins. Use {@link #resetToDefaults()} to restore them. */ public static void clearRegisteredVariables() { variables.clear(); } + + /** + * Clears the registry and registers the built-in variables again. + * + * @since 1.1.4 + */ + public static void resetToDefaults() { + variables.clear(); + registerVariable(new DateVariable()); + registerVariable(new TimestampVariable()); + registerVariable(new RandomVariable()); + registerVariable(new EnvironmentVariable()); + } + + private static String normalize(String name) { + return name.toLowerCase(Locale.ROOT); + } } diff --git a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java index 8c462fb..09a2619 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableProcessor.java @@ -23,7 +23,7 @@ public class DynamicVariableProcessor { private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableProcessor.class); - private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)(?::([^}]*?))?\\}\\}"); + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)(?::([^}]*?))?}}"); private static final int GROUP_NAME = 1; private static final int GROUP_FORMAT = 2; @@ -34,7 +34,7 @@ public String process(String input) { } Matcher matcher = VARIABLE_PATTERN.matcher(input); - StringBuffer sb = new StringBuffer(); + StringBuilder sb = new StringBuilder(); // Check if any matches exist if (!matcher.find()) { diff --git a/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java index 2fbb782..587730b 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/EnvironmentVariable.java @@ -15,6 +15,7 @@ */ package io.github.ktestify.io.inputs.types; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.io.inputs.DynamicVariable; public class EnvironmentVariable implements DynamicVariable { @@ -26,8 +27,14 @@ public String getName() { @Override public String process(String format) { if (format == null || format.isEmpty()) { - throw new IllegalArgumentException("Environment variable name cannot be null or empty"); + throw ConfigException.missingValue("env"); } - return System.getenv(format); + + String value = System.getenv(format); + if (value == null) { + throw new ConfigException("Environment variable '" + format + "' is not set"); + } + + return value; } } diff --git a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java index df37f75..6c4776f 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/RandomVariable.java @@ -93,6 +93,6 @@ private String generateRandomNumber(int length) { } private static String generateRandomString(int length) { - return RandomStringUtils.randomAlphanumeric(length); + return RandomStringUtils.secure().nextAlphanumeric(length); } } diff --git a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java index b29a522..166bbed 100644 --- a/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java +++ b/src/main/java/io/github/ktestify/io/inputs/types/TimestampVariable.java @@ -27,6 +27,9 @@ public String getName() { @Override public String process(String format) { + if (format == null || format.isEmpty()) { + return process(); + } return LocalDateTime.now().format(DateTimeFormatter.ofPattern(format)); } diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java index 9acb169..1966b1c 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaConsumer.java @@ -36,8 +36,8 @@ * responsibilities belong exclusively to their respective collaborators. The only decision made here is: fetch → match * → return result. * - *

Concrete subclasses only need to supply a {@link RecordMatcher} — typically resolved by - * {@code RecordMatcherFactory} — and call the {@link #AbstractKafkaConsumer(ConsumerContext, RecordMatcher)} + *

Concrete subclasses only need to supply a {@link RecordMatcher}: typically resolved by + * {@code RecordMatcherFactory}: and call the {@link #AbstractKafkaConsumer(ConsumerContext, RecordMatcher)} * constructor. * * @param the Kafka record key type @@ -95,7 +95,7 @@ protected AbstractKafkaConsumer( * *

    *
  1. Create a {@link KafkaRecordFetcher} for this invocation. - *
  2. Call {@link KafkaRecordFetcher#fetch()} — blocks until records arrive or timeout. + *
  3. Call {@link KafkaRecordFetcher#fetch()}: blocks until records arrive or timeout. *
  4. Pass the fetched records to {@link RecordMatcher#match(List, MatchContext)}. *
  5. Close the fetcher unconditionally in a {@code finally} block. *
@@ -119,7 +119,7 @@ public Boolean call() throws ConsumerException { return result.isPassed(); } catch (FetchException e) { - throw new ConsumerException(e.getMessage()); + throw new ConsumerException(e.getMessage(), e); } } diff --git a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java index 3b2d501..c6f9a68 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java @@ -17,6 +17,7 @@ import io.confluent.kafka.schemaregistry.client.SchemaRegistryClient; import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.core.AbstractProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.utils.FileUtils; @@ -114,7 +115,7 @@ protected String resolvePayload() { if (payloadFile != null) { return FileUtils.getFileContent(payloadFile); } - throw new IllegalStateException("No payload content was provided"); + throw new ProducerException("No payload content was provided"); } protected String resolveSchema() throws IOException, RestClientException { diff --git a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java index 045209e..1955072 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -20,16 +20,14 @@ import io.github.ktestify.config.FrameworkConfig; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.FetchException; +import io.github.ktestify.exceptions.FetchTimeoutException; import io.github.ktestify.io.core.RecordFetcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.models.MatchedRecord; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.Consumer; @@ -52,7 +50,7 @@ * * *

This class knows nothing about matching or assertions. It produces {@link ConsumedRecord} objects - * and hands them to whoever called {@link #fetch()} — typically an {@code AbstractKafkaConsumer} subclass. + * and hands them to whoever called {@link #fetch()}: typically an {@code AbstractKafkaConsumer} subclass. * * @param the Kafka record key type * @param the Kafka record value type @@ -105,23 +103,30 @@ public KafkaRecordFetcher(ConsumerContext context) { /** * {@inheritDoc} * - *

Blocks until at least one record that passes the key-filter is found, or {@code readTimeoutMs} elapses. + *

Blocks until at least one record that passes the key-filter is found, or {@code readTimeoutMs} elapses. The + * read timeout is a single budget that covers partition assignment, offset lookup, and polling. * - * @throws FetchException if the timeout expires without a matching record, or if the Kafka consumer is woken up - * externally + * @throws FetchException if the timeout expires without a matching record, if partition assignment or offset lookup + * does not complete in time, or if the Kafka consumer is woken up externally */ @Override public List> fetch() throws FetchException { + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(readTimeoutMs); try { - subscribeAndAwaitAssignment(); + subscribeAndAwaitAssignment(deadlineNanos); long delta = calculateDeltaTime(); - seekToOffset(delta); - return pollUntilRecordFound(); + seekToOffset(delta, deadlineNanos); + return pollUntilRecordFound(deadlineNanos); } catch (WakeupException e) { throw new FetchException( "Kafka consumer was woken up for shutdown on topic '" + context.getTopic().getNamespacedTopic() + "'", e); + } catch (org.apache.kafka.common.errors.TimeoutException e) { + throw new FetchException( + "Kafka operation timed out on topic '" + context.getTopic().getNamespacedTopic() + "': " + + e.getMessage(), + e); } } @@ -148,8 +153,9 @@ public void close() { /** * Clears the shared deduplication registry. * - *

Call this at the start of each independent test scenario (e.g. in a {@code @Before} / {@code Before} hook) to - * ensure records from a previous scenario are not filtered out. + *

The registry is intentionally not cleared automatically between scenarios, so that a record + * already matched by an earlier scenario cannot be matched again later in the same run. Call this method only for + * an explicit reset, for example from the {@code clear known messages} step. */ public static void clearMatchedRecords() { MATCHED_RECORDS.clear(); @@ -157,17 +163,27 @@ public static void clearMatchedRecords() { } // ========================================================================= - // Private — Kafka mechanics (previously inside AbstractKafkaConsumer) + // Private: Kafka mechanics // ========================================================================= - /** Subscribes to the configured topic and blocks until Kafka assigns partitions. */ - private void subscribeAndAwaitAssignment() { + /** + * Subscribes to the configured topic and blocks until Kafka assigns partitions or the deadline passes. + * + * @param deadlineNanos absolute {@link System#nanoTime()} deadline + * @throws FetchException if no partition is assigned before the deadline + */ + private void subscribeAndAwaitAssignment(long deadlineNanos) throws FetchException { String namespacedTopic = context.getTopic().getNamespacedTopic(); kafkaConsumer.subscribe(Collections.singletonList(namespacedTopic)); log.info(MESSAGE_CONSUMER_SUBSCRIBED_TO_TOPIC, namespacedTopic); long pollIntervalMs = frameworkConfig.getPollIntervalMillis(); while (kafkaConsumer.assignment().isEmpty()) { + if (System.nanoTime() >= deadlineNanos) { + throw new FetchTimeoutException("Timed out after " + readTimeoutMs + + "ms waiting for partition assignment on topic '" + namespacedTopic + + "'. Check that the broker is reachable and the topic exists."); + } kafkaConsumer.poll(Duration.ofMillis(pollIntervalMs)); } log.debug("Partition assignment received for topic '{}'.", namespacedTopic); @@ -177,7 +193,7 @@ private void subscribeAndAwaitAssignment() { * Calculates the earliest timestamp to read from. * *

The "now" reference used below is either {@link ConsumerContext#getReferenceTimestamp()}, when the caller has - * pinned it — or the live {@code System.currentTimeMillis()} otherwise. Pinning "now" lets a single Cucumber step + * pinned it: or the live {@code System.currentTimeMillis()} otherwise. Pinning "now" lets a single Cucumber step * spawn several internal fetches (e.g. a batch consumer, or multiple {@code Then} steps executed in quick * succession) without the seek offset drifting forward as wall-clock time advances between them. * @@ -200,7 +216,7 @@ private long calculateDeltaTime() { return delta; } - // 2. Properties map value (in seconds — convert to ms) + // 2. Properties map value (in seconds: convert to ms) long defaultDeltaMs = frameworkConfig.getConsumerDeltaTime().toMillis(); String deltaTimeStr = properties.get(CONSUMER_DELTA_TIME); if (deltaTimeStr != null && !deltaTimeStr.isEmpty()) { @@ -222,14 +238,19 @@ private long calculateDeltaTime() { /** * Seeks each assigned partition to the offset corresponding to {@code deltaTimestamp}. Partitions with no messages * after that timestamp are seeked to the end. + * + * @param deltaTimestamp the epoch-millisecond timestamp to seek to + * @param deadlineNanos absolute {@link System#nanoTime()} deadline bounding the offset lookup */ - private void seekToOffset(long deltaTimestamp) { + private void seekToOffset(long deltaTimestamp, long deadlineNanos) { Set assignments = kafkaConsumer.assignment(); log.debug(MESSAGE_CONSUMER_SEEKING_TO_OFFSET, deltaTimestamp); Map timestampMap = assignments.stream().collect(Collectors.toMap(tp -> tp, tp -> deltaTimestamp)); - Map offsets = kafkaConsumer.offsetsForTimes(timestampMap); + long remainingMs = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime())); + Map offsets = + kafkaConsumer.offsetsForTimes(timestampMap, Duration.ofMillis(remainingMs)); log.trace(MESSAGE_CONSUMER_RETRIEVED_OFFSETS, timestampMap); offsets.forEach((topicPartition, offsetAndTimestamp) -> { @@ -244,24 +265,31 @@ private void seekToOffset(long deltaTimestamp) { } /** - * Polls Kafka until a candidate record is found or {@link #readTimeoutMs} expires. + * Polls Kafka until a candidate record is found or the deadline passes. * *

In single-record mode (default) the method returns as soon as at least one candidate passes the - * key-filter and deduplication check. + * key-filter and is successfully claimed in the deduplication registry. * *

In batch mode ({@link ConsumerContext#isBatchConsumer()} == {@code true}) the method keeps polling - * until {@link ConsumerContext#getBatchSize()} distinct candidates have been collected, or the timeout expires. + * until {@link ConsumerContext#getBatchSize()} distinct candidates have been collected, or the deadline passes. If + * the batch is incomplete at the deadline, the records it claimed are released so other consumers can still match + * them. * + * @param deadlineNanos absolute {@link System#nanoTime()} deadline * @return non-empty, unmodifiable list of {@link ConsumedRecord} ready for matching - * @throws FetchException if the timeout expires before the required number of records is found + * @throws FetchException if the batch size is invalid, or the deadline passes before the required number of records + * is found */ - private List> pollUntilRecordFound() throws FetchException { - long startTime = System.currentTimeMillis(); + private List> pollUntilRecordFound(long deadlineNanos) throws FetchException { long pollIntervalMs = frameworkConfig.getPollIntervalMillis(); String namespacedTopic = context.getTopic().getNamespacedTopic(); boolean batchMode = context.isBatchConsumer(); int targetSize = batchMode ? context.getBatchSize() : 1; + if (targetSize < 1) { + throw new FetchException( + "Invalid batch size " + targetSize + " for topic '" + namespacedTopic + "': must be at least 1."); + } List> accumulated = new ArrayList<>(); log.debug( @@ -270,36 +298,34 @@ private List> pollUntilRecordFound() throws FetchException { batchMode ? "BATCH" : "SINGLE", targetSize); - while (System.currentTimeMillis() - startTime < readTimeoutMs) { + while (System.nanoTime() < deadlineNanos) { ConsumerRecords batch = kafkaConsumer.poll(Duration.ofMillis(pollIntervalMs)); if (!batch.isEmpty()) { log.debug(MESSAGE_CONSUMER_RECORDS_IN_TOPIC_NOT_EMPTY, namespacedTopic); for (ConsumerRecord record : batch) { - ConsumedRecord consumed = ConsumedRecord.fromKafkaRecord(record); - - if (isAlreadyMatched(consumed)) { - log.debug(MESSAGE_CONSUMER_RECORD_ALREADY_MATCHED, consumed.toMatchedRecord()); + if (!passesKeyFilter(record)) { continue; } - if (!passesKeyFilter(record)) { + ConsumedRecord consumed = ConsumedRecord.fromKafkaRecord(record); + if (!tryClaim(consumed)) { + log.debug(MESSAGE_CONSUMER_RECORD_ALREADY_MATCHED, consumed.toMatchedRecord()); continue; } log.info(MESSAGE_CONSUMER_GOT_RECORD_WITH_KEY_AND_VALUE, record.key(), record.value()); - registerAsMatched(consumed); accumulated.add(consumed); // In SINGLE mode return immediately on the first passing record. - // In BATCH mode stop as soon as targetSize is reached — do NOT + // In BATCH mode stop as soon as targetSize is reached: do NOT // continue draining the current poll batch beyond the target. if (!batchMode) { return Collections.unmodifiableList(accumulated); } if (accumulated.size() >= targetSize) { log.info( - "Batch complete — collected {} / {} records from topic '{}'.", + "Batch complete: collected {} / {} records from topic '{}'.", accumulated.size(), targetSize, namespacedTopic); @@ -310,29 +336,42 @@ private List> pollUntilRecordFound() throws FetchException { } if (!accumulated.isEmpty()) { - // Partial batch collected — treat as a timeout with context - throw new FetchException("Timed out after " + readTimeoutMs + "ms waiting for " + targetSize + // Partial batch: give the claimed records back before reporting the timeout. + accumulated.forEach(this::release); + throw new FetchTimeoutException("Timed out after " + readTimeoutMs + "ms waiting for " + targetSize + " record(s) on topic '" + namespacedTopic - + "' — only " + accumulated.size() + " collected."); + + "': only " + accumulated.size() + " collected."); } - throw new FetchException( + throw new FetchTimeoutException( "Timed out after " + readTimeoutMs + "ms waiting for a record on topic '" + namespacedTopic + "'."); } // ========================================================================= - // Private — filters and deduplication + // Private: filters and deduplication // ========================================================================= - /** Returns {@code true} if the record has already been claimed by another consumer in this test execution. */ - private boolean isAlreadyMatched(ConsumedRecord record) { - return MATCHED_RECORDS.contains(record.toMatchedRecord()); + /** + * Atomically claims a record in the shared deduplication registry. + * + *

The check and the registration happen in a single {@link Set#add(Object)} call on a concurrent set, so two + * fetchers running in parallel can never both claim the same record. + * + * @param record the candidate record + * @return {@code true} if this fetcher claimed the record, {@code false} if it was already claimed + */ + private boolean tryClaim(ConsumedRecord record) { + MatchedRecord token = record.toMatchedRecord(); + boolean claimed = MATCHED_RECORDS.add(token); + if (claimed) { + log.debug(MESSAGE_CONSUMER_RECORD_NOT_MATCHED_YET, token); + } + return claimed; } - /** Registers a record in the shared deduplication registry. */ - private void registerAsMatched(ConsumedRecord record) { - MATCHED_RECORDS.add(record.toMatchedRecord()); - log.debug(MESSAGE_CONSUMER_RECORD_NOT_MATCHED_YET, record.toMatchedRecord()); + /** Removes a previously claimed record from the shared deduplication registry. */ + private void release(ConsumedRecord record) { + MATCHED_RECORDS.remove(record.toMatchedRecord()); } /** @@ -347,7 +386,7 @@ private boolean passesKeyFilter(ConsumerRecord record) { } if (expectedKey == null || expectedKey.isEmpty()) { - return true; // No filter configured — accept any key + return true; // No filter configured: accept any key } String recordKey = record.key() != null ? record.key().toString() : null; @@ -361,7 +400,7 @@ private boolean passesKeyFilter(ConsumerRecord record) { } // ========================================================================= - // Private — timeout resolution + // Private: timeout resolution // ========================================================================= private long resolveReadTimeout() { diff --git a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java index 61bac70..336f966 100644 --- a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java +++ b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java @@ -57,17 +57,42 @@ public final class SchemaRegistryClientFactory { private static final Logger LOG = LoggerFactory.getLogger(SchemaRegistryClientFactory.class); + /** + * Cached singleton client created from the default configuration. Reused across all callers in the same JVM to + * avoid creating a new {@link RestService} (and its HTTP connection pool) on every call. + */ + private static volatile SchemaRegistryClient cachedClient; + private SchemaRegistryClientFactory() { // Utility class } /** - * Creates a Schema Registry client using default configuration. + * Returns a cached Schema Registry client using default configuration. * - * @return a new Schema Registry client + *

The first call creates and caches the client; subsequent calls return the same instance. + * + * @return a cached Schema Registry client */ public static SchemaRegistryClient createClient() { - return createClient(KtestifyConfig.getOrLoad()); + if (cachedClient == null) { + synchronized (SchemaRegistryClientFactory.class) { + if (cachedClient == null) { + cachedClient = createClient(KtestifyConfig.getOrLoad()); + } + } + } + return cachedClient; + } + + /** + * Clears the cached singleton client. Primarily intended for test isolation so that a new config (e.g. via + * {@link KtestifyConfig#reset()}) takes effect on the next {@link #createClient()} call. + */ + public static void resetCache() { + synchronized (SchemaRegistryClientFactory.class) { + cachedClient = null; + } } /** diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java index 4975276..17b8044 100644 --- a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java +++ b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaConsumer.java @@ -28,7 +28,7 @@ *

Delegates all Kafka mechanics to {@link io.github.ktestify.io.kafka.KafkaRecordFetcher} and all assertion logic to * the {@link RecordMatcher} resolved by {@link RecordMatcherFactory#forAvro(String)}. * - *

Typical usage — consume and match against an expected JSON file: + *

Typical usage: consume and match against an expected JSON file: * *

  * Consumer<String, GenericRecord> kafkaConsumer = KafkaClientFactory.createAvroConsumer();
diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java
index 059882b..faf2246 100644
--- a/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java
+++ b/src/main/java/io/github/ktestify/io/kafka/impl/AvroKafkaProducer.java
@@ -17,6 +17,7 @@
 
 import com.google.gson.JsonObject;
 import io.confluent.kafka.schemaregistry.client.rest.exceptions.RestClientException;
+import io.github.ktestify.exceptions.ProducerException;
 import io.github.ktestify.io.kafka.AbstractKafkaProducer;
 import io.github.ktestify.io.kafka.ProducerContext;
 import io.github.ktestify.models.Topic;
@@ -83,12 +84,12 @@ protected void produce() {
                     metadata.timestamp());
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new RuntimeException("Interrupted while producing Avro message", e);
+            throw new ProducerException("Interrupted while producing Avro message", e);
 
         } catch (ExecutionException e) {
-            throw new RuntimeException("Failed to produce Avro message", e.getCause());
+            throw new ProducerException("Failed to produce Avro message", e.getCause());
         } catch (IOException | RestClientException e) {
-            throw new RuntimeException("Failed to read schema or payload for Avro message", e);
+            throw new ProducerException("Failed to read schema or payload for Avro message", e);
         }
     }
 }
diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
index 1c166cf..d22e4ad 100644
--- a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
+++ b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaConsumer.java
@@ -28,7 +28,7 @@
  * mechanics to {@link io.github.ktestify.io.kafka.KafkaRecordFetcher} and all assertion logic to the supplied
  * {@link RecordMatcher}.
  *
- * 

Typical usage — consume and assert against a file: + *

Typical usage: consume and assert against a file: * *

  * Consumer<String, String> kafkaConsumer = KafkaClientFactory.createRawConsumer();
diff --git a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java
index 41213eb..2ee419f 100644
--- a/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java
+++ b/src/main/java/io/github/ktestify/io/kafka/impl/RawKafkaProducer.java
@@ -15,6 +15,7 @@
  */
 package io.github.ktestify.io.kafka.impl;
 
+import io.github.ktestify.exceptions.ProducerException;
 import io.github.ktestify.io.kafka.AbstractKafkaProducer;
 import io.github.ktestify.io.kafka.ProducerContext;
 import io.github.ktestify.models.Topic;
@@ -68,9 +69,9 @@ protected void produce() {
                     metadata.timestamp());
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new RuntimeException("Interrupted while producing raw message", e);
+            throw new ProducerException("Interrupted while producing raw message", e);
         } catch (ExecutionException e) {
-            throw new RuntimeException("Failed to produce raw message", e.getCause());
+            throw new ProducerException("Failed to produce raw message", e.getCause());
         }
     }
 }
diff --git a/src/main/java/io/github/ktestify/manager/ObjectManager.java b/src/main/java/io/github/ktestify/manager/ObjectManager.java
index dfaddfd..b662fad 100644
--- a/src/main/java/io/github/ktestify/manager/ObjectManager.java
+++ b/src/main/java/io/github/ktestify/manager/ObjectManager.java
@@ -47,7 +47,7 @@ public void register(String name, T object) {
      * used to retrieve it.
      *
      * @param name the canonical name
-     * @param alias the alias (may be {@code null} or blank — ignored if so)
+     * @param alias the alias (may be {@code null} or blank: ignored if so)
      * @param object the object to store
      */
     public void register(String name, String alias, T object) {
diff --git a/src/main/java/io/github/ktestify/match/MatchResult.java b/src/main/java/io/github/ktestify/match/MatchResult.java
index 340f310..8b21af9 100644
--- a/src/main/java/io/github/ktestify/match/MatchResult.java
+++ b/src/main/java/io/github/ktestify/match/MatchResult.java
@@ -96,4 +96,34 @@ public static MatchResult fail(String diff, String expected, String actual) {
     public static MatchResult fail(String message) {
         return new MatchResult(false, message, "", "");
     }
+
+    /**
+     * Creates a failing result for the case where the transport delivered no record to compare.
+     *
+     * 

Matchers return this instead of throwing {@link IndexOutOfBoundsException} when they receive an empty or + * {@code null} record list. + * + * @return a failing {@code MatchResult} + * @since 1.1.4 + */ + public static MatchResult noRecords() { + return fail("No record was available to match."); + } + + /** + * Creates a failing result for a record whose value is {@code null}, for example a Kafka tombstone. + * + *

Every matcher that inspects the record value returns this instead of throwing a {@link NullPointerException}. + * Matchers that only inspect the key or transport attributes do not use it, because a tombstone still carries a + * key. + * + * @param expected the expected value, used for reporting; may be {@code null} + * @return a failing {@code MatchResult} + * @since 1.1.4 + */ + public static MatchResult nullValue(String expected) { + return new MatchResult(false, NULL_VALUE_MESSAGE, expected, null); + } + + public static final String NULL_VALUE_MESSAGE = "Record value is null (tombstone)."; } diff --git a/src/main/java/io/github/ktestify/match/RecordMatcher.java b/src/main/java/io/github/ktestify/match/RecordMatcher.java index 3415204..3c924ff 100644 --- a/src/main/java/io/github/ktestify/match/RecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/RecordMatcher.java @@ -27,16 +27,16 @@ * {@link MatchContext}. * *

Implementations have zero dependency on Kafka, IBM MQ, or any other transport. They only know - * about {@link ConsumedRecord} — the common currency produced by every fetcher. This means every matcher works - * unchanged for Kafka today and IBM MQ tomorrow. + * about {@link ConsumedRecord}: the common currency produced by every fetcher. This means every matcher works unchanged + * for Kafka today and IBM MQ tomorrow. * *

Concrete implementations live in {@code io.github.ktestify.match.impl}: * *

    - *
  • {@code NoOpRecordMatcher} — always passes; use when only consumption matters - *
  • {@code FileRecordMatcher} — compares record value against a file (String diff) - *
  • {@code JsonRecordMatcher} — structural JSON comparison with excluded-field support - *
  • {@code AvroRecordMatcher} — delegates to {@code AvroUtils} for Avro records + *
  • {@code NoOpRecordMatcher}: always passes; use when only consumption matters + *
  • {@code FileRecordMatcher}: compares record value against a file (String diff) + *
  • {@code JsonRecordMatcher}: structural JSON comparison with excluded-field support + *
  • {@code AvroRecordMatcher}: delegates to {@code AvroUtils} for Avro records *
* * @param the type of the record value (e.g. {@code String}, {@code GenericRecord}) diff --git a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java index 3826b08..58a7dea 100644 --- a/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java +++ b/src/main/java/io/github/ktestify/match/RecordMatcherFactory.java @@ -15,7 +15,7 @@ */ package io.github.ktestify.match; -import io.github.ktestify.exceptions.ConsumerException; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import lombok.extern.slf4j.Slf4j; import org.apache.avro.generic.GenericRecord; @@ -27,8 +27,8 @@ *

Two typed factory methods are provided so the compiler can enforce type safety: * *

    - *
  • {@link #forRaw(String)} — for {@code String}-valued topics - *
  • {@link #forAvro(String)} — for {@code GenericRecord}-valued (Avro) topics + *
  • {@link #forRaw(String)}: for {@code String}-valued topics + *
  • {@link #forAvro(String)}: for {@code GenericRecord}-valued (Avro) topics *
* *

When {@code matchMethod} is {@code null} or blank a {@link NoOpRecordMatcher} is returned, making "consume-only" @@ -66,11 +66,11 @@ private RecordMatcherFactory() {} * @param matchMethod one of the {@code METHOD_*} constants in {@code ConfigConstants}, or {@code null} / blank for * no-op * @return the appropriate matcher; never {@code null} - * @throws ConsumerException if the method name is non-blank but unrecognised + * @throws ConfigException if the method name is non-blank but unrecognised */ public static RecordMatcher forRaw(String matchMethod) { if (matchMethod == null || matchMethod.isBlank()) { - log.debug("No matchMethod specified — using NoOpRecordMatcher."); + log.debug("No matchMethod specified, using NoOpRecordMatcher."); return new NoOpRecordMatcher<>(); } log.debug("Resolving raw RecordMatcher for method '{}'.", matchMethod); @@ -83,7 +83,7 @@ public static RecordMatcher forRaw(String matchMethod) { case METHOD_RECORD_KEY_MATCH -> new KeyRecordMatcher(); case METHOD_MATCH_ATTRIBUTES -> new AttributeRecordMatcher<>(); default -> - throw new ConsumerException("Unknown raw matchMethod '" + matchMethod + "'. " + throw new ConfigException("Unknown raw matchMethod '" + matchMethod + "'. " + "Valid values: methodMatchFile, methodMatchKeyValue, methodFieldsToMatch, " + "methodMatchXML, methodMatchXPath, methodRecordKeyMatch, methodMatchAttributes."); }; @@ -99,11 +99,11 @@ public static RecordMatcher forRaw(String matchMethod) { * @param matchMethod one of the {@code METHOD_*} constants in {@code ConfigConstants}, or {@code null} / blank for * no-op * @return the appropriate matcher; never {@code null} - * @throws ConsumerException if the method name is non-blank but unrecognised + * @throws ConfigException if the method name is non-blank but unrecognised */ public static RecordMatcher forAvro(String matchMethod) { if (matchMethod == null || matchMethod.isBlank()) { - log.debug("No matchMethod specified — using NoOpRecordMatcher."); + log.debug("No matchMethod specified, using NoOpRecordMatcher."); return new NoOpRecordMatcher<>(); } log.debug("Resolving Avro RecordMatcher for method '{}'.", matchMethod); @@ -113,7 +113,7 @@ public static RecordMatcher forAvro(String matchMethod) { case METHOD_FIELDS_TO_MATCH -> new AvroFieldsRecordMatcher(); case METHOD_RECORD_KEY_MATCH -> new AvroKeyRecordMatcher(); default -> - throw new ConsumerException("Unknown Avro matchMethod '" + matchMethod + "'. " + throw new ConfigException("Unknown Avro matchMethod '" + matchMethod + "'. " + "Valid values: methodMatchFile, methodMatchKeyValue, " + "methodFieldsToMatch, methodRecordKeyMatch."); }; diff --git a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java index 5877944..b43b6d2 100644 --- a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java @@ -29,7 +29,8 @@ * *

Transport-agnostic by design, this matcher is reused by any transport that populates {@code attributes} (HTTP * status code today, gRPC status / MQ reason code / script exit code in the future). It operates on - * {@code List>} for any {@code V} since it never inspects {@link ConsumedRecord#getValue()}. + * {@code List>} for any {@code V} since it never inspects {@link ConsumedRecord#getValue()}, so a + * record with a {@code null} value is matched on its attributes like any other. * *

Matching rule: every key in {@code expectedAttributes} must be present in the actual record's {@code attributes} * with an exactly-equal String value (case-sensitive). Only the first record in the list is used (single-record @@ -43,19 +44,17 @@ public class AttributeRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - Map expected = context.getExpectedAttributes(); if (expected == null || expected.isEmpty()) { log.debug("No expected attributes configured, nothing to assert."); return MatchResult.pass(); } - if (records == null || records.isEmpty()) { - throw new ComparisonException("AttributeRecordMatcher requires at least one record to compare."); + return MatchResult.noRecords(); } - Map actual = records.get(0).getAttributes(); + Map actual = records.getFirst().getAttributes(); Map safeActual = actual != null ? actual : Collections.emptyMap(); List diffs = new ArrayList<>(); diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java index d19e3d6..5639706 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java @@ -21,7 +21,6 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; import java.util.ArrayList; import java.util.List; @@ -35,7 +34,9 @@ * *

Requires {@link MatchContext#getMatchKey()} to specify the JSON field name to examine. Either * {@link MatchContext#getMatchValue()} (inline) or {@link MatchContext#getMatchFilePath()} (file-based) must also be - * set. + * set. Alternatively, {@link MatchContext#getMatchKeyValues()} checks several fields at once. + * + *

A record with a {@code null} value (tombstone) fails with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -45,6 +46,9 @@ public class AvroFieldsRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } // Multi-field inline matching (keys/values columns) if (context.getMatchKeyValues() != null && !context.getMatchKeyValues().isEmpty()) { @@ -54,31 +58,41 @@ public MatchResult match(List> records, MatchConte if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroFieldsRecordMatcher requires matchKey (the field name) to be set."); } - - GenericRecord value = records.getFirst().getValue(); - String actualValue = toJson(value); String key = context.getMatchKey(); - // Option A — inline expected value + // Option A: inline expected value if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { - log.debug( - "Avro field match against inline value — key: '{}', expected: '{}'", key, context.getMatchValue()); - boolean result = AvroUtils.doesAvroValueFromKeyMatchesRecord(context.getMatchValue(), key, actualValue); - if (result) { - return MatchResult.pass(context.getMatchValue(), actualValue); + String expected = context.getMatchValue(); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected field '{}' = '{}'.", key, expected); + return MatchResult.nullValue(expected); + } + String actualValue = AvroJson.of(value); + log.debug("Avro field match against inline value, key: '{}', expected: '{}'", key, expected); + if (AvroUtils.doesAvroValueFromKeyMatchesRecord(expected, key, actualValue)) { + return MatchResult.pass(expected, actualValue); } return MatchResult.fail( - "Avro field '" + key + "' does not match expected value '" + context.getMatchValue() + "'.", - context.getMatchValue(), + "Avro field '" + key + "' does not match expected value '" + expected + "'.", + expected, actualValue); } - // Option B — field comparison against expected file + // Option B: field comparison against expected file if (context.getMatchFilePath() != null && !context.getMatchFilePath().isBlank()) { String expectedRecord = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - log.debug("Avro field match against file — key: '{}', file: '{}'", key, context.getMatchFilePath()); - boolean result = AvroUtils.doesAvroValueFromKeyMatchesRecords(key, expectedRecord, actualValue); - if (result) { + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error( + "Avro record value is null (tombstone), expected field '{}' from '{}'.", + key, + context.getMatchFilePath()); + return MatchResult.nullValue(expectedRecord); + } + String actualValue = AvroJson.of(value); + log.debug("Avro field match against file, key: '{}', file: '{}'", key, context.getMatchFilePath()); + if (AvroUtils.doesAvroValueFromKeyMatchesRecords(key, expectedRecord, actualValue)) { return MatchResult.pass(expectedRecord, actualValue); } return MatchResult.fail( @@ -93,21 +107,25 @@ public MatchResult match(List> records, MatchConte /** * Validates every key/value pair in {@link MatchContext#getMatchKeyValues()} against the actual Avro record. * - *

All pairs must match for the result to pass. The first mismatch is reported in the diff. + *

All pairs must match for the result to pass. Every mismatch is reported in the diff. * * @param records the consumed records (only the first is examined) * @param context the match context carrying the key/value pairs * @return a {@link MatchResult} indicating whether all fields matched - * @throws ComparisonException if the key/value map is empty (should not happen, guarded by the caller) + * @throws ComparisonException if a field lookup cannot be performed * @since 1.1.1 */ private MatchResult matchMultipleFields(List> records, MatchContext context) throws ComparisonException { - GenericRecord value = records.getFirst().getValue(); - String actualValue = toJson(value); Map keyValues = context.getMatchKeyValues(); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected fields {}.", keyValues); + return MatchResult.nullValue(keyValues.toString()); + } + String actualValue = AvroJson.of(value); - log.debug("Avro multi-field match against inline values : pairs: {}", keyValues); + log.debug("Avro multi-field match against inline values, pairs: {}", keyValues); List mismatches = new ArrayList<>(); for (Map.Entry entry : keyValues.entrySet()) { @@ -123,12 +141,4 @@ private MatchResult matchMultipleFields(List> reco } return MatchResult.fail(String.join(" ", mismatches), keyValues.toString(), actualValue); } - - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); - } - return AvroUtils.getPrettyAvroValue(value.toString()); - } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java index 643cd59..a4044af 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -21,7 +21,6 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; import java.util.List; import lombok.extern.slf4j.Slf4j; @@ -29,15 +28,18 @@ /** * Asserts both the record key and Avro value against a configured expected key and expected JSON - * file. + * file. Supports optional field exclusion via {@link MatchContext#getExcludedFields()}, like + * {@link AvroFileRecordMatcher}. * *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()} — the expected record key - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected value JSON file + *
  • {@link MatchContext#getMatchKey()}: the expected record key + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value JSON file *
* + *

A record with a {@code null} value (tombstone) never matches; the key is still compared and reported. + * * @since 0.3.0 */ @Slf4j @@ -46,7 +48,9 @@ public class AvroFileKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroFileKeyRecordMatcher requires matchKey to be set."); } @@ -54,20 +58,22 @@ public MatchResult match(List> records, MatchConte throw new ComparisonException("AvroFileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.get(0); + ConsumedRecord record = records.getFirst(); String actualKey = record.getKey(); String expectedKey = context.getMatchKey(); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actualValue = toJson(record.getValue()); + GenericRecord value = record.getValue(); + String actualValue = value == null ? null : AvroJson.of(value); boolean keyMatches = expectedKey.equals(actualKey); - boolean valueMatches = - AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); + boolean valueMatches = actualValue != null && valueMatches(expectedValue, actualValue, context); if (!keyMatches) { - log.error("Avro key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Avro key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (!valueMatches) { + if (actualValue == null) { + log.error("Avro value mismatch: record value is null (tombstone)."); + } else if (!valueMatches) { log.error( "Avro value does not match file '{}'.\nExpected:\n{}\nActual:\n{}", context.getMatchFilePath(), @@ -79,17 +85,17 @@ public MatchResult match(List> records, MatchConte log.info("Avro record key and value both match."); return MatchResult.pass(expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } - return MatchResult.fail( - "Key match: " + keyMatches + ", value match: " + valueMatches, - expectedKey + " / " + expectedValue, - actualKey + " / " + actualValue); + String diff = "Key match: " + keyMatches + ", value match: " + valueMatches + + (actualValue == null ? " (" + MatchResult.NULL_VALUE_MESSAGE + ")" : ""); + return MatchResult.fail(diff, expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); + private static boolean valueMatches(String expectedValue, String actualValue, MatchContext context) { + List excluded = context.getExcludedFields(); + if (excluded != null && !excluded.isEmpty()) { + log.debug("Excluding Avro fields: {}", excluded); + return AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expectedValue, actualValue, excluded); } - return AvroUtils.getPrettyAvroValue(value.toString()); + return AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java index 654c451..7d45259 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java @@ -21,7 +21,6 @@ import io.github.ktestify.match.RecordMatcher; import io.github.ktestify.models.ConsumedRecord; import io.github.ktestify.utils.FileUtils; -import io.github.ktestify.utils.serdes.AvroDeserializer; import io.github.ktestify.utils.serdes.AvroUtils; import java.util.List; import lombok.extern.slf4j.Slf4j; @@ -31,7 +30,8 @@ * Compares an Avro record's value against the content of an expected JSON file using smart matching. Supports optional * field exclusion via {@link MatchContext#getExcludedFields()}. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -41,20 +41,22 @@ public class AvroFileRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("AvroFileRecordMatcher requires matchFilePath to be set."); } - GenericRecord value = records.get(0).getValue(); - String actualValue = toJson(value); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); + GenericRecord value = records.getFirst().getValue(); + if (value == null) { + log.error("Avro record value is null (tombstone), expected content of '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expectedValue); + } + String actualValue = AvroJson.of(value); - log.debug( - "Avro file match — actual:\n{}\nExpected:\n{}", - actualValue, - AvroUtils.getPrettyAvroValue(AvroUtils.convertMapToJsonString( - AvroUtils.convertDatesToTimestamps(AvroUtils.convertJsonToMap(expectedValue))))); + log.debug("Avro file match, actual:\n{}\nExpected:\n{}", actualValue, expectedValue); boolean result; if (context.getExcludedFields() != null && !context.getExcludedFields().isEmpty()) { @@ -77,12 +79,4 @@ public MatchResult match(List> records, MatchConte return MatchResult.fail( "Avro record does not match file '" + context.getMatchFilePath() + "'.", expectedValue, actualValue); } - - private String toJson(GenericRecord value) { - if (value.getSchema() != null) { - return AvroUtils.getPrettyAvroValue( - AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); - } - return AvroUtils.getPrettyAvroValue(value.toString()); - } } diff --git a/src/main/java/io/github/ktestify/match/impl/AvroJson.java b/src/main/java/io/github/ktestify/match/impl/AvroJson.java new file mode 100644 index 0000000..70f784c --- /dev/null +++ b/src/main/java/io/github/ktestify/match/impl/AvroJson.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * 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 + * + * http://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 io.github.ktestify.match.impl; + +import io.github.ktestify.utils.serdes.AvroDeserializer; +import io.github.ktestify.utils.serdes.AvroUtils; +import org.apache.avro.generic.GenericRecord; + +/** + * Converts an Avro {@link GenericRecord} into the pretty-printed JSON form used by every Avro matcher. + * + *

Logical types are decoded by {@link AvroDeserializer} first, so dates, timestamps and decimals are compared in + * their readable form rather than as raw epoch numbers or bytes. + * + * @since 1.1.4 + */ +final class AvroJson { + + private AvroJson() {} + + /** + * Returns the JSON representation of {@code value}. + * + * @param value the Avro record; must not be {@code null} (callers handle tombstones first) + * @return a pretty-printed JSON string + */ + static String of(GenericRecord value) { + if (value.getSchema() != null) { + return AvroUtils.getPrettyAvroValue( + AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); + } + return AvroUtils.getPrettyAvroValue(value.toString()); + } +} diff --git a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java index 152bd0c..ccae0ff 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -27,6 +27,9 @@ /** * Asserts that the Avro record key equals the expected key in {@link MatchContext#getMatchKey()}. * + *

The record value is never inspected, so a tombstone ({@code null} value) with the expected key passes. A + * {@code null} key never matches. + * * @since 0.3.0 */ @Slf4j @@ -35,22 +38,24 @@ public class AvroKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("AvroKeyRecordMatcher requires matchKey to be set."); } String expectedKey = context.getMatchKey(); - String actualKey = records.get(0).getKey(); + String actualKey = records.getFirst().getKey(); if (expectedKey.equals(actualKey)) { log.info("Avro record key matches expected key '{}'.", expectedKey); return MatchResult.pass(expectedKey, actualKey); } - log.error("Avro record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Avro record key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); return MatchResult.fail( - "Avro record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Avro record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + "'.", expectedKey, actualKey); } diff --git a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java index c198de9..38d5bf2 100644 --- a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java @@ -31,7 +31,10 @@ * ({@link MatchContext#getMatchFilePath()}). * *

Requires {@link MatchContext#getMatchKey()} to encode the position as {@code "line:from:to"} (e.g. - * {@code "0:10:20"} means line 0, characters 10–20). + * {@code "0:10:20"} means line 0, characters 10 to 20, end exclusive). + * + *

A record with a {@code null} value (tombstone), or one too short to contain the requested field, fails the match. + * An expected file that does not contain the field is a configuration error and throws {@link ComparisonException}. * * @since 0.3.0 */ @@ -40,42 +43,58 @@ public class FieldsRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } int[] pos = parsePosition(context); int line = pos[0]; int from = pos[1]; int to = pos[2]; + String position = "line=" + line + " from=" + from + " to=" + to; - String actualValue = records.get(0).getValue(); - String actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); + String expectedField = resolveExpectedField(context, line, from, to, position); - // Option A — compare against an inline expected value - if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { - String expectedField = context.getMatchValue(); - log.debug("Fields match: expected='{}', actual='{}'", expectedField, actualField); - if (expectedField.equals(actualField)) { - return MatchResult.pass(expectedField, actualField); - } + String actualValue = records.getFirst().getValue(); + if (actualValue == null) { + log.error("Record value is null (tombstone), expected field at {} to be '{}'.", position, expectedField); + return MatchResult.nullValue(expectedField); + } + + String actualField; + try { + actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); + } catch (IllegalArgumentException e) { + log.error("Record has no field at {}: {}", position, e.getMessage()); return MatchResult.fail( - "Field at line=" + line + " from=" + from + " to=" + to + " does not match.", - expectedField, - actualField); + "Record has no field at " + position + ": " + e.getMessage(), expectedField, actualValue); } - // Option B — compare the same field position extracted from an expected file + log.debug("Fields match: expected='{}', actual='{}'", expectedField, actualField); + if (expectedField.equals(actualField)) { + return MatchResult.pass(expectedField, actualField); + } + return MatchResult.fail("Field at " + position + " does not match.", expectedField, actualField); + } + + /** + * Resolves the expected field, either inline from {@link MatchContext#getMatchValue()} or extracted from the same + * position in the expected file. + */ + private String resolveExpectedField(MatchContext context, int line, int from, int to, String position) + throws ComparisonException { + if (context.getMatchValue() != null && !context.getMatchValue().isBlank()) { + return context.getMatchValue(); + } if (context.getMatchFilePath() != null && !context.getMatchFilePath().isBlank()) { String expectedContent = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String expectedField = FieldMatcherUtils.getFieldsToMatch(expectedContent, line, from, to); - log.debug("Fields match (from file): expected='{}', actual='{}'", expectedField, actualField); - if (expectedField.equals(actualField)) { - return MatchResult.pass(expectedField, actualField); + try { + return FieldMatcherUtils.getFieldsToMatch(expectedContent, line, from, to); + } catch (IllegalArgumentException e) { + throw new ComparisonException("Expected file '" + context.getMatchFilePath() + "' has no field at " + + position + ": " + e.getMessage()); } - return MatchResult.fail( - "Field at line=" + line + " from=" + from + " to=" + to + " does not match.", - expectedField, - actualField); } - throw new ComparisonException("FieldsRecordMatcher requires either matchValue or matchFilePath to be set."); } diff --git a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java index c7c3033..158a491 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -31,10 +31,12 @@ *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()} — expected record key - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected value file + *
  • {@link MatchContext#getMatchKey()}: the expected record key + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value file *
* + *

A record with a {@code null} value (tombstone) never matches; the key is still compared and reported. + * * @since 0.3.0 */ @Slf4j @@ -42,7 +44,9 @@ public class FileKeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("FileKeyRecordMatcher requires matchKey to be set."); } @@ -50,19 +54,21 @@ public MatchResult match(List> records, MatchContext cont throw new ComparisonException("FileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.get(0); + ConsumedRecord record = records.getFirst(); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); String actualValue = record.getValue(); String expectedKey = context.getMatchKey(); String actualKey = record.getKey(); boolean keyMatches = expectedKey.equals(actualKey); - boolean valueMatches = actualValue.equals(expectedValue); + boolean valueMatches = expectedValue.equals(actualValue); if (!keyMatches) { - log.error("Key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (!valueMatches) { + if (actualValue == null) { + log.error("Value mismatch: record value is null (tombstone)."); + } else if (!valueMatches) { log.error( "Value mismatch.\nExpected diff:\n{}\nActual diff:\n{}", StringDiffUtils.getPrettyStringDiff(expectedValue, actualValue, StringDiffUtils.Type.EXPECTED), @@ -73,9 +79,8 @@ public MatchResult match(List> records, MatchContext cont log.info("Record key and value both match."); return MatchResult.pass(expectedValue, actualValue); } - return MatchResult.fail( - "Key match: " + keyMatches + ", value match: " + valueMatches, - expectedKey + " / " + expectedValue, - actualKey + " / " + actualValue); + String diff = "Key match: " + keyMatches + ", value match: " + valueMatches + + (actualValue == null ? " (" + MatchResult.NULL_VALUE_MESSAGE + ")" : ""); + return MatchResult.fail(diff, expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); } } diff --git a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java index 14f6af0..0df73c1 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java @@ -28,7 +28,8 @@ /** * Compares the String value of the first consumed record against the content of an expected file. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. * * @since 0.3.0 */ @@ -37,14 +38,20 @@ public class FileRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("FileRecordMatcher requires matchFilePath to be set."); } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected content of '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } if (actual.equals(expected)) { log.info("Record value matches expected file '{}'.", context.getMatchFilePath()); return MatchResult.pass(expected, actual); diff --git a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java index 4a8d6e7..de835af 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -26,6 +26,9 @@ /** * Asserts that the record key equals the expected key defined in {@link MatchContext#getMatchKey()}. * + *

The record value is never inspected, so a tombstone ({@code null} value) with the expected key passes. A + * {@code null} key never matches. + * * @since 0.3.0 */ @Slf4j @@ -33,22 +36,24 @@ public class KeyRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchKey() == null || context.getMatchKey().isBlank()) { throw new ComparisonException("KeyRecordMatcher requires matchKey to be set."); } String expectedKey = context.getMatchKey(); - String actualKey = records.get(0).getKey(); + String actualKey = records.getFirst().getKey(); if (expectedKey.equals(actualKey)) { log.info("Record key matches expected key '{}'.", expectedKey); return MatchResult.pass(expectedKey, actualKey); } - log.error("Record key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Record key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); return MatchResult.fail( - "Record key does not match — expected: '" + expectedKey + "', actual: '" + actualKey + "'.", + "Record key does not match, expected: '" + expectedKey + "', actual: '" + actualKey + "'.", expectedKey, actualKey); } diff --git a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java index 72910ee..9b71765 100644 --- a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java @@ -27,7 +27,8 @@ * *

Use this when the test only needs to verify that a record was produced to the source (i.e. it exists), * without asserting anything about its content. It is also the safe default when no {@code matchMethod} has been - * configured. + * configured. Existence is guaranteed by the transport layer, which throws when no record arrives; this matcher never + * inspects the list, so a tombstone or an empty list both pass. * * @param the type of the record value * @since 0.3.0 @@ -38,7 +39,8 @@ public class NoOpRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) { log.debug( - "NoOpRecordMatcher: skipping assertion on {} record(s) — no match method configured.", records.size()); + "NoOpRecordMatcher: skipping assertion on {} record(s), no match method configured.", + records == null ? 0 : records.size()); return MatchResult.pass(); } } diff --git a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java index 0df5737..e862e77 100644 --- a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java @@ -32,10 +32,12 @@ *

Requires: * *

    - *
  • {@link MatchContext#getMatchFilePath()} — path to the expected XML file - *
  • {@link MatchContext#getExcludedFields()} — repurposed here as the list of XPath expressions to evaluate + *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected XML file + *
  • {@link MatchContext#getExcludedFields()}: repurposed here as the list of XPath expressions to evaluate *
* + *

A record with a {@code null} value (tombstone) fails with {@link MatchResult#nullValue(String)}. + * * @since 0.3.0 */ @Slf4j @@ -43,7 +45,9 @@ public class XPathRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getExcludedFields() == null || context.getExcludedFields().isEmpty()) { throw new ComparisonException( "XPathRecordMatcher requires xPath expressions to be set via excludedFields."); @@ -53,9 +57,13 @@ public MatchResult match(List> records, MatchContext cont } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected XML from '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } log.debug( - "XPath comparison — XPaths: {}\nActual:\n{}\nExpected:\n{}", + "XPath comparison, XPaths: {}\nActual:\n{}\nExpected:\n{}", context.getExcludedFields(), actual, expected); diff --git a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java index 8b5598b..09a5bd9 100644 --- a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java @@ -37,11 +37,13 @@ * {@link MatchContext#getExcludedFields()}. * *

Additionally, any element in the expected template whose text content is exactly {@code EXCLUDED} is automatically - * added to the exclusion list. This allows a single expected file to serve multiple scenarios — scenarios only need to - * explicitly list elements they want structurally excluded; all others marked {@code EXCLUDED} in the file are + * added to the exclusion list. This allows a single expected file to serve multiple scenarios: scenarios only need to + * explicitly list elements they want structurally excluded, and all others marked {@code EXCLUDED} in the file are * suppressed as well. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails + * with {@link MatchResult#nullValue(String)}. The expected template is parsed with + * {@link XMLUtils#newHardenedSAXParserFactory()} to prevent XXE. * * @since 0.3.0 */ @@ -53,14 +55,20 @@ public class XmlRecordMatcher implements RecordMatcher { @Override public MatchResult match(List> records, MatchContext context) throws ComparisonException { - + if (records == null || records.isEmpty()) { + return MatchResult.noRecords(); + } if (context.getMatchFilePath() == null || context.getMatchFilePath().isBlank()) { throw new ComparisonException("XmlRecordMatcher requires matchFilePath to be set."); } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.get(0).getValue(); - log.debug("XML comparison — actual:\n{}\nExpected:\n{}", actual, expected); + String actual = records.getFirst().getValue(); + if (actual == null) { + log.error("Record value is null (tombstone), expected XML from '{}'.", context.getMatchFilePath()); + return MatchResult.nullValue(expected); + } + log.debug("XML comparison, actual:\n{}\nExpected:\n{}", actual, expected); // Merge explicit exclusions with any elements marked EXCLUDED in the template List effectiveExclusions = buildEffectiveExclusions(context.getExcludedFields(), expected); @@ -87,7 +95,7 @@ public MatchResult match(List> records, MatchContext cont private List buildEffectiveExclusions(List explicit, String expectedXml) { List result = new ArrayList<>(explicit != null ? explicit : List.of()); try { - SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParserFactory factory = XMLUtils.newHardenedSAXParserFactory(); SAXParser parser = factory.newSAXParser(); parser.parse(new InputSource(new StringReader(expectedXml)), new DefaultHandler() { private String currentElement; @@ -108,7 +116,7 @@ public void characters(char[] ch, int start, int length) { public void endElement(String uri, String localName, String qName) { if (EXCLUDED_SENTINEL.equals(text.toString().trim()) && !result.contains(currentElement)) { log.debug( - "Auto-excluding element '{}' — sentinel value '{}' found in template.", + "Auto-excluding element '{}': sentinel value '{}' found in template.", currentElement, EXCLUDED_SENTINEL); result.add(currentElement); diff --git a/src/main/java/io/github/ktestify/models/ConsumedRecord.java b/src/main/java/io/github/ktestify/models/ConsumedRecord.java index fd6dcd0..94c1c48 100644 --- a/src/main/java/io/github/ktestify/models/ConsumedRecord.java +++ b/src/main/java/io/github/ktestify/models/ConsumedRecord.java @@ -28,7 +28,7 @@ * *

This is the common currency that flows between the transport layer ({@code RecordFetcher} and * {@code RequestResponseClient}) and the assertion layer ({@code RecordMatcher}). Matchers have zero dependency on - * Kafka or any other transport — they only know about {@code ConsumedRecord}. + * Kafka or any other transport: they only know about {@code ConsumedRecord}. * *

Synchronous transports (see {@code RequestResponseClient}) additionally populate {@link #attributes} with * structured transport metadata such as an HTTP status code. Asynchronous transports leave it empty. @@ -42,10 +42,10 @@ public class ConsumedRecord { /** The source topic / queue / channel name. */ String source; - /** Partition index — 0 for non-partitioned sources (e.g. IBM MQ). */ + /** Partition index: 0 for non-partitioned sources (e.g. IBM MQ). */ int partition; - /** Offset within the partition — {@code -1} if the source has no offset concept. */ + /** Offset within the partition: {@code -1} if the source has no offset concept. */ long offset; /** Record key as a String. May be {@code null} if the source has no key concept. */ diff --git a/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java b/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java index b7f85a0..42dad64 100644 --- a/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java +++ b/src/main/java/io/github/ktestify/plugin/KtestifyPlugin.java @@ -18,7 +18,7 @@ /** * Service Provider Interface (SPI) for ktestify plugins. * - *

A plugin extends ktestify with new capabilities — typically a new transport (e.g. Azure Blob Storage, IBM MQ, + *

A plugin extends ktestify with new capabilities: typically a new transport (e.g. Azure Blob Storage, IBM MQ, * Amazon S3) plus a set of Cucumber step definitions that exercise it. * *

How to implement a plugin

@@ -45,9 +45,9 @@ *

Lifecycle

* *
    - *
  1. {@link #initialize(PluginContext)} — called once at JVM startup, before any Cucumber scenario runs. + *
  2. {@link #initialize(PluginContext)}: called once at JVM startup, before any Cucumber scenario runs. *
  3. Plugin steps execute normally during scenario runs. - *
  4. {@link #shutdown()} — called once on JVM shutdown. + *
  5. {@link #shutdown()}: called once on JVM shutdown. *
* * @since 1.1.0 @@ -61,7 +61,7 @@ public interface KtestifyPlugin { * *

This ID is used in log messages and as the HOCON config subtree key ({@code ktestify.plugins.}). * - * @return the plugin identifier — never {@code null} or blank + * @return the plugin identifier: never {@code null} or blank */ String getId(); @@ -78,7 +78,7 @@ public interface KtestifyPlugin { *

Shown in the ktestify startup banner alongside the plugin ID and version so operators can immediately identify * who is responsible for each loaded plugin. Override this in your implementation to provide a real name. * - * @return the author name — {@code "unknown"} by default + * @return the author name: {@code "unknown"} by default */ default String getAuthorName() { return "unknown"; @@ -90,7 +90,7 @@ default String getAuthorName() { *

Displayed next to {@link #getAuthorName()} in the plugin registry startup log. Override this to provide a real * email address. * - * @return the author email — empty string by default + * @return the author email: empty string by default */ default String getAuthorEmail() { return ""; @@ -101,7 +101,7 @@ default String getAuthorEmail() { * {@code "io.github.ktestify.azureblob.steps"}). * *

The ktestify runtime injects this package as a {@code --glue} argument to the Cucumber CLI so step definitions - * are discovered automatically — no manual configuration required. + * are discovered automatically: no manual configuration required. * *

Return {@code null} or an empty string if the plugin does not contribute any step definitions. * @@ -123,7 +123,7 @@ default String getAuthorEmail() { /** * Shuts down the plugin. Called once on JVM shutdown, after all Cucumber scenarios have finished. * - *

Implementations should release all resources (connections, thread pools, etc.) and must not throw exceptions — + *

Implementations should release all resources (connections, thread pools, etc.) and must not throw exceptions: * log and swallow instead. */ void shutdown(); diff --git a/src/main/java/io/github/ktestify/plugin/PluginContext.java b/src/main/java/io/github/ktestify/plugin/PluginContext.java index 43e6227..d1fcc0a 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginContext.java +++ b/src/main/java/io/github/ktestify/plugin/PluginContext.java @@ -47,7 +47,7 @@ public interface PluginContext { * {@code ktestify.plugins.} subtree. The full config object is provided (rather than a pre-sliced subtree) so * plugins can also access shared settings (e.g. {@code ktestify.framework.directories.assets}) when needed. * - * @return the loaded framework configuration — never {@code null} + * @return the loaded framework configuration: never {@code null} */ KtestifyConfig getConfig(); } diff --git a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java index fedd2ca..44aa50d 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -20,6 +20,7 @@ import java.net.URL; import java.net.URLClassLoader; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,20 +31,25 @@ *

Two loading phases

* *
    - *
  1. Classpath (Phase 1) — {@link ServiceLoader#load(Class)} on the current thread's context classloader. - * Picks up all plugins that are on the classpath (i.e. bundled as Maven dependencies in the fat JAR). The Shade - * {@code ServicesResourceTransformer} ensures all {@code META-INF/services} descriptors survive JAR merging. - *
  2. External directory (Phase 2) — scans the directory configured by {@code ktestify.plugins.dir} (default - * {@code /workspace/plugins}) for {@code *.jar} files. Each JAR is added to a shared {@link URLClassLoader} - * (parent = current context classloader) and its plugins are discovered independently via - * {@link ServiceLoader#load(Class, ClassLoader)}. + *
  3. Classpath (Phase 1): {@link ServiceLoader#load(Class, ClassLoader)} on the current thread's context + * classloader. Picks up all plugins that are on the classpath (i.e. bundled as Maven dependencies in the fat + * JAR). The Shade {@code ServicesResourceTransformer} ensures all {@code META-INF/services} descriptors survive + * JAR merging. + *
  4. External directory (Phase 2): scans the directory configured by {@code ktestify.plugins.dir} (default + * {@code /workspace/plugins}) for {@code *.jar} files. All JARs are added to a shared {@link URLClassLoader} + * (parent = current context classloader). Only providers whose class is defined by that external classloader are + * loaded, so classpath plugins visible through parent delegation are never initialized a second time. *
* - *

Initialization order

+ *

Initialization order and failure handling

* - * Plugins are initialized in discovery order: classpath plugins first, then external plugins in filesystem order. A - * plugin whose {@link KtestifyPlugin#initialize(PluginContext)} throws will cause an immediate {@link PluginException} - * — the run is aborted. + *

Plugins are initialized in discovery order: classpath plugins first, then external plugins. Each plugin is + * initialized with the thread context classloader set to the classloader that defined it. + * + *

Two plugins declaring the same {@link KtestifyPlugin#getId() id} abort the run with a {@link PluginException}. A + * plugin whose {@link KtestifyPlugin#initialize(PluginContext)} throws, or a broken {@code META-INF/services} entry, + * also aborts the run. In every failure case, plugins that were already initialized are shut down in reverse order and + * the external classloader is closed before the exception propagates. * *

Usage

* @@ -52,7 +58,7 @@ * PluginRegistry registry = PluginRegistry.load(ctx); * * // Inject plugin glue packages into Cucumber CLI - * registry.getGluePackages().forEach(pkg -> args.add("--glue"); args.add(pkg)); + * registry.getGluePackages().forEach(pkg -> { args.add("--glue"); args.add(pkg); }); * * // On JVM shutdown * registry.shutdown(); @@ -71,8 +77,20 @@ public final class PluginRegistry { private final List plugins; - private PluginRegistry(List plugins) { + /** + * The {@link URLClassLoader} created for external plugin JARs, or {@code null} if no external JARs were found. + * + *

Kept open for the lifetime of the registry because loaded plugin classes reference it. It is closed in + * {@link #shutdown()} after all plugins have been shut down. + */ + private final URLClassLoader externalClassLoader; + + /** Guards {@link #shutdown()} so that plugins are shut down at most once. */ + private final AtomicBoolean shutDown = new AtomicBoolean(false); + + private PluginRegistry(List plugins, URLClassLoader externalClassLoader) { this.plugins = Collections.unmodifiableList(plugins); + this.externalClassLoader = externalClassLoader; } // ------------------------------------------------------------------------- @@ -84,19 +102,37 @@ private PluginRegistry(List plugins) { * * @param ctx the plugin context (config + services) handed to each plugin at init time * @return a fully initialized {@code PluginRegistry} - * @throws PluginException if any plugin fails to initialize + * @throws PluginException if a plugin fails to initialize, a provider cannot be instantiated, or two plugins share + * the same id */ public static PluginRegistry load(PluginContext ctx) { List all = new ArrayList<>(); + Map byId = new HashMap<>(); + URLClassLoader externalCL = null; LOG.info("Loading plugins..."); - // Phase 1 — classpath / fat-jar plugins - loadFromClasspath(ctx, all); + try { + // Phase 1: classpath / fat-jar plugins + ClassLoader contextCL = Thread.currentThread().getContextClassLoader(); + discover("classpath", ServiceLoader.load(KtestifyPlugin.class, contextCL), null, ctx, all, byId); - // Phase 2 — external plugin directory - String pluginsDir = resolvePluginsDir(ctx); - loadFromDirectory(pluginsDir, ctx, all); + // Phase 2: external plugin directory + externalCL = createExternalClassLoader(resolvePluginsDir(ctx), contextCL); + if (externalCL != null) { + discover("external", ServiceLoader.load(KtestifyPlugin.class, externalCL), externalCL, ctx, all, byId); + } + } catch (RuntimeException | ServiceConfigurationError e) { + LOG.error("Plugin loading failed, rolling back {} initialized plugin(s).", all.size()); + shutdownAll(all); + if (externalCL != null) { + closeClassLoaderQuietly(externalCL); + } + if (e instanceof PluginException pe) { + throw pe; + } + throw new PluginException("Plugin discovery failed: " + e.getMessage(), e); + } if (all.isEmpty()) { LOG.info("No plugins loaded."); @@ -107,7 +143,7 @@ public static PluginRegistry load(PluginContext ctx) { all.stream().map(p -> p.getId() + "@" + p.getVersion()).collect(Collectors.joining(", "))); } - return new PluginRegistry(all); + return new PluginRegistry(all, externalCL); } // ------------------------------------------------------------------------- @@ -126,35 +162,36 @@ public List getPlugins() { /** * Returns the Cucumber glue packages contributed by all loaded plugins. * - *

Each non-blank value returned by {@link KtestifyPlugin#getGluePackage()} is included. The caller should add - * each as a separate {@code --glue } argument to the Cucumber CLI. + *

Each non-blank value returned by {@link KtestifyPlugin#getGluePackage()} is included once. The caller should + * add each as a separate {@code --glue } argument to the Cucumber CLI. * - * @return an ordered list of glue package names — may be empty, never {@code null} + * @return an ordered, duplicate-free list of glue package names; may be empty, never {@code null} */ public List getGluePackages() { return plugins.stream() .map(KtestifyPlugin::getGluePackage) .filter(p -> p != null && !p.isBlank()) + .distinct() .collect(Collectors.toList()); } /** - * Shuts down all plugins in reverse initialization order. + * Shuts down all plugins in reverse initialization order, then closes the external plugin {@link URLClassLoader} if + * one was created. * - *

Exceptions thrown by individual plugins are caught, logged as warnings, and swallowed so the remaining plugins - * can still be shut down cleanly. + *

Exceptions thrown by individual plugins are caught and logged as warnings so the remaining plugins can still + * be shut down cleanly. Calling this method more than once has no further effect. */ public void shutdown() { - LOG.info("Shutting down {} plugin(s)…", plugins.size()); - List reversed = new ArrayList<>(plugins); - Collections.reverse(reversed); - for (KtestifyPlugin plugin : reversed) { - try { - plugin.shutdown(); - LOG.info("Plugin '{}' shut down.", plugin.getId()); - } catch (Exception e) { - LOG.warn("Error shutting down plugin '{}' — ignored: {}", plugin.getId(), e.getMessage()); - } + if (!shutDown.compareAndSet(false, true)) { + LOG.debug("Plugin registry already shut down, ignoring repeated call."); + return; + } + shutdownAll(plugins); + + // Close the external URLClassLoader to release file handles on plugin JARs. + if (externalClassLoader != null) { + closeClassLoaderQuietly(externalClassLoader); } } @@ -162,14 +199,43 @@ public void shutdown() { // Private helpers // ------------------------------------------------------------------------- - /** Phase 1: discovers plugins already on the current classloader (fat-jar deps). */ - private static void loadFromClasspath(PluginContext ctx, List target) { - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - ServiceLoader loader = ServiceLoader.load(KtestifyPlugin.class, cl); + /** + * Iterates over the providers of {@code loader}, instantiates and initializes each one, and appends it to + * {@code target}. + * + * @param phase a short label used in log messages + * @param loader the service loader to iterate + * @param requiredDefiningLoader when non-null, providers whose class is not defined by this classloader are skipped + * (they were already discovered through the parent classloader in Phase 1) + * @param ctx the plugin context + * @param target the list receiving initialized plugins + * @param byId index used to detect duplicate plugin ids + */ + private static void discover( + String phase, + ServiceLoader loader, + ClassLoader requiredDefiningLoader, + PluginContext ctx, + List target, + Map byId) { int before = target.size(); - for (KtestifyPlugin plugin : loader) { + for (ServiceLoader.Provider provider : loader.stream().toList()) { + Class type = provider.type(); + if (requiredDefiningLoader != null && type.getClassLoader() != requiredDefiningLoader) { + LOG.debug("[{}] Skipping '{}': already visible on the classpath.", phase, type.getName()); + continue; + } + + KtestifyPlugin plugin = provider.get(); + KtestifyPlugin existing = byId.putIfAbsent(plugin.getId(), plugin); + if (existing != null) { + throw new PluginException("Duplicate plugin id '" + plugin.getId() + "': provided by both " + + existing.getClass().getName() + " and " + type.getName() + "."); + } + LOG.info( - "[classpath] Plugin discovered: {} v{} (author: {} <{}>)", + "[{}] Plugin discovered: {} v{} (author: {} <{}>)", + phase, plugin.getId(), plugin.getVersion(), plugin.getAuthorName(), @@ -177,35 +243,37 @@ private static void loadFromClasspath(PluginContext ctx, List ta initPlugin(plugin, ctx); target.add(plugin); } - int loaded = target.size() - before; - LOG.debug("Phase 1 (classpath): {} plugin(s) discovered.", loaded); + LOG.debug("Phase '{}': {} plugin(s) loaded.", phase, target.size() - before); } /** - * Phase 2: scans an external directory for {@code .jar} files, loads them via a {@link URLClassLoader}, and - * discovers plugins inside each JAR. + * Builds a single {@link URLClassLoader} for every {@code *.jar} file in {@code dirPath}. + * + * @param dirPath the configured plugins directory, may be {@code null} or blank + * @param parent the parent classloader + * @return the classloader, or {@code null} when the directory is not configured, missing, or contains no JARs */ - private static void loadFromDirectory(String dirPath, PluginContext ctx, List target) { + private static URLClassLoader createExternalClassLoader(String dirPath, ClassLoader parent) { if (dirPath == null || dirPath.isBlank()) { LOG.debug("Phase 2 (external): plugins dir not configured."); - return; + return null; } File dir = new File(dirPath); - if (!dir.exists() || !dir.isDirectory()) { + if (!dir.isDirectory()) { LOG.debug("Phase 2 (external): directory '{}' does not exist.", dirPath); - return; + return null; } File[] jars = dir.listFiles(f -> f.isFile() && f.getName().endsWith(".jar")); if (jars == null || jars.length == 0) { - LOG.debug("Phase 2 (external): no *.jar files found in '{}'", dirPath); - return; + LOG.debug("Phase 2 (external): no *.jar files found in '{}'.", dirPath); + return null; } + Arrays.sort(jars); - LOG.info("[external] Scanning '{}' — {} JAR(s) found.", dirPath, jars.length); + LOG.info("[external] Scanning '{}': {} JAR(s) found.", dirPath, jars.length); - // Build a single URLClassLoader for all external jars (parent = current context CL) URL[] urls = Arrays.stream(jars) .map(f -> { try { @@ -216,30 +284,38 @@ private static void loadFromDirectory(String dirPath, PluginContext ctx, List loader = ServiceLoader.load(KtestifyPlugin.class, pluginCL); - int before = target.size(); - for (KtestifyPlugin plugin : loader) { - LOG.info( - "[external] Plugin discovered: {} v{} (author: {} <{}>, from '{}')", - plugin.getId(), - plugin.getVersion(), - plugin.getAuthorName(), - plugin.getAuthorEmail(), - dirPath); - initPlugin(plugin, ctx); - target.add(plugin); + /** Shuts down {@code list} in reverse order, logging and swallowing individual failures. */ + private static void shutdownAll(List list) { + LOG.info("Shutting down {} plugin(s)...", list.size()); + List reversed = new ArrayList<>(list); + Collections.reverse(reversed); + for (KtestifyPlugin plugin : reversed) { + try { + withContextClassLoader(plugin, plugin::shutdown); + LOG.info("Plugin '{}' shut down.", plugin.getId()); + } catch (Exception e) { + LOG.warn("Error shutting down plugin '{}', ignored.", plugin.getId(), e); + } + } + } + + /** Closes a {@link URLClassLoader} silently, logging any failure as a warning. */ + private static void closeClassLoaderQuietly(URLClassLoader cl) { + try { + cl.close(); + LOG.debug("External plugin URLClassLoader closed."); + } catch (Exception e) { + LOG.warn("Failed to close external plugin URLClassLoader, ignored.", e); } - int loaded = target.size() - before; - LOG.debug("Phase 2 (external): {} plugin(s) discovered from '{}'.", loaded, dirPath); } /** Calls {@link KtestifyPlugin#initialize(PluginContext)}, wrapping any exception in a {@link PluginException}. */ private static void initPlugin(KtestifyPlugin plugin, PluginContext ctx) { try { - plugin.initialize(ctx); + withContextClassLoader(plugin, () -> plugin.initialize(ctx)); LOG.info("Plugin '{}' initialized.", plugin.getId()); } catch (PluginException e) { throw e; // already wrapped @@ -248,12 +324,25 @@ private static void initPlugin(KtestifyPlugin plugin, PluginContext ctx) { } } + /** Runs {@code action} with the thread context classloader set to the classloader that defined {@code plugin}. */ + private static void withContextClassLoader(KtestifyPlugin plugin, Runnable action) { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + ClassLoader pluginLoader = plugin.getClass().getClassLoader(); + thread.setContextClassLoader(pluginLoader != null ? pluginLoader : previous); + try { + action.run(); + } finally { + thread.setContextClassLoader(previous); + } + } + /** Reads the configured plugins directory from HOCON, returning {@code null} if the path is absent. */ private static String resolvePluginsDir(PluginContext ctx) { try { return ctx.getConfig().getRaw().getString(PLUGINS_DIR_PATH); } catch (Exception e) { - LOG.debug("Could not read '{}' from config — external plugins disabled.", PLUGINS_DIR_PATH); + LOG.debug("Could not read '{}' from config, external plugins disabled.", PLUGINS_DIR_PATH); return null; } } diff --git a/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java b/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java index 720b06f..d5c1b3f 100644 --- a/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java +++ b/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java @@ -28,12 +28,23 @@ public final class FieldMatcherUtils { /** * Returns the line at the given 0-based index from a multi-line string. * + *

Both {@code \n} and {@code \r\n} line endings are supported; the line terminator is never part of the result. + * * @param content the full record content * @param line 0-based line index * @return the content of the requested line + * @throws IllegalArgumentException if the content is null or the line index is out of range */ public static String getLine(String content, int line) { - return content.split("\n")[line]; + if (content == null) { + throw new IllegalArgumentException("Content cannot be null."); + } + String[] lines = content.split("\r?\n", -1); + if (line < 0 || line >= lines.length) { + throw new IllegalArgumentException( + "Invalid line index " + line + " (content has " + lines.length + " line(s))."); + } + return lines[line]; } /** diff --git a/src/main/java/io/github/ktestify/utils/FileUtils.java b/src/main/java/io/github/ktestify/utils/FileUtils.java index d07e5a5..2175809 100644 --- a/src/main/java/io/github/ktestify/utils/FileUtils.java +++ b/src/main/java/io/github/ktestify/utils/FileUtils.java @@ -15,6 +15,7 @@ */ package io.github.ktestify.utils; +import io.github.ktestify.exceptions.ComparisonException; import io.github.ktestify.io.inputs.DynamicVariableProcessor; import java.io.*; import java.nio.charset.StandardCharsets; @@ -43,7 +44,7 @@ public static File getFile(String fullPath) { * * @param file the file to read * @return the content of the file as a String - * @throws RuntimeException if something went wrong + * @throws ComparisonException if the file cannot be read */ public static String getFileContent(File file) { try (InputStream inputStream = new FileInputStream(file)) { @@ -55,7 +56,7 @@ public static String getFileContent(File file) { return content; } catch (IOException e) { - throw new RuntimeException(e); + throw new ComparisonException("Failed to read file: " + file.getAbsolutePath(), e); } } @@ -64,15 +65,14 @@ public static String getFileContent(File file) { * * @param file The file for which to open the InputStream. * @return An InputStream for the specified file. - * @throws RuntimeException If an IOException occurs when opening the InputStream. + * @throws ComparisonException If an IOException occurs when opening the InputStream. */ public static InputStream getInputStream(File file) { try { return new FileInputStream(file); } catch (IOException e) { LOGGER.error("Something when wrong while getting file content from a File object : {} ", e.getMessage()); - - throw new RuntimeException(e); + throw new ComparisonException("Failed to open file input stream: " + file.getAbsolutePath(), e); } } @@ -82,7 +82,7 @@ public static InputStream getInputStream(File file) { * * @param file An InputStream to read from. * @return A string representing the content read from the InputStream. - * @throws RuntimeException If an IOException occurs during stream reading. + * @throws ComparisonException If an IOException occurs during stream reading. */ public static String getFileContent(InputStream file) { try { @@ -95,7 +95,7 @@ public static String getFileContent(InputStream file) { return content; } catch (IOException e) { LOGGER.error("Something when wrong while getting file content from a input stream : {} ", e.getMessage()); - throw new RuntimeException(e); + throw new ComparisonException("Failed to read from input stream", e); } } diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index 8ed212a..8e9f395 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -19,6 +19,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParser; import javax.xml.parsers.SAXParserFactory; import lombok.experimental.UtilityClass; @@ -26,16 +28,11 @@ import org.slf4j.LoggerFactory; import org.xml.sax.Attributes; import org.xml.sax.InputSource; +import org.xml.sax.SAXNotRecognizedException; +import org.xml.sax.SAXNotSupportedException; import org.xml.sax.helpers.DefaultHandler; import org.xmlunit.builder.DiffBuilder; -import org.xmlunit.diff.Comparison; -import org.xmlunit.diff.ComparisonResult; -import org.xmlunit.diff.ComparisonType; -import org.xmlunit.diff.DefaultNodeMatcher; -import org.xmlunit.diff.Diff; -import org.xmlunit.diff.Difference; -import org.xmlunit.diff.DifferenceEvaluator; -import org.xmlunit.diff.ElementSelectors; +import org.xmlunit.diff.*; /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. @@ -48,6 +45,84 @@ public final class XMLUtils { private static final Logger LOGGER = LoggerFactory.getLogger(XMLUtils.class); private static final int XMLNS_PREFIX_LENGTH = "xmlns:".length(); + /** + * Creates a hardened {@link SAXParserFactory} with XXE protections enabled. + * + *

Enables secure processing and disables external general/parameter entities, DOCTYPE declarations, and external + * DTD loading. Use this factory for every SAX parse of test data, including expected template files. + * + * @return a hardened, non-namespace-aware SAXParserFactory + * @since 1.1.4 + */ + public static SAXParserFactory newHardenedSAXParserFactory() { + SAXParserFactory factory = SAXParserFactory.newInstance(); + // Namespace-awareness is intentionally NOT enabled here: getNamespacesFromString + // relies on xmlns:* attributes being reported as regular attributes via getQName(), + // which namespace-aware mode would suppress. XXE hardening features below are + // independent of this setting. + try { + factory.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not enable secure processing on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disallow DOCTYPE declarations on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external general entities on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external parameter entities on SAXParserFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) { + LOGGER.warn("Could not disable external DTD loading on SAXParserFactory: {}", e.getMessage()); + } + return factory; + } + + /** + * Creates a hardened {@link DocumentBuilderFactory} with XXE protections enabled. + * + *

Disables external general/parameter entities, DOCTYPE declarations, and external DTD loading. Used by + * XMLUnit's {@link DiffBuilder} via {@code withDocumentBuilderFactory}. + * + * @return a hardened DocumentBuilderFactory + */ + private static DocumentBuilderFactory newHardenedDocumentBuilderFactory() { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + try { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disallow DOCTYPE declarations on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external general entities on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external parameter entities on DocumentBuilderFactory: {}", e.getMessage()); + } + try { + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (ParserConfigurationException e) { + LOGGER.warn("Could not disable external DTD loading on DocumentBuilderFactory: {}", e.getMessage()); + } + factory.setExpandEntityReferences(false); + factory.setXIncludeAware(false); + return factory; + } + /** * Extracts XML namespace declarations from a raw XML string. * @@ -57,7 +132,7 @@ public final class XMLUtils { public static Map getNamespacesFromString(String xmlContent) { Map namespaces = new HashMap<>(); try { - SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParserFactory factory = newHardenedSAXParserFactory(); SAXParser saxParser = factory.newSAXParser(); saxParser.parse(new InputSource(new StringReader(xmlContent)), new DefaultHandler() { @Override @@ -88,6 +163,7 @@ public static boolean compareXML(String actualValue, String expectedValue) { LOGGER.debug("Comparing XML:\nActual : {}\nExpected: {}", actualValue, expectedValue); Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText)) .checkForIdentical() .build(); @@ -118,6 +194,7 @@ public static boolean compareXML(String actualValue, String expectedValue, List< Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .normalizeWhitespace() .ignoreWhitespace() .withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText)) @@ -150,6 +227,7 @@ public static boolean compareXMLByXPath(String actualValue, String expectedValue Map namespaces = getNamespacesFromString(expectedValue); Diff diff = DiffBuilder.compare(actualValue) .withTest(expectedValue) + .withDocumentBuilderFactory(newHardenedDocumentBuilderFactory()) .withNodeMatcher(new DefaultNodeMatcher( ElementSelectors.byXPath(xPathExpression, namespaces, ElementSelectors.byNameAndText))) .checkForIdentical() @@ -229,7 +307,7 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome String controlXPath = comparison.getControlDetails().getXPath(); String testXPath = comparison.getTestDetails().getXPath(); - // 1. Direct hit — the differing node IS an excluded element + // 1. Direct hit: the differing node IS an excluded element if (isExcluded(controlXPath) || isExcluded(testXPath)) { return ComparisonResult.SIMILAR; } @@ -237,28 +315,32 @@ public ComparisonResult evaluate(Comparison comparison, ComparisonResult outcome // 2. Child-count difference caused by an excluded element being present in one // document but absent in the other. XMLUnit fires CHILD_NODELIST_LENGTH on // the *parent* node, so the XPath points to the parent (e.g. /order[1]), not - // to the excluded child. We mark it SIMILAR when the count delta equals the - // number of excluded elements that appear in either document. + // to the excluded child. + // + // Without DOM access we cannot determine which excluded elements are actual + // children of this parent. We conservatively suppress the length difference + // when the delta does not exceed the total number of excluded elements. + // This is safe because XMLUnit also fires CHILD_LOOKUP (point 3 below) for + // each missing/extra child: any non-excluded child will be caught there and + // reported as a real difference. if (comparison.getType() == ComparisonType.CHILD_NODELIST_LENGTH) { Object controlVal = comparison.getControlDetails().getValue(); Object testVal = comparison.getTestDetails().getValue(); if (controlVal instanceof Integer controlCount && testVal instanceof Integer testCount) { int delta = Math.abs(controlCount - testCount); - long excludedPresent = excludedElements.stream() - .filter(el -> xPathBelongsToParent(controlXPath, el) || xPathBelongsToParent(testXPath, el)) - .count(); - if (delta <= excludedPresent) { + if (delta > 0 && delta <= excludedElements.size()) { LOGGER.debug( - "Suppressing CHILD_NODELIST_LENGTH difference — " - + "delta {} covered by {} excluded element(s).", + "Suppressing CHILD_NODELIST_LENGTH difference, " + + "delta {} within {} excluded element(s); " + + "non-excluded children will be caught by CHILD_LOOKUP.", delta, - excludedPresent); + excludedElements.size()); return ComparisonResult.SIMILAR; } } } - // 3. CHILD_LOOKUP — one side is null (node absent in one document) + // 3. CHILD_LOOKUP: one side is null (node absent in one document) // Check whichever side is non-null. if (comparison.getType() == ComparisonType.CHILD_LOOKUP) { String nonNullXPath = controlXPath != null ? controlXPath : testXPath; @@ -278,16 +360,5 @@ private boolean isExcluded(String xPath) { || xPath.contains("/" + el + "/") || xPath.endsWith("/" + el)); } - - /** - * Returns true if an element named {@code el} would be a direct child of the node identified by - * {@code parentXPath} (used for child-count checks). We cannot resolve the actual DOM here, so we use the - * parent XPath as a proxy — any excluded element whose name is in our list is considered a potential child. - */ - private boolean xPathBelongsToParent(String parentXPath, String el) { - // We can't walk the DOM, so we conservatively consider any excluded element - // as a potential contributor to the count difference. - return parentXPath != null && !excludedElements.isEmpty(); - } } } diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java index 1023495..2a0780f 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java @@ -21,25 +21,14 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import org.apache.avro.Conversions.DecimalConversion; import org.apache.avro.Conversions.UUIDConversion; import org.apache.avro.LogicalType; import org.apache.avro.Schema; import org.apache.avro.Schema.Type; -import org.apache.avro.data.TimeConversions.DateConversion; -import org.apache.avro.data.TimeConversions.LocalTimestampMicrosConversion; -import org.apache.avro.data.TimeConversions.LocalTimestampMillisConversion; -import org.apache.avro.data.TimeConversions.TimeMicrosConversion; -import org.apache.avro.data.TimeConversions.TimeMillisConversion; -import org.apache.avro.data.TimeConversions.TimestampMicrosConversion; -import org.apache.avro.data.TimeConversions.TimestampMillisConversion; +import org.apache.avro.data.TimeConversions.*; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericFixed; import org.apache.avro.generic.GenericRecord; @@ -88,7 +77,7 @@ private AvroDeserializer() {} /** Deserializes a {@link GenericRecord} into an ordered {@link Map}, preserving schema field order. */ public static Map recordDeserializer(GenericRecord record) { if (record.getSchema() == null || record.getSchema().getFields() == null) { - LOGGER.error("Record schema or fields are null — returning empty map."); + LOGGER.error("Record schema or fields are null: returning empty map."); return new HashMap<>(); } return record.getSchema().getFields().stream() @@ -124,7 +113,7 @@ public static Object objectDeserializer(Object value, Schema schema) { } // ========================================================================= - // Private — primitive / compound + // Private: primitive / compound // ========================================================================= @SuppressWarnings("unchecked") @@ -137,12 +126,25 @@ private static Object primitiveDeserializer(Object value, Schema schema) { case ARRAY -> arrayDeserializer((Collection) value, schema); case FIXED -> ((GenericFixed) value).bytes(); case STRING -> ((CharSequence) value).toString(); - case BYTES -> ((ByteBuffer) value).array(); + case BYTES -> toByteArray((ByteBuffer) value); case INT, LONG, FLOAT, DOUBLE, BOOLEAN, NULL -> value; default -> throw new IllegalStateException(ERROR_UNEXPECTED_VALUE_TYPE + schema.getType()); }; } + /** + * Copies the readable bytes of {@code buffer} without disturbing its position. + * + *

{@link ByteBuffer#array()} is not used because it ignores the buffer's offset, position and limit, and fails + * for direct or read-only buffers. + */ + private static byte[] toByteArray(ByteBuffer buffer) { + ByteBuffer view = buffer.duplicate(); + byte[] bytes = new byte[view.remaining()]; + view.get(bytes); + return bytes; + } + private static Object unionDeserializer(Object value, Schema schema) { return objectDeserializer( value, @@ -172,7 +174,7 @@ private static Collection arrayDeserializer(Collection value, Schema schem } // ========================================================================= - // Private — logical types + // Private: logical types // ========================================================================= private static Instant timestampMicrosDeserializer(Object v, Schema s, Type t, LogicalType l) { diff --git a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java index d89de15..cbad55b 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -125,7 +125,14 @@ public static String convertMapToJsonString(Map map) { return "{}"; } - var gson = new GsonBuilder().setPrettyPrinting().create(); + // AvroDeserializer emits java.time values for logical types. Gson has no adapters for them and cannot + // reflect into JDK classes, so they are written as their ISO-8601 string form. + var gson = new GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter( + java.time.temporal.TemporalAccessor.class, (JsonSerializer) + (src, typeOfSrc, context) -> new JsonPrimitive(src.toString())) + .create(); return gson.toJson(map); } @@ -286,8 +293,7 @@ public static boolean deepEquals( * *

    *
  • A simple field name (e.g. {@code "timestamp"}), which excludes any field with that exact name at any - * nesting depth (top-level object, nested object, or inside array elements) — this is the historical - * behavior. + * nesting depth (top-level object, nested object, or inside array elements): this is the historical behavior. *
  • A dot-notation path (e.g. {@code "RootField.NestedField"}), which excludes only the field found at that * exact nested location, leaving sibling fields (and the parent object itself) subject to normal comparison. * Paths can be nested arbitrarily deep (e.g. {@code "a.b.c"}), and also apply within array elements (e.g. @@ -635,7 +641,7 @@ private static boolean isKeyExcluded(String key, String qualifiedKey, ListWhen both values are plain {@link String} instances, no date detection or conversion is attempted — they are + *

    When both values are plain {@link String} instances, no date detection or conversion is attempted: they are * compared literally, so genuine Avro {@code string} fields whose content happens to look like a date are never * misinterpreted. * @@ -730,7 +736,7 @@ private static boolean compareValues( * *

    Since the actual side has already been resolved to an {@link Instant} by {@link AvroDeserializer} (i.e. this * is genuinely a timestamp-millis/timestamp-micros logical-type field), we know for certain the expected string is - * meant to represent a timestamp, so no content-sniffing/guessing is required — only parsing. + * meant to represent a timestamp, so no content-sniffing/guessing is required: only parsing. * * @param expectedValue the expected date string * @return the parsed {@link Instant}, or null if it could not be parsed with any supported format @@ -1153,6 +1159,12 @@ public static Object convertJsonToAvro(@NonNull JsonElement jsonElement, @NonNul private static Object handleUnionType(JsonElement jsonElement, Schema fieldSchema) { LOGGER.debug("Found a UNION Type in the field schema"); + boolean nullable = fieldSchema.getTypes().stream().anyMatch(s -> s.getType() == Schema.Type.NULL); + if (nullable && (jsonElement == null || jsonElement.isJsonNull())) { + LOGGER.debug("JSON value is null and the union accepts NULL"); + return null; + } + for (var schema : fieldSchema.getTypes()) { if (schema.getType() != Schema.Type.NULL) { LOGGER.debug("Found a non-null union type, converting it into Avro"); diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 5ef30e4..19835b3 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -135,6 +135,10 @@ ktestify { assets = "" assets = ${?KTESTIFY_ASSETS_DIR} + # Directory for Avro/JSON schema files + schemas = "" + schemas = ${?KTESTIFY_SCHEMAS_DIR} + # Directory for test output/reports output = "" output = ${?KTESTIFY_OUTPUT_DIR} @@ -173,7 +177,7 @@ ktestify { # # Each key also has a corresponding environment variable override. logging { - # io.github.ktestify.* — the framework itself + # io.github.ktestify.*: the framework itself level = "DEBUG" level = ${?KTESTIFY_LOG_LEVEL} diff --git a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java index fe196b4..13d3bdb 100644 --- a/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/core/AbstractSynchronousConsumerTest.java @@ -61,7 +61,7 @@ void setUp() { // ========================================================================= @Nested - @DisplayName("call — success") + @DisplayName("call: success") class Success { @Test @@ -97,7 +97,7 @@ void wiresRequestAndContext() { } @Test - @DisplayName("does not close the client — the client outlives a single call") + @DisplayName("does not close the client: the client outlives a single call") void doesNotCloseClient() { when(client.execute(anyString())).thenReturn(records()); when(matcher.match(any(), any())).thenReturn(MatchResult.pass()); @@ -113,7 +113,7 @@ void doesNotCloseClient() { // ========================================================================= @Nested - @DisplayName("call — failure") + @DisplayName("call: failure") class Failure { @Test diff --git a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java index 5b70bb1..8020eb9 100644 --- a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java @@ -48,8 +48,31 @@ public void setUp() { @AfterEach public void tearDown() { - // Clean up after each test to avoid interference - DynamicVariableFactory.clearRegisteredVariables(); + // Restore the built-ins so other test classes are not affected + DynamicVariableFactory.resetToDefaults(); + } + + @Test + public void testLookupIsCaseInsensitive() { + DynamicVariableFactory.resetToDefaults(); + + assertTrue(DynamicVariableFactory.isRegistered("env")); + assertTrue(DynamicVariableFactory.isRegistered("ENV")); + assertTrue(DynamicVariableFactory.isRegistered("Env")); + assertSame(DynamicVariableFactory.getVariable("env"), DynamicVariableFactory.getVariable("ENV")); + } + + @Test + public void testGetVariableWithNullNameReturnsNull() { + assertNull(DynamicVariableFactory.getVariable(null)); + assertFalse(DynamicVariableFactory.isRegistered(null)); + } + + @Test + public void testResetToDefaultsRestoresBuiltIns() { + DynamicVariableFactory.resetToDefaults(); + + assertEquals(Set.of("date", "timestamp", "random", "env"), DynamicVariableFactory.getRegisteredVariableNames()); } @Test diff --git a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java index d66499f..c2d02ff 100644 --- a/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/types/TimestampVariableTest.java @@ -16,7 +16,6 @@ package io.github.ktestify.io.inputs.types; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -146,11 +145,10 @@ public void testProcessWithInvalidFormat() { public void testProcessWithNullFormat() { try (MockedStatic mockedDateTime = Mockito.mockStatic(LocalDateTime.class)) { mockedDateTime.when(LocalDateTime::now).thenReturn(FIXED_DATETIME); - - assertThrows( - NullPointerException.class, - () -> timestampVariable.process(null), - "Should throw NullPointerException for null format"); + assertEquals( + FIXED_DATETIME.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + timestampVariable.process(), + "Default process method should use ISO_LOCAL_DATE_TIME format"); } } diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java similarity index 93% rename from src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java rename to src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java index bba940e..bd865c3 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java @@ -43,13 +43,7 @@ import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; /** @@ -58,24 +52,24 @@ *

    Infrastructure: * *

      - *
    • {@link KafkaTestExtension} — Kafka broker via Testcontainers - *
    • {@link SchemaRegistryTestExtension} — Confluent Schema Registry via Testcontainers + *
    • {@link KafkaTestExtension}: Kafka broker via Testcontainers + *
    • {@link SchemaRegistryTestExtension}: Confluent Schema Registry via Testcontainers *
    * - *

    Records are seeded via a plain {@link KafkaProducer} with {@link KafkaAvroSerializer} — NOT the project's - * {@code AvroKafkaProducer} — keeping the consumer under test fully isolated from the producer implementation. + *

    Records are seeded via a plain {@link KafkaProducer} with {@link KafkaAvroSerializer}: NOT the project's + * {@code AvroKafkaProducer}: keeping the consumer under test fully isolated from the producer implementation. * *

    {@link KafkaRecordFetcher#clearMatchedRecords()} is called before every test to prevent deduplication state * leaking between tests. */ @ExtendWith({KafkaTestExtension.class, SchemaRegistryTestExtension.class}) @DisplayName("AvroKafkaConsumer Integration Tests") -class AvroKafkaConsumerTest { +class AvroKafkaConsumerITTests { private static final String TOPIC_PREFIX = "test-avro-consumer-"; // ------------------------------------------------------------------------- - // Shared Avro schema — same shape as expected-order.json + // Shared Avro schema: same shape as expected-order.json // ------------------------------------------------------------------------- static final Schema ORDER_SCHEMA = SchemaBuilder.record("Order") .namespace("io.github.ktestify.test") @@ -162,7 +156,7 @@ private GenericRecord differentOrder(String orderId, String status) { /** Absolute path of a classpath resource under {@code match/}. */ private static String resourcePath(String filename) { - URL url = AvroKafkaConsumerTest.class.getClassLoader().getResource("match/" + filename); + URL url = AvroKafkaConsumerITTests.class.getClassLoader().getResource("match/" + filename); assertNotNull(url, "Test resource not found: match/" + filename); return url.getPath(); } @@ -204,7 +198,7 @@ private org.apache.kafka.clients.consumer.Consumer create // ========================================================================= @Nested - @DisplayName("Consume-only — no matcher") + @DisplayName("Consume-only: no matcher") class ConsumeOnly { @Test @@ -231,11 +225,11 @@ void throwsWhenTopicEmpty() { } // ========================================================================= - // File matching — AvroFileRecordMatcher + // File matching: AvroFileRecordMatcher // ========================================================================= @Nested - @DisplayName("File matching — METHOD_MATCH_FILE") + @DisplayName("File matching: METHOD_MATCH_FILE") class FileMatching { @Test @@ -280,7 +274,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter — expectedRecordKey") + @DisplayName("Key filter: expectedRecordKey") class KeyFilter { @Test @@ -307,7 +301,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("Batch consumption — isBatchConsumer(true)") + @DisplayName("Batch consumption: isBatchConsumer(true)") class BatchConsumption { /** 4 orders with distinct keys and field values. */ @@ -376,7 +370,7 @@ void throwsWhenNotEnoughRecords() throws Exception { } @Test - @DisplayName("all 4 Avro records are registered as matched — a second consumer finds nothing") + @DisplayName("all 4 Avro records are registered as matched: a second consumer finds nothing") void batchRecordsAreDeduplicated() throws Exception { seedBatch(); @@ -391,7 +385,7 @@ void batchRecordsAreDeduplicated() throws Exception { .build()) .call(); - // Second consumer — all records already matched, must time out + // Second consumer: all records already matched, must time out assertThrows( ConsumerException.class, () -> new AvroKafkaConsumer(ConsumerContext.builder() diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerITTests.java similarity index 98% rename from src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java rename to src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerITTests.java index f40d031..e1e2b09 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerITTests.java @@ -22,6 +22,7 @@ import io.confluent.kafka.serializers.KafkaAvroSerializerConfig; import io.github.ktestify.config.ConfigBuilder; import io.github.ktestify.config.KtestifyConfig; +import io.github.ktestify.exceptions.ProducerException; import io.github.ktestify.io.kafka.impl.AvroKafkaProducer; import io.github.ktestify.models.Topic; import io.github.ktestify.tests.extentions.KafkaTestExtension; @@ -32,28 +33,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; -import java.util.UUID; +import java.util.*; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; -import org.apache.kafka.clients.consumer.Consumer; -import org.apache.kafka.clients.consumer.ConsumerConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.*; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -79,7 +66,7 @@ */ @ExtendWith({KafkaTestExtension.class, SchemaRegistryTestExtension.class}) @DisplayName("AvroKafkaProducer Integration Tests") -class AvroKafkaProducerTest { +class AvroKafkaProducerITTests { private static final String TEST_TOPIC_PREFIX = "test-avro-producer-"; @@ -1007,7 +994,7 @@ void shouldThrowExceptionForNullPayloads() throws Exception { AvroKafkaProducer avroProducer = new AvroKafkaProducer( topic, null, producer, null, Map.of(), null, "com.example.avro.NoPayloadRecord"); - assertThrows(IllegalStateException.class, avroProducer::send); + assertThrows(ProducerException.class, avroProducer::send); } @Test diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java similarity index 92% rename from src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java rename to src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java index 95bc220..03a0a7a 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java @@ -34,27 +34,21 @@ import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringSerializer; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; /** * Integration tests for {@link RawKafkaConsumer}. * - *

    Kafka broker is provided by {@link KafkaTestExtension}. Records are seeded via a plain {@link KafkaProducer} — NOT - * the project's RawKafkaProducer — keeping the consumer under test fully isolated from the producer implementation. + *

    Kafka broker is provided by {@link KafkaTestExtension}. Records are seeded via a plain {@link KafkaProducer}: NOT + * the project's RawKafkaProducer: keeping the consumer under test fully isolated from the producer implementation. * *

    Every test gets a fresh, uniquely-named topic and {@link KafkaRecordFetcher#clearMatchedRecords()} is called * before each test to prevent deduplication state leaking between tests. */ @ExtendWith(KafkaTestExtension.class) @DisplayName("RawKafkaConsumer Integration Tests") -class RawKafkaConsumerTest { +class RawKafkaConsumerITTests { private static final String TOPIC_PREFIX = "test-raw-consumer-"; @@ -136,7 +130,7 @@ private Topic outputTopic() { /** Absolute path of a classpath resource under {@code match/}. */ private static String resourcePath(String filename) { - URL url = RawKafkaConsumerTest.class.getClassLoader().getResource("match/" + filename); + URL url = RawKafkaConsumerITTests.class.getClassLoader().getResource("match/" + filename); assertNotNull(url, "Test resource not found: match/" + filename); return url.getPath(); } @@ -157,7 +151,7 @@ private ConsumerContext ctx() { // ========================================================================= @Nested - @DisplayName("Consume-only — no matcher") + @DisplayName("Consume-only: no matcher") class ConsumeOnly { @Test @@ -172,7 +166,7 @@ void returnsTrueWhenRecordPresent() throws Exception { @Test @DisplayName("throws ConsumerException when topic is empty and timeout elapses") void throwsWhenTopicEmpty() { - // Do NOT seed — consumer must time out + // Do NOT seed: consumer must time out ConsumerContext ctx = ConsumerContext.builder() .topic(outputTopic()) .consumer(KafkaClientFactory.createRawConsumer( @@ -190,7 +184,7 @@ void throwsWhenTopicEmpty() { // ========================================================================= @Nested - @DisplayName("File matching — METHOD_MATCH_FILE") + @DisplayName("File matching: METHOD_MATCH_FILE") class FileMatching { @Test @@ -199,7 +193,7 @@ void matchesFile() throws Exception { // The file content has a trailing newline; trim so the raw value sent // over Kafka equals what FileUtils reads from the file. String fileContent = new String( - RawKafkaConsumerTest.class + RawKafkaConsumerITTests.class .getClassLoader() .getResourceAsStream("match/expected-order.json") .readAllBytes(), @@ -245,7 +239,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter — expectedRecordKey") + @DisplayName("Key filter: expectedRecordKey") class KeyFilter { @Test @@ -273,7 +267,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("XML matching — METHOD_MATCH_XML") + @DisplayName("XML matching: METHOD_MATCH_XML") class XmlMatching { @Test @@ -301,7 +295,7 @@ void matchesXml() throws Exception { // ========================================================================= @Nested - @DisplayName("Batch consumption — isBatchConsumer(true)") + @DisplayName("Batch consumption: isBatchConsumer(true)") class BatchConsumption { /** The 4 payloads seeded in every test in this nested class. */ @@ -350,7 +344,7 @@ void throwsWhenNotEnoughRecords() throws Exception { .topic(outputTopic()) .consumer(KafkaClientFactory.createRawConsumer( KtestifyConfig.getOrLoad(), "raw-batch-short-" + UUID.randomUUID())) - .readTimeout(3_000L) // short timeout — we expect a failure + .readTimeout(3_000L) // short timeout: we expect a failure .consumerDeltaTime(60_000L) .isBatchConsumer(true) .batchSize(4) @@ -359,7 +353,7 @@ void throwsWhenNotEnoughRecords() throws Exception { } @Test - @DisplayName("all 4 records are registered as matched — a second consumer finds nothing") + @DisplayName("all 4 records are registered as matched: a second consumer finds nothing") void batchRecordsAreDeduplicated() throws Exception { seedBatch(); @@ -375,7 +369,7 @@ void batchRecordsAreDeduplicated() throws Exception { .build()) .call(); - // Second consumer — all records already matched, so it must time out + // Second consumer: all records already matched, so it must time out assertThrows( ConsumerException.class, () -> new RawKafkaConsumer(ConsumerContext.builder() @@ -392,12 +386,12 @@ void batchRecordsAreDeduplicated() throws Exception { } // ========================================================================= - // referenceTimestamp — pinned "now" fixes clock-drift across delayed fetches + // referenceTimestamp: pinned "now" fixes clock-drift across delayed fetches // (see https://github.com/ktestify/ktestify-cucumber/issues/38) // ========================================================================= @Nested - @DisplayName("referenceTimestamp — pinned 'now' avoids clock drift") + @DisplayName("referenceTimestamp: pinned 'now' avoids clock drift") class ReferenceTimestamp { /** Narrow enough that a few seconds of drift pushes the seek window past the seeded record. */ @@ -412,7 +406,7 @@ void recordIsMissedDueToClockDriftWithoutReferenceTimestamp() throws Exception { seedRecord("KEY-1", "{\"orderId\":\"ORD-DRIFT\"}"); // Simulate the delay a slow Cucumber step (or a previous DataTable row) would introduce - // before this consumer actually seeks — this is exactly the drift the maintainer described + // before this consumer actually seeks: this is exactly the drift the maintainer described // in issue #38. Thread.sleep(SIMULATED_STEP_DELAY_MS); @@ -422,7 +416,7 @@ void recordIsMissedDueToClockDriftWithoutReferenceTimestamp() throws Exception { KtestifyConfig.getOrLoad(), "drift-consumer-" + UUID.randomUUID())) .readTimeout(3_000L) .consumerDeltaTime(NARROW_DELTA_TIME_MS) - // No referenceTimestamp — "now" is resolved live, at seek time. + // No referenceTimestamp: "now" is resolved live, at seek time. .build(); assertThrows( @@ -467,7 +461,7 @@ void sequentialFetchesShareSameSeekWindow() throws Exception { seedRecord("KEY-1", "{\"orderId\":\"ORD-A\"}"); seedRecord("KEY-2", "{\"orderId\":\"ORD-B\"}"); - // First "row" — simulate a small delay before it runs. + // First "row": simulate a small delay before it runs. Thread.sleep(1_500L); boolean firstResult = new RawKafkaConsumer(ConsumerContext.builder() .topic(outputTopic()) @@ -480,7 +474,7 @@ void sequentialFetchesShareSameSeekWindow() throws Exception { .build()) .call(); - // Second "row" — additional delay elapses before it runs too. + // Second "row": additional delay elapses before it runs too. Thread.sleep(1_500L); boolean secondResult = new RawKafkaConsumer(ConsumerContext.builder() .topic(outputTopic()) diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerITTests.java similarity index 97% rename from src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java rename to src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerITTests.java index 5230a87..83956be 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerITTests.java @@ -30,25 +30,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; -import java.util.UUID; -import org.apache.kafka.clients.consumer.Consumer; -import org.apache.kafka.clients.consumer.ConsumerConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.apache.kafka.clients.consumer.ConsumerRecords; -import org.apache.kafka.clients.consumer.KafkaConsumer; +import java.util.*; +import org.apache.kafka.clients.consumer.*; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -68,7 +55,7 @@ */ @ExtendWith(KafkaTestExtension.class) @DisplayName("RawKafkaProducer Integration Tests") -class RawKafkaProducerTest { +class RawKafkaProducerITTests { private static final String TEST_TOPIC_PREFIX = "test-raw-producer-"; @@ -230,7 +217,7 @@ void shouldThrowExceptionForEmptyPayload() { // Then - empty string payload should throw IllegalStateException // because resolvePayload() treats empty string as no payload - assertThrows(IllegalStateException.class, rawProducer::send); + assertThrows(ProducerException.class, rawProducer::send); } } @@ -571,7 +558,7 @@ void shouldThrowExceptionWhenNoPayloadProvided() { RawKafkaProducer rawProducer = new RawKafkaProducer(context); // When/Then - assertThrows(IllegalStateException.class, rawProducer::send); + assertThrows(ProducerException.class, rawProducer::send); } } diff --git a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java index f4f69ef..5d485c9 100644 --- a/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java +++ b/src/test/java/io/github/ktestify/match/RecordMatcherFactoryTest.java @@ -18,7 +18,7 @@ import static io.github.ktestify.match.RecordMatcherFactory.*; import static org.junit.jupiter.api.Assertions.*; -import io.github.ktestify.exceptions.ConsumerException; +import io.github.ktestify.exceptions.ConfigException; import io.github.ktestify.match.impl.*; import org.apache.avro.generic.GenericRecord; import org.junit.jupiter.api.DisplayName; @@ -36,7 +36,7 @@ class RecordMatcherFactoryTest { // ========================================================================= @Nested - @DisplayName("forRaw — known methods") + @DisplayName("forRaw: known methods") class ForRawKnownMethods { @Test @@ -83,7 +83,7 @@ void matchAttributes() { } @Nested - @DisplayName("forRaw — null/blank → NoOpRecordMatcher") + @DisplayName("forRaw: null/blank → NoOpRecordMatcher") class ForRawNullBlank { @ParameterizedTest(name = "forRaw(\"{0}\") → NoOpRecordMatcher") @@ -95,13 +95,13 @@ void returnsNoOp(String method) { } @Nested - @DisplayName("forRaw — unknown method → ConsumerException") + @DisplayName("forRaw: unknown method → ConsumerException") class ForRawUnknown { @Test @DisplayName("throws ConsumerException for an unrecognised method name") void throwsForUnknown() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forRaw("methodDoesNotExist")); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forRaw("methodDoesNotExist")); } } @@ -110,7 +110,7 @@ void throwsForUnknown() { // ========================================================================= @Nested - @DisplayName("forAvro — known methods") + @DisplayName("forAvro: known methods") class ForAvroKnownMethods { @Test @@ -139,7 +139,7 @@ void matchKey() { } @Nested - @DisplayName("forAvro — null/blank → NoOpRecordMatcher") + @DisplayName("forAvro: null/blank → NoOpRecordMatcher") class ForAvroNullBlank { @ParameterizedTest(name = "forAvro(\"{0}\") → NoOpRecordMatcher") @@ -152,31 +152,31 @@ void returnsNoOp(String method) { } @Nested - @DisplayName("forAvro — unknown method → ConsumerException") + @DisplayName("forAvro: unknown method → ConsumerException") class ForAvroUnknown { @Test @DisplayName("throws ConsumerException for an unrecognised method name") void throwsForUnknown() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); } @Test @DisplayName("throws ConsumerException for XML method (not supported for Avro)") void throwsForXml() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); } @Test @DisplayName("throws ConsumerException for XPath method (not supported for Avro)") void throwsForXPath() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); } @Test @DisplayName("throws ConsumerException for attribute method (raw transports only)") void throwsForAttributes() { - assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_ATTRIBUTES)); + assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_ATTRIBUTES)); } } diff --git a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java index 0f5c6f0..0a10bea 100644 --- a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java @@ -147,10 +147,12 @@ void multipleMismatchesReported() { class Misconfiguration { @Test - @DisplayName("throws ComparisonException when there is no record to inspect") - void throwsWithoutRecords() { + @DisplayName("fails with noRecords when there is no record to inspect, like every other matcher") + void failsWithoutRecords() throws ComparisonException { MatchContext context = ctxWithAttributes(Map.of("statusCode", "200")); - assertThrows(ComparisonException.class, () -> matcher.match(List.of(), context)); + MatchResult result = matcher.match(List.of(), context); + assertFalse(result.isPassed()); + assertEquals(MatchResult.noRecords().getDiff(), result.getDiff()); } } } diff --git a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java index 4ed40c3..152bc42 100644 --- a/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java +++ b/src/test/java/io/github/ktestify/match/impl/MatcherTestSupport.java @@ -27,7 +27,7 @@ import org.apache.avro.generic.GenericRecord; /** - * Shared test helpers for {@link RecordMatcher} unit tests. Keeps each test class lean — no boilerplate record + * Shared test helpers for {@link RecordMatcher} unit tests. Keeps each test class lean: no boilerplate record * construction. */ final class MatcherTestSupport { @@ -62,7 +62,7 @@ static MatchContext ctxWithAttributes(Map expectedAttributes) { /** * Returns the absolute path of a classpath resource inside {@code src/test/resources/match/}. Throws - * {@link IllegalStateException} if the resource is not found — catches typos early. + * {@link IllegalStateException} if the resource is not found: catches typos early. */ static String resourcePath(String filename) { String path = "match/" + filename; diff --git a/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java new file mode 100644 index 0000000..79a6ba6 --- /dev/null +++ b/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * 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 + * + * http://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 io.github.ktestify.match.impl; + +import static io.github.ktestify.match.impl.MatcherTestSupport.*; +import static org.junit.jupiter.api.Assertions.*; + +import io.github.ktestify.exceptions.ComparisonException; +import io.github.ktestify.match.MatchContext; +import io.github.ktestify.match.MatchResult; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Checks that every matcher handles a tombstone (record with a {@code null} value) the same way: value matchers fail + * cleanly without throwing, key-only and attribute matchers ignore the value. + */ +@DisplayName("Tombstone handling across all matchers") +class TombstoneMatcherTest { + + private static final String KEY = "test-key"; + + private static void assertNullValueFailure(MatchResult result) { + assertFalse(result.isPassed()); + assertTrue(result.getDiff().contains(MatchResult.NULL_VALUE_MESSAGE), result.getDiff()); + } + + @Nested + @DisplayName("String value matchers fail with the tombstone message") + class StringValueMatchers { + + @Test + void fileRecordMatcher() throws ComparisonException { + MatchResult result = + new FileRecordMatcher().match(rawRecord(KEY, null), ctxWithFile("expected-order.json")); + assertNullValueFailure(result); + assertNull(result.getActual()); + } + + @Test + void fileKeyRecordMatcher() throws ComparisonException { + MatchResult result = new FileKeyRecordMatcher() + .match(rawRecord(KEY, null), ctxWithFileAndKey("expected-order.json", KEY)); + assertNullValueFailure(result); + } + + @Test + void fieldsRecordMatcherInline() throws ComparisonException { + MatchResult result = + new FieldsRecordMatcher().match(rawRecord(KEY, null), ctxWithKeyAndValue("0:0:3", "ABC")); + assertNullValueFailure(result); + } + + @Test + void fieldsRecordMatcherFromFile() throws ComparisonException { + MatchContext ctx = MatchContext.builder() + .matchKey("0:0:3") + .matchFilePath(resourcePath("positional-record.txt")) + .build(); + assertNullValueFailure(new FieldsRecordMatcher().match(rawRecord(KEY, null), ctx)); + } + + @Test + void xmlRecordMatcher() throws ComparisonException { + assertNullValueFailure( + new XmlRecordMatcher().match(rawRecord(KEY, null), ctxWithFile("expected-order.xml"))); + } + + @Test + void xPathRecordMatcher() throws ComparisonException { + MatchContext ctx = MatchContext.builder() + .matchFilePath(resourcePath("expected-order.xml")) + .excludedFields(List.of("/order/id")) + .build(); + assertNullValueFailure(new XPathRecordMatcher().match(rawRecord(KEY, null), ctx)); + } + } + + @Nested + @DisplayName("Avro value matchers fail with the tombstone message") + class AvroValueMatchers { + + @Test + void avroFileRecordMatcher() throws ComparisonException { + assertNullValueFailure( + new AvroFileRecordMatcher().match(avroRecord(KEY, null), ctxWithFile("expected-order.json"))); + } + + @Test + void avroFileKeyRecordMatcher() throws ComparisonException { + assertNullValueFailure(new AvroFileKeyRecordMatcher() + .match(avroRecord(KEY, null), ctxWithFileAndKey("expected-order.json", KEY))); + } + + @Test + void avroFieldsRecordMatcherInline() throws ComparisonException { + assertNullValueFailure( + new AvroFieldsRecordMatcher().match(avroRecord(KEY, null), ctxWithKeyAndValue("orderId", "1"))); + } + + @Test + void avroFieldsRecordMatcherFromFile() throws ComparisonException { + assertNullValueFailure(new AvroFieldsRecordMatcher() + .match(avroRecord(KEY, null), ctxWithFileAndKey("expected-order.json", "orderId"))); + } + + @Test + void avroFieldsRecordMatcherMultiField() throws ComparisonException { + assertNullValueFailure(new AvroFieldsRecordMatcher() + .match(avroRecord(KEY, null), ctxWithKeyValues(Map.of("orderId", "1")))); + } + } + + @Nested + @DisplayName("Matchers that never read the value accept a tombstone") + class ValueAgnosticMatchers { + + @Test + void keyRecordMatcher() throws ComparisonException { + assertTrue(new KeyRecordMatcher() + .match(rawRecord(KEY, null), ctxWithKey(KEY)) + .isPassed()); + } + + @Test + void avroKeyRecordMatcher() throws ComparisonException { + assertTrue(new AvroKeyRecordMatcher() + .match(avroRecord(KEY, null), ctxWithKey(KEY)) + .isPassed()); + } + + @Test + void noOpRecordMatcher() { + assertTrue(new NoOpRecordMatcher() + .match(rawRecord(KEY, null), MatchContext.builder().build()) + .isPassed()); + } + } + + @Nested + @DisplayName("Every matcher returns noRecords for an empty or null list") + class EmptyInput { + + @Test + void noOpToleratesNullList() { + assertTrue(new NoOpRecordMatcher() + .match(null, MatchContext.builder().build()) + .isPassed()); + } + + @Test + void attributeMatcherReturnsNoRecords() throws ComparisonException { + MatchResult result = new AttributeRecordMatcher() + .match( + List.of(), + MatchContext.builder() + .expectedAttributes(Map.of("statusCode", "200")) + .build()); + assertFalse(result.isPassed()); + } + } +} diff --git a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java index 6a61d36..21d18ce 100644 --- a/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/XmlRecordMatcherTest.java @@ -106,7 +106,7 @@ void differentXml() throws ComparisonException { @Test @DisplayName("fails when differing element is not in the exclusion list") void failsWhenNotExcluded() throws ComparisonException { - // status differs but only orderId is excluded — should still fail + // status differs but only orderId is excluded: should still fail MatchResult result = matcher.match( rawRecord(STATUS_ONLY_DIFF_XML), ctxWithFileAndExclusions("expected-order.xml", List.of("orderId"))); diff --git a/src/test/java/io/github/ktestify/models/TopicTest.java b/src/test/java/io/github/ktestify/models/TopicTest.java index 91057dd..4e2cd3b 100644 --- a/src/test/java/io/github/ktestify/models/TopicTest.java +++ b/src/test/java/io/github/ktestify/models/TopicTest.java @@ -20,14 +20,10 @@ import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; /** - * Unit tests for {@link Topic#validateTopic(Topic)} — focusing on the namespace auto-injection logic: + * Unit tests for {@link Topic#validateTopic(Topic)}: focusing on the namespace auto-injection logic: * *

      * if (topic.getTopicNamespace() == null || blank)
    @@ -35,7 +31,7 @@
      * → inject the config namespace
      * 
    */ -@DisplayName("Topic.validateTopic — namespace auto-injection") +@DisplayName("Topic.validateTopic: namespace auto-injection") class TopicTest { // ----------------------------------------------------------------------- @@ -56,7 +52,7 @@ private void loadConfigWithoutNamespace() { .resolve()); } - /** Minimal valid INPUT topic — no namespace attached. */ + /** Minimal valid INPUT topic: no namespace attached. */ private Topic inputTopicWithoutNamespace(String name) { return Topic.builder().topicName(name).topicType(Topic.Type.INPUT).build(); } @@ -173,7 +169,7 @@ void blankNamespace_staysUnchanged() { } @Nested - @DisplayName("validateTopic — general guard-rail assertions") + @DisplayName("validateTopic: general guard-rail assertions") class GeneralValidation { @BeforeEach diff --git a/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java b/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java new file mode 100644 index 0000000..62eec80 --- /dev/null +++ b/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * 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 + * + * http://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 io.github.ktestify.plugin; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Test-only plugin registered through {@code src/test/resources/META-INF/services}. It counts lifecycle calls so tests + * can assert that {@link PluginRegistry} initializes and shuts down each plugin exactly once. + */ +public class ClasspathTestPlugin implements KtestifyPlugin { + + /** Plugin id exposed by this test plugin. */ + public static final String ID = "classpath-test"; + + /** Number of {@link #initialize(PluginContext)} calls across all instances. */ + public static final AtomicInteger INIT_CALLS = new AtomicInteger(); + + /** Number of {@link #shutdown()} calls across all instances. */ + public static final AtomicInteger SHUTDOWN_CALLS = new AtomicInteger(); + + /** Resets the lifecycle counters. */ + public static void resetCounters() { + INIT_CALLS.set(0); + SHUTDOWN_CALLS.set(0); + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getVersion() { + return "0.0.0-test"; + } + + @Override + public String getGluePackage() { + return "io.github.ktestify.plugin.testglue"; + } + + @Override + public void initialize(PluginContext context) { + INIT_CALLS.incrementAndGet(); + } + + @Override + public void shutdown() { + SHUTDOWN_CALLS.incrementAndGet(); + } +} diff --git a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java index 13665ab..dc1a347 100644 --- a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java +++ b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java @@ -20,10 +20,15 @@ import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.PluginException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; @@ -50,11 +55,11 @@ void tearDown() { } // ========================================================================= - // PluginRegistry — no plugins + // PluginRegistry: no plugins // ========================================================================= @Nested - @DisplayName("load() — no plugins on classpath / empty dir") + @DisplayName("load(): no plugins on classpath / empty dir") class NoPluginsTests { @Test @@ -64,13 +69,13 @@ void returnsEmptyRegistryWhenNoPlugins() { // (classpath plugins from the main scope are not included in the test CL hierarchy) PluginRegistry registry = PluginRegistry.load(CTX); assertNotNull(registry); - // getGluePackages may be empty or contain classpath plugins — either is valid + // getGluePackages may be empty or contain classpath plugins: either is valid assertNotNull(registry.getPlugins()); assertNotNull(registry.getGluePackages()); } @Test - @DisplayName("empty plugins dir — loads cleanly with no external plugins") + @DisplayName("empty plugins dir: loads cleanly with no external plugins") void emptyPluginsDirLoadsCleanly(@TempDir Path tempDir) { KtestifyConfig cfg = KtestifyConfig.load( ConfigFactory.parseString("ktestify.plugins.dir = \"" + tempDir.toAbsolutePath() + "\"")); @@ -80,7 +85,7 @@ void emptyPluginsDirLoadsCleanly(@TempDir Path tempDir) { } @Test - @DisplayName("non-existent plugins dir — loads cleanly") + @DisplayName("non-existent plugins dir: loads cleanly") void nonExistentPluginsDirLoadsCleanly() { KtestifyConfig cfg = KtestifyConfig.load( ConfigFactory.parseString("ktestify.plugins.dir = \"/does/not/exist/plugins\"")); @@ -89,7 +94,7 @@ void nonExistentPluginsDirLoadsCleanly() { } @Test - @DisplayName("blank plugins dir — skips external loading") + @DisplayName("blank plugins dir: skips external loading") void blankPluginsDirSkipsExternalLoading() { KtestifyConfig cfg = KtestifyConfig.load(ConfigFactory.parseString("ktestify.plugins.dir = \"\"")); @@ -98,7 +103,7 @@ void blankPluginsDirSkipsExternalLoading() { } // ========================================================================= - // PluginRegistry — shutdown + // PluginRegistry: shutdown // ========================================================================= @Nested @@ -111,10 +116,71 @@ void shutdownOnEmptyRegistryDoesNotThrow() { PluginRegistry registry = PluginRegistry.load(CTX); assertDoesNotThrow(registry::shutdown); } + + @Test + @DisplayName("shutdown() is idempotent: plugins are shut down only once") + void shutdownIsIdempotent() { + ClasspathTestPlugin.resetCounters(); + PluginRegistry registry = PluginRegistry.load(CTX); + + registry.shutdown(); + registry.shutdown(); + + assertEquals(1, ClasspathTestPlugin.SHUTDOWN_CALLS.get()); + } + } + + // ========================================================================= + // PluginRegistry: classpath + external directory + // ========================================================================= + + @Nested + @DisplayName("load(): classpath and external directory together") + class DiscoveryTests { + + @Test + @DisplayName("classpath plugin is loaded and initialized exactly once without an external dir") + void classpathPluginLoadedOnce() { + ClasspathTestPlugin.resetCounters(); + PluginRegistry registry = PluginRegistry.load(CTX); + + assertEquals(1, countById(registry, ClasspathTestPlugin.ID)); + assertEquals(1, ClasspathTestPlugin.INIT_CALLS.get()); + registry.shutdown(); + } + + @Test + @DisplayName("classpath plugin is not rediscovered through the external classloader's parent") + void classpathPluginNotDuplicatedByExternalPhase(@TempDir Path tempDir) throws Exception { + // Any JAR in the plugins dir triggers Phase 2, whose URLClassLoader delegates to the classpath. + Path jar = tempDir.resolve("unrelated.jar"); + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) { + out.putNextEntry(new JarEntry("placeholder.txt")); + out.write("x".getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + KtestifyConfig cfg = KtestifyConfig.load( + ConfigFactory.parseString("ktestify.plugins.dir = \"" + tempDir.toAbsolutePath() + "\"")); + ClasspathTestPlugin.resetCounters(); + + PluginRegistry registry = PluginRegistry.load(() -> cfg); + + assertEquals(1, countById(registry, ClasspathTestPlugin.ID)); + assertEquals(1, ClasspathTestPlugin.INIT_CALLS.get()); + assertEquals(1, Collections.frequency(registry.getGluePackages(), "io.github.ktestify.plugin.testglue")); + registry.shutdown(); + assertEquals(1, ClasspathTestPlugin.SHUTDOWN_CALLS.get()); + } + + private long countById(PluginRegistry registry, String id) { + return registry.getPlugins().stream() + .filter(p -> id.equals(p.getId())) + .count(); + } } // ========================================================================= - // PluginRegistry — getGluePackages + // PluginRegistry: getGluePackages // ========================================================================= @Nested @@ -122,7 +188,7 @@ void shutdownOnEmptyRegistryDoesNotThrow() { class GluePackagesTests { @Test - @DisplayName("returns list — never null") + @DisplayName("returns list: never null") void getGluePackagesNeverNull() { PluginRegistry registry = PluginRegistry.load(CTX); assertNotNull(registry.getGluePackages()); @@ -131,7 +197,7 @@ void getGluePackagesNeverNull() { @Test @DisplayName("filters out null and blank glue packages") void filtersNullAndBlankGluePackages() { - // StubPlugin with null glue — should not appear in result + // StubPlugin with null glue: should not appear in result StubPlugin nullGlue = new StubPlugin("null-glue", "1.0", null, "A", "a@a.com"); StubPlugin blankGlue = new StubPlugin("blank-glue", "1.0", " ", "A", "a@a.com"); StubPlugin realGlue = new StubPlugin("real-glue", "1.0", "io.github.ktestify.foo", "A", "a@a.com"); @@ -148,11 +214,11 @@ void filtersNullAndBlankGluePackages() { } // ========================================================================= - // KtestifyPlugin — default methods + // KtestifyPlugin: default methods // ========================================================================= @Nested - @DisplayName("KtestifyPlugin — default author methods") + @DisplayName("KtestifyPlugin: default author methods") class DefaultAuthorMethodsTests { @Test @@ -258,7 +324,7 @@ void getConfigReturnsNonNull() { // ========================================================================= /** - * Minimal plugin implementation that uses only default interface methods — validates that {@link KtestifyPlugin} + * Minimal plugin implementation that uses only default interface methods: validates that {@link KtestifyPlugin} * default methods work without any overrides. */ static final class MinimalPlugin implements KtestifyPlugin { @@ -288,7 +354,7 @@ public void shutdown() { } } - /** Full stub — all fields configurable for parameterised test cases. */ + /** Full stub: all fields configurable for parameterised test cases. */ static final class StubPlugin implements KtestifyPlugin { private final String id; private final String version; diff --git a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java index 0341959..3c71fd4 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/KafkaTestExtension.java @@ -49,7 +49,7 @@ public class KafkaTestExtension implements BeforeAllCallback, AfterAllCallback, ExtensionContext.Store.CloseableResource { // ----------------------------------------------------------------- - // Testcontainers image – pin to a specific Confluent Platform version + // Testcontainers image: pin to a specific Confluent Platform version // so that builds are reproducible. Bump when you need a newer Kafka. // ----------------------------------------------------------------- private static final DockerImageName KAFKA_IMAGE = DockerImageName.parse("apache/kafka:4.2.0"); diff --git a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java index 3e7ba79..7b8f78a 100644 --- a/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java +++ b/src/test/java/io/github/ktestify/tests/extentions/SchemaRegistryTestExtension.java @@ -51,7 +51,7 @@ public class SchemaRegistryTestExtension implements BeforeAllCallback, AfterAllCallback, ExtensionContext.Store.CloseableResource { // ----------------------------------------------------------------- - // Testcontainers image – pin to a specific Confluent Platform version + // Testcontainers image: pin to a specific Confluent Platform version // so that builds are reproducible. Bump when you need a newer version. // ----------------------------------------------------------------- private static final DockerImageName SCHEMA_REGISTRY_IMAGE = diff --git a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java index dca8656..71a7b12 100644 --- a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java @@ -31,7 +31,14 @@ void testGetLine() { @Test void testGetLineOutOfBounds() { String content = "First line\nSecond line"; - assertThrows(ArrayIndexOutOfBoundsException.class, () -> FieldMatcherUtils.getLine(content, 2)); + assertThrows(IllegalArgumentException.class, () -> FieldMatcherUtils.getLine(content, 2)); + assertThrows(IllegalArgumentException.class, () -> FieldMatcherUtils.getLine(content, -1)); + } + + @Test + void testGetLineStripsCarriageReturn() { + String content = "First line\r\nSecond line\r\n"; + Assertions.assertEquals("Second line", FieldMatcherUtils.getLine(content, 1)); } @Test diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java new file mode 100644 index 0000000..3d105ef --- /dev/null +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com) + * + * 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 + * + * http://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 io.github.ktestify.utils.serdes; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Regression tests for Avro logical types going through {@link AvroDeserializer} and {@link AvroUtils}. */ +@DisplayName("Avro logical types to JSON") +class AvroLogicalTypesSerializationTest { + + private static final Schema SCHEMA = new Schema.Parser().parse(""" + { + "type": "record", "name": "Logical", "fields": [ + {"name": "day", "type": {"type": "int", "logicalType": "date"}}, + {"name": "at", "type": {"type": "long", "logicalType": "timestamp-micros"}}, + {"name": "local", "type": {"type": "long", "logicalType": "local-timestamp-millis"}}, + {"name": "time", "type": {"type": "int", "logicalType": "time-millis"}}, + {"name": "payload", "type": "bytes"} + ] + } + """); + + @Test + @DisplayName("java.time values are serialized as ISO-8601 strings") + void javaTimeValuesSerializeAsIsoStrings() { + Map map = new LinkedHashMap<>(); + map.put("day", LocalDate.of(2026, 10, 2)); + map.put("at", Instant.parse("2026-10-02T10:15:30Z")); + map.put("local", LocalDateTime.of(2026, 10, 2, 10, 15, 30)); + map.put("time", LocalTime.of(10, 15, 30)); + + String json = AvroUtils.convertMapToJsonString(map); + Map roundTrip = AvroUtils.convertJsonToMap(json); + + assertEquals("2026-10-02", roundTrip.get("day")); + assertEquals("2026-10-02T10:15:30Z", roundTrip.get("at")); + assertEquals("2026-10-02T10:15:30", roundTrip.get("local")); + assertEquals("10:15:30", roundTrip.get("time")); + } + + @Test + @DisplayName("a GenericRecord with logical types converts to JSON without reflection errors") + void genericRecordWithLogicalTypesConverts() { + GenericRecord record = new GenericData.Record(SCHEMA); + record.put("day", 20_000); + record.put("at", 1_000_000L); + record.put("local", 1_000L); + record.put("time", 1_000); + record.put("payload", ByteBuffer.wrap(new byte[] {1, 2, 3})); + + String json = + assertDoesNotThrow(() -> AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(record))); + + assertTrue(json.contains("\"day\": \"2024-10-04\""), json); + } + + @Test + @DisplayName("bytes honour the ByteBuffer position and limit") + void bytesHonourBufferWindow() { + ByteBuffer buffer = ByteBuffer.wrap(new byte[] {9, 1, 2, 9}, 1, 2).slice(); + Schema bytesSchema = Schema.create(Schema.Type.BYTES); + + byte[] result = (byte[]) AvroDeserializer.objectDeserializer(buffer, bytesSchema); + + assertArrayEquals(new byte[] {1, 2}, result); + assertEquals(0, buffer.position(), "source buffer must not be consumed"); + } +} diff --git a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java index e7bcd0c..d31e2c4 100644 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/serdes/AvroUtilsTest.java @@ -765,7 +765,7 @@ void testConvertDatesToTimestamps_NestedObject() { Map jsonMap = AvroUtils.convertJsonToMap(json); Map result = AvroUtils.convertDatesToTimestamps(jsonMap); - // Recursion into nested maps now works, but no conversion is performed — the nested date-like string + // Recursion into nested maps now works, but no conversion is performed: the nested date-like string // must remain untouched. assertEquals("2025-01-02T00:00:00Z", ((Map) result.get("nested")).get("date")); } @@ -791,7 +791,7 @@ void testConvertDatesToTimestamps_NestedList() { List list = (List) result.get("list"); - // Recursion into nested list items works, but no conversion is performed — dates remain untouched. + // Recursion into nested list items works, but no conversion is performed: dates remain untouched. assertEquals("2025-01-02T00:00:00Z", ((Map) list.get(0)).get("date")); assertEquals("2025-01-03T00:00:00Z", ((Map) list.get(1)).get("date")); } @@ -1176,7 +1176,7 @@ void testConvertDatesToTimestampsInNestedStructures() { Map result = AvroUtils.convertDatesToTimestamps(inputMap); - // No conversion is performed anymore (content-sniffing removed) — dates remain untouched at all levels, + // No conversion is performed anymore (content-sniffing removed): dates remain untouched at all levels, // but the recursion still produces a correctly structured (deep-copied) map. assertEquals("2022-01-03T00:00:00Z", result.get("date"), "Top level date should remain unconverted"); assertEquals( @@ -2499,14 +2499,14 @@ class EffectiveSizeCheckWithExcludedKeysTests { @Test @DisplayName("Should return true when expected has an extra key that is excluded (core bug regression)") void shouldReturnTrue_WhenExpectedHasExtraExcludedKey() { - // expected has key3 which is excluded — old code failed here (size 3 != 2) + // expected has key3 which is excluded: old code failed here (size 3 != 2) String expected = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"}"; String actual = "{\"key1\":\"value1\",\"key2\":\"value2\"}"; List excludedKeys = List.of("key3"); assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Expected map has an extra key that is excluded — effective sizes are equal, should match"); + "Expected map has an extra key that is excluded: effective sizes are equal, should match"); } @Test @@ -2519,7 +2519,7 @@ void shouldReturnTrue_WhenActualHasExtraExcludedKey() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Actual map has an extra key that is excluded — effective sizes are equal, should match"); + "Actual map has an extra key that is excluded: effective sizes are equal, should match"); } @Test @@ -2533,20 +2533,20 @@ void shouldReturnTrue_WhenEachMapHasDistinctExtraExcludedKey() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Each map carries a different extra excluded key — effective sizes are equal, should match"); + "Each map carries a different extra excluded key: effective sizes are equal, should match"); } @Test @DisplayName("Should return false when effective sizes still differ after accounting for excluded keys") void shouldReturnFalse_WhenEffectiveSizesStillDifferAfterExclusion() { - // expected has key2 + key3 (key3 excluded), actual has only key1 — effective 2 vs 1 + // expected has key2 + key3 (key3 excluded), actual has only key1: effective 2 vs 1 String expected = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"}"; String actual = "{\"key1\":\"value1\"}"; List excludedKeys = List.of("key3"); assertFalse( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Even after excluding key3, expected still has more non-excluded keys than actual — should not match"); + "Even after excluding key3, expected still has more non-excluded keys than actual: should not match"); } @Test @@ -2559,7 +2559,7 @@ void shouldNotAffectEffectiveSize_WhenExcludedKeyAbsentFromBothMaps() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "An excluded key absent from both maps must not distort effective sizes — equal maps should still match"); + "An excluded key absent from both maps must not distort effective sizes: equal maps should still match"); } @Test @@ -2572,7 +2572,7 @@ void shouldReturnTrue_WhenMultipleExtraExcludedKeysOnlyInExpected() { assertTrue( AvroUtils.doesAvroRecordsSmartMatchesWithExclusions(expected, actual, excludedKeys), - "Multiple extra excluded keys only in expected — effective sizes both 2, should match"); + "Multiple extra excluded keys only in expected: effective sizes both 2, should match"); } } @@ -2584,7 +2584,7 @@ class TypeDrivenDateComparisonTests { @DisplayName("Should compare literally when both sides are plain strings, even if date-like") void shouldCompareLiterally_WhenBothSidesAreDateLikeStrings() { // A genuine Avro `string` field whose content happens to look like a date/timestamp must never be - // converted or reinterpreted — it must be compared as a literal string on both sides. + // converted or reinterpreted: it must be compared as a literal string on both sides. Map expected = new HashMap<>(); expected.put("MyDate", "2026-02-10T16:19:14.123Z"); diff --git a/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin b/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin new file mode 100644 index 0000000..4c24c55 --- /dev/null +++ b/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin @@ -0,0 +1 @@ +io.github.ktestify.plugin.ClasspathTestPlugin