diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5e78ff..fd5b225 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,28 @@ +# ============================================================ +# 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 ] + branches: [ main ] # feature branches are covered by pull_request — avoids duplicate runs paths-ignore: - "**.md" - "LICENSE" @@ -17,28 +37,284 @@ 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: - 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: "" + + # ── 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." diff --git a/pom.xml b/pom.xml index b90e14b..f36d2f0 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.github.ktestify ktestify-parent - 1.0.5 + 1.0.2 @@ -40,7 +40,7 @@ scm:git:git://github.com/ktestify/ktestify-core.git - scm:git:ssh://git@github.com/ktestify/ktestify-core.git + scm:git:ssh://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,12 +196,34 @@ - - + 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 e41e641..154f7e3 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 ca1719b..457d0f1 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 cf6fc97..5179be1 100644 --- a/src/main/java/io/github/ktestify/exceptions/ComparisonException.java +++ b/src/main/java/io/github/ktestify/exceptions/ComparisonException.java @@ -45,16 +45,4 @@ 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 e314da1..c6b79e1 100644 --- a/src/main/java/io/github/ktestify/exceptions/ConsumerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ConsumerException.java @@ -15,36 +15,9 @@ */ 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 546365f..6692602 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 deleted file mode 100644 index b966215..0000000 --- a/src/main/java/io/github/ktestify/exceptions/FetchTimeoutException.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 59c5c2e..761ec96 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 ec7cedc..5db3a01 100644 --- a/src/main/java/io/github/ktestify/exceptions/ProducerException.java +++ b/src/main/java/io/github/ktestify/exceptions/ProducerException.java @@ -15,32 +15,8 @@ */ 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 14f459f..188352a 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 241d600..9eac21d 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(), e); + throw new ConsumerException(e.getMessage()); } } } 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 22d80d6..5200689 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 bce4e88..4dc4c31 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 da00d9e..8735dfd 100644 --- a/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java +++ b/src/main/java/io/github/ktestify/io/inputs/DynamicVariableFactory.java @@ -19,91 +19,43 @@ 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.Locale; +import java.util.HashMap; 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 ConcurrentHashMap<>(); + private static final Map variables = new HashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(DynamicVariableFactory.class); static { - resetToDefaults(); + registerVariable(new DateVariable()); + registerVariable(new TimestampVariable()); + registerVariable(new RandomVariable()); + registerVariable(new EnvironmentVariable()); } - /** - * 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(normalize(variable.getName()), variable); + variables.put(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 name == null ? null : variables.get(normalize(name)); + return variables.get(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 name != null && variables.containsKey(normalize(name)); + return variables.containsKey(name); } - /** - * Returns an immutable snapshot of the registered (lower-case) variable names. - * - * @return the registered names - */ public static Set getRegisteredVariableNames() { - return Set.copyOf(variables.keySet()); + return 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 09a2619..8c462fb 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); - StringBuilder sb = new StringBuilder(); + StringBuffer sb = new StringBuffer(); // 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 587730b..2fbb782 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,7 +15,6 @@ */ 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 { @@ -27,14 +26,8 @@ public String getName() { @Override public String process(String format) { if (format == null || format.isEmpty()) { - throw ConfigException.missingValue("env"); + throw new IllegalArgumentException("Environment variable name cannot be null or empty"); } - - String value = System.getenv(format); - if (value == null) { - throw new ConfigException("Environment variable '" + format + "' is not set"); - } - - return value; + return System.getenv(format); } } 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 6c4776f..df37f75 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.secure().nextAlphanumeric(length); + return RandomStringUtils.randomAlphanumeric(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 166bbed..b29a522 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,9 +27,6 @@ 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 1966b1c..9acb169 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(), e); + throw new ConsumerException(e.getMessage()); } } 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 c6f9a68..3b2d501 100644 --- a/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java +++ b/src/main/java/io/github/ktestify/io/kafka/AbstractKafkaProducer.java @@ -17,7 +17,6 @@ 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; @@ -115,7 +114,7 @@ protected String resolvePayload() { if (payloadFile != null) { return FileUtils.getFileContent(payloadFile); } - throw new ProducerException("No payload content was provided"); + throw new IllegalStateException("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 1955072..045209e 100644 --- a/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java +++ b/src/main/java/io/github/ktestify/io/kafka/KafkaRecordFetcher.java @@ -20,14 +20,16 @@ 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.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; 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; @@ -50,7 +52,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 @@ -103,30 +105,23 @@ public KafkaRecordFetcher(ConsumerContext context) { /** * {@inheritDoc} * - *

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. + *

Blocks until at least one record that passes the key-filter is found, or {@code readTimeoutMs} elapses. * - * @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 + * @throws FetchException if the timeout expires without a matching record, 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(deadlineNanos); + subscribeAndAwaitAssignment(); long delta = calculateDeltaTime(); - seekToOffset(delta, deadlineNanos); - return pollUntilRecordFound(deadlineNanos); + seekToOffset(delta); + return pollUntilRecordFound(); } 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); } } @@ -153,9 +148,8 @@ public void close() { /** * Clears the shared deduplication registry. * - *

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. + *

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. */ public static void clearMatchedRecords() { MATCHED_RECORDS.clear(); @@ -163,27 +157,17 @@ public static void clearMatchedRecords() { } // ========================================================================= - // Private: Kafka mechanics + // Private — Kafka mechanics (previously inside AbstractKafkaConsumer) // ========================================================================= - /** - * 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 { + /** Subscribes to the configured topic and blocks until Kafka assigns partitions. */ + private void subscribeAndAwaitAssignment() { 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); @@ -193,7 +177,7 @@ private void subscribeAndAwaitAssignment(long deadlineNanos) throws FetchExcepti * 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. * @@ -216,7 +200,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()) { @@ -238,19 +222,14 @@ 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, long deadlineNanos) { + private void seekToOffset(long deltaTimestamp) { Set assignments = kafkaConsumer.assignment(); log.debug(MESSAGE_CONSUMER_SEEKING_TO_OFFSET, deltaTimestamp); Map timestampMap = assignments.stream().collect(Collectors.toMap(tp -> tp, tp -> deltaTimestamp)); - long remainingMs = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime())); - Map offsets = - kafkaConsumer.offsetsForTimes(timestampMap, Duration.ofMillis(remainingMs)); + Map offsets = kafkaConsumer.offsetsForTimes(timestampMap); log.trace(MESSAGE_CONSUMER_RETRIEVED_OFFSETS, timestampMap); offsets.forEach((topicPartition, offsetAndTimestamp) -> { @@ -265,31 +244,24 @@ private void seekToOffset(long deltaTimestamp, long deadlineNanos) { } /** - * Polls Kafka until a candidate record is found or the deadline passes. + * Polls Kafka until a candidate record is found or {@link #readTimeoutMs} expires. * *

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

In batch mode ({@link ConsumerContext#isBatchConsumer()} == {@code true}) the method keeps polling - * 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. + * until {@link ConsumerContext#getBatchSize()} distinct candidates have been collected, or the timeout expires. * - * @param deadlineNanos absolute {@link System#nanoTime()} deadline * @return non-empty, unmodifiable list of {@link ConsumedRecord} ready for matching - * @throws FetchException if the batch size is invalid, or the deadline passes before the required number of records - * is found + * @throws FetchException if the timeout expires before the required number of records is found */ - private List> pollUntilRecordFound(long deadlineNanos) throws FetchException { + private List> pollUntilRecordFound() throws FetchException { + long startTime = System.currentTimeMillis(); 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( @@ -298,34 +270,36 @@ private List> pollUntilRecordFound(long deadlineNanos) throws batchMode ? "BATCH" : "SINGLE", targetSize); - while (System.nanoTime() < deadlineNanos) { + while (System.currentTimeMillis() - startTime < readTimeoutMs) { ConsumerRecords batch = kafkaConsumer.poll(Duration.ofMillis(pollIntervalMs)); if (!batch.isEmpty()) { log.debug(MESSAGE_CONSUMER_RECORDS_IN_TOPIC_NOT_EMPTY, namespacedTopic); for (ConsumerRecord record : batch) { - if (!passesKeyFilter(record)) { - continue; - } ConsumedRecord consumed = ConsumedRecord.fromKafkaRecord(record); - if (!tryClaim(consumed)) { + + if (isAlreadyMatched(consumed)) { log.debug(MESSAGE_CONSUMER_RECORD_ALREADY_MATCHED, consumed.toMatchedRecord()); continue; } + if (!passesKeyFilter(record)) { + 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); @@ -336,42 +310,29 @@ private List> pollUntilRecordFound(long deadlineNanos) throws } if (!accumulated.isEmpty()) { - // 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 + // Partial batch collected — treat as a timeout with context + throw new FetchException("Timed out after " + readTimeoutMs + "ms waiting for " + targetSize + " record(s) on topic '" + namespacedTopic - + "': only " + accumulated.size() + " collected."); + + "' — only " + accumulated.size() + " collected."); } - throw new FetchTimeoutException( + throw new FetchException( "Timed out after " + readTimeoutMs + "ms waiting for a record on topic '" + namespacedTopic + "'."); } // ========================================================================= - // Private: filters and deduplication + // Private — filters and deduplication // ========================================================================= - /** - * 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; + /** 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()); } - /** Removes a previously claimed record from the shared deduplication registry. */ - private void release(ConsumedRecord record) { - MATCHED_RECORDS.remove(record.toMatchedRecord()); + /** 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()); } /** @@ -386,7 +347,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; @@ -400,7 +361,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 336f966..61bac70 100644 --- a/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java +++ b/src/main/java/io/github/ktestify/io/kafka/SchemaRegistryClientFactory.java @@ -57,42 +57,17 @@ 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 } /** - * Returns a cached Schema Registry client using default configuration. + * Creates a Schema Registry client using default configuration. * - *

The first call creates and caches the client; subsequent calls return the same instance. - * - * @return a cached Schema Registry client + * @return a new Schema Registry client */ public static SchemaRegistryClient createClient() { - 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; - } + return createClient(KtestifyConfig.getOrLoad()); } /** 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 17b8044..4975276 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 faf2246..059882b 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,7 +17,6 @@
 
 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;
@@ -84,12 +83,12 @@ protected void produce() {
                     metadata.timestamp());
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new ProducerException("Interrupted while producing Avro message", e);
+            throw new RuntimeException("Interrupted while producing Avro message", e);
 
         } catch (ExecutionException e) {
-            throw new ProducerException("Failed to produce Avro message", e.getCause());
+            throw new RuntimeException("Failed to produce Avro message", e.getCause());
         } catch (IOException | RestClientException e) {
-            throw new ProducerException("Failed to read schema or payload for Avro message", e);
+            throw new RuntimeException("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 d22e4ad..1c166cf 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 2ee419f..41213eb 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,7 +15,6 @@
  */
 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;
@@ -69,9 +68,9 @@ protected void produce() {
                     metadata.timestamp());
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new ProducerException("Interrupted while producing raw message", e);
+            throw new RuntimeException("Interrupted while producing raw message", e);
         } catch (ExecutionException e) {
-            throw new ProducerException("Failed to produce raw message", e.getCause());
+            throw new RuntimeException("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 b662fad..dfaddfd 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 8b21af9..340f310 100644
--- a/src/main/java/io/github/ktestify/match/MatchResult.java
+++ b/src/main/java/io/github/ktestify/match/MatchResult.java
@@ -96,34 +96,4 @@ 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 3c924ff..3415204 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 58a7dea..3826b08 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.ConfigException; +import io.github.ktestify.exceptions.ConsumerException; 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 ConfigException if the method name is non-blank but unrecognised + * @throws ConsumerException 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 ConfigException("Unknown raw matchMethod '" + matchMethod + "'. " + throw new ConsumerException("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 ConfigException if the method name is non-blank but unrecognised + * @throws ConsumerException 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 ConfigException("Unknown Avro matchMethod '" + matchMethod + "'. " + throw new ConsumerException("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 b43b6d2..5877944 100644 --- a/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AttributeRecordMatcher.java @@ -29,8 +29,7 @@ * *

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()}, so a - * record with a {@code null} value is matched on its attributes like any other. + * {@code List>} for any {@code V} since it never inspects {@link ConsumedRecord#getValue()}. * *

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 @@ -44,17 +43,19 @@ 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()) { - return MatchResult.noRecords(); + throw new ComparisonException("AttributeRecordMatcher requires at least one record to compare."); } - Map actual = records.getFirst().getAttributes(); + Map actual = records.get(0).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 5639706..d19e3d6 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFieldsRecordMatcher.java @@ -21,6 +21,7 @@ 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; @@ -34,9 +35,7 @@ * *

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. Alternatively, {@link MatchContext#getMatchKeyValues()} checks several fields at once. - * - *

A record with a {@code null} value (tombstone) fails with {@link MatchResult#nullValue(String)}. + * set. * * @since 0.3.0 */ @@ -46,9 +45,6 @@ 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()) { @@ -58,41 +54,31 @@ 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()) { - 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); + 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); } return MatchResult.fail( - "Avro field '" + key + "' does not match expected value '" + expected + "'.", - expected, + "Avro field '" + key + "' does not match expected value '" + context.getMatchValue() + "'.", + context.getMatchValue(), 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())); - 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)) { + log.debug("Avro field match against file — key: '{}', file: '{}'", key, context.getMatchFilePath()); + boolean result = AvroUtils.doesAvroValueFromKeyMatchesRecords(key, expectedRecord, actualValue); + if (result) { return MatchResult.pass(expectedRecord, actualValue); } return MatchResult.fail( @@ -107,25 +93,21 @@ 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. Every mismatch is reported in the diff. + *

All pairs must match for the result to pass. The first 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 a field lookup cannot be performed + * @throws ComparisonException if the key/value map is empty (should not happen, guarded by the caller) * @since 1.1.1 */ private MatchResult matchMultipleFields(List> records, MatchContext context) throws ComparisonException { - 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); + String actualValue = toJson(value); + Map keyValues = context.getMatchKeyValues(); - 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()) { @@ -141,4 +123,12 @@ 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 a4044af..643cd59 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileKeyRecordMatcher.java @@ -21,6 +21,7 @@ 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; @@ -28,18 +29,15 @@ /** * Asserts both the record key and Avro value against a configured expected key and expected JSON - * file. Supports optional field exclusion via {@link MatchContext#getExcludedFields()}, like - * {@link AvroFileRecordMatcher}. + * file. * *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()}: the expected record key - *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value JSON file + *
  • {@link MatchContext#getMatchKey()} — the expected record key + *
  • {@link MatchContext#getMatchFilePath()} — 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 @@ -48,9 +46,7 @@ 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."); } @@ -58,22 +54,20 @@ public MatchResult match(List> records, MatchConte throw new ComparisonException("AvroFileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.getFirst(); + ConsumedRecord record = records.get(0); String actualKey = record.getKey(); String expectedKey = context.getMatchKey(); String expectedValue = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - GenericRecord value = record.getValue(); - String actualValue = value == null ? null : AvroJson.of(value); + String actualValue = toJson(record.getValue()); boolean keyMatches = expectedKey.equals(actualKey); - boolean valueMatches = actualValue != null && valueMatches(expectedValue, actualValue, context); + boolean valueMatches = + AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); if (!keyMatches) { - log.error("Avro key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Avro key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (actualValue == null) { - log.error("Avro value mismatch: record value is null (tombstone)."); - } else if (!valueMatches) { + if (!valueMatches) { log.error( "Avro value does not match file '{}'.\nExpected:\n{}\nActual:\n{}", context.getMatchFilePath(), @@ -85,17 +79,17 @@ public MatchResult match(List> records, MatchConte log.info("Avro record key and value both match."); return MatchResult.pass(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); + return MatchResult.fail( + "Key match: " + keyMatches + ", value match: " + valueMatches, + expectedKey + " / " + expectedValue, + actualKey + " / " + actualValue); } - 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); + private String toJson(GenericRecord value) { + if (value.getSchema() != null) { + return AvroUtils.getPrettyAvroValue( + AvroUtils.convertMapToJsonString(AvroDeserializer.recordDeserializer(value))); } - return AvroUtils.doesAvroRecordsSmartMatches(AvroUtils.getPrettyAvroValue(expectedValue), actualValue); + return AvroUtils.getPrettyAvroValue(value.toString()); } } 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 7d45259..654c451 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroFileRecordMatcher.java @@ -21,6 +21,7 @@ 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; @@ -30,8 +31,7 @@ * 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. A record with a {@code null} value (tombstone) fails - * with {@link MatchResult#nullValue(String)}. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. * * @since 0.3.0 */ @@ -41,22 +41,20 @@ 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, expectedValue); + log.debug( + "Avro file match — actual:\n{}\nExpected:\n{}", + actualValue, + AvroUtils.getPrettyAvroValue(AvroUtils.convertMapToJsonString( + AvroUtils.convertDatesToTimestamps(AvroUtils.convertJsonToMap(expectedValue))))); boolean result; if (context.getExcludedFields() != null && !context.getExcludedFields().isEmpty()) { @@ -79,4 +77,12 @@ 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 deleted file mode 100644 index 70f784c..0000000 --- a/src/main/java/io/github/ktestify/match/impl/AvroJson.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 ccae0ff..152bd0c 100644 --- a/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/AvroKeyRecordMatcher.java @@ -27,9 +27,6 @@ /** * 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 @@ -38,24 +35,22 @@ 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.getFirst().getKey(); + String actualKey = records.get(0).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 38d5bf2..c198de9 100644 --- a/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FieldsRecordMatcher.java @@ -31,10 +31,7 @@ * ({@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 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}. + * {@code "0:10:20"} means line 0, characters 10–20). * * @since 0.3.0 */ @@ -43,58 +40,42 @@ 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 expectedField = resolveExpectedField(context, line, from, to, position); - - 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 actualValue = records.get(0).getValue(); + String actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); - String actualField; - try { - actualField = FieldMatcherUtils.getFieldsToMatch(actualValue, line, from, to); - } catch (IllegalArgumentException e) { - log.error("Record has no field at {}: {}", position, e.getMessage()); + // 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); + } return MatchResult.fail( - "Record has no field at " + position + ": " + e.getMessage(), expectedField, actualValue); + "Field at line=" + line + " from=" + from + " to=" + to + " does not match.", + expectedField, + actualField); } - 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(); - } + // Option B — compare the same field position extracted from an expected file if (context.getMatchFilePath() != null && !context.getMatchFilePath().isBlank()) { String expectedContent = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - 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()); + 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); } + 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 158a491..c7c3033 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileKeyRecordMatcher.java @@ -31,12 +31,10 @@ *

Requires: * *

    - *
  • {@link MatchContext#getMatchKey()}: the expected record key - *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected value file + *
  • {@link MatchContext#getMatchKey()} — expected record key + *
  • {@link MatchContext#getMatchFilePath()} — 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 @@ -44,9 +42,7 @@ 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."); } @@ -54,21 +50,19 @@ public MatchResult match(List> records, MatchContext cont throw new ComparisonException("FileKeyRecordMatcher requires matchFilePath to be set."); } - ConsumedRecord record = records.getFirst(); + ConsumedRecord record = records.get(0); 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 = expectedValue.equals(actualValue); + boolean valueMatches = actualValue.equals(expectedValue); if (!keyMatches) { - log.error("Key mismatch, expected: '{}', actual: '{}'", expectedKey, actualKey); + log.error("Key mismatch — expected: '{}', actual: '{}'", expectedKey, actualKey); } - if (actualValue == null) { - log.error("Value mismatch: record value is null (tombstone)."); - } else if (!valueMatches) { + if (!valueMatches) { log.error( "Value mismatch.\nExpected diff:\n{}\nActual diff:\n{}", StringDiffUtils.getPrettyStringDiff(expectedValue, actualValue, StringDiffUtils.Type.EXPECTED), @@ -79,8 +73,9 @@ public MatchResult match(List> records, MatchContext cont log.info("Record key and value both match."); return MatchResult.pass(expectedValue, actualValue); } - String diff = "Key match: " + keyMatches + ", value match: " + valueMatches - + (actualValue == null ? " (" + MatchResult.NULL_VALUE_MESSAGE + ")" : ""); - return MatchResult.fail(diff, expectedKey + " / " + expectedValue, actualKey + " / " + actualValue); + return MatchResult.fail( + "Key match: " + keyMatches + ", value match: " + valueMatches, + 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 0df73c1..14f6af0 100644 --- a/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/FileRecordMatcher.java @@ -28,8 +28,7 @@ /** * Compares the String value of the first consumed record against the content of an expected file. * - *

Requires {@link MatchContext#getMatchFilePath()} to be set. A record with a {@code null} value (tombstone) fails - * with {@link MatchResult#nullValue(String)}. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. * * @since 0.3.0 */ @@ -38,20 +37,14 @@ 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.getFirst().getValue(); + String actual = records.get(0).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 de835af..4a8d6e7 100644 --- a/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/KeyRecordMatcher.java @@ -26,9 +26,6 @@ /** * 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 @@ -36,24 +33,22 @@ 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.getFirst().getKey(); + String actualKey = records.get(0).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 9b71765..72910ee 100644 --- a/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/NoOpRecordMatcher.java @@ -27,8 +27,7 @@ * *

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. 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. + * configured. * * @param the type of the record value * @since 0.3.0 @@ -39,8 +38,7 @@ 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 == null ? 0 : records.size()); + "NoOpRecordMatcher: skipping assertion on {} record(s) — no match method configured.", 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 e862e77..0df5737 100644 --- a/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XPathRecordMatcher.java @@ -32,12 +32,10 @@ *

Requires: * *

    - *
  • {@link MatchContext#getMatchFilePath()}: the path to the expected XML file - *
  • {@link MatchContext#getExcludedFields()}: repurposed here as the list of XPath expressions to evaluate + *
  • {@link MatchContext#getMatchFilePath()} — 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 @@ -45,9 +43,7 @@ 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."); @@ -57,13 +53,9 @@ public MatchResult match(List> records, MatchContext cont } String expected = FileUtils.getFileContent(FileUtils.getFile(context.getMatchFilePath())); - String actual = records.getFirst().getValue(); - if (actual == null) { - log.error("Record value is null (tombstone), expected XML from '{}'.", context.getMatchFilePath()); - return MatchResult.nullValue(expected); - } + String actual = records.get(0).getValue(); 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 09a5bd9..8b5598b 100644 --- a/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java +++ b/src/main/java/io/github/ktestify/match/impl/XmlRecordMatcher.java @@ -37,13 +37,11 @@ * {@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, and 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; all others marked {@code EXCLUDED} in the file are * suppressed as well. * - *

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. + *

Requires {@link MatchContext#getMatchFilePath()} to be set. * * @since 0.3.0 */ @@ -55,20 +53,14 @@ 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.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); + String actual = records.get(0).getValue(); + 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); @@ -95,7 +87,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 = XMLUtils.newHardenedSAXParserFactory(); + SAXParserFactory factory = SAXParserFactory.newInstance(); SAXParser parser = factory.newSAXParser(); parser.parse(new InputSource(new StringReader(expectedXml)), new DefaultHandler() { private String currentElement; @@ -116,7 +108,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 94c1c48..fd6dcd0 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 42dad64..b7f85a0 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 d1fcc0a..43e6227 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 44aa50d..fedd2ca 100644 --- a/src/main/java/io/github/ktestify/plugin/PluginRegistry.java +++ b/src/main/java/io/github/ktestify/plugin/PluginRegistry.java @@ -20,7 +20,6 @@ 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; @@ -31,25 +30,20 @@ *

Two loading phases

* *
    - *
  1. 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. - *
  2. 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. + *
  3. 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. + *
  4. 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)}. *
* - *

Initialization order and failure handling

+ *

Initialization order

* - *

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. + * 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. * *

Usage

* @@ -58,7 +52,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(); @@ -77,20 +71,8 @@ public final class PluginRegistry { private final 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) { + private PluginRegistry(List plugins) { this.plugins = Collections.unmodifiableList(plugins); - this.externalClassLoader = externalClassLoader; } // ------------------------------------------------------------------------- @@ -102,37 +84,19 @@ private PluginRegistry(List plugins, URLClassLoader externalClas * * @param ctx the plugin context (config + services) handed to each plugin at init time * @return a fully initialized {@code PluginRegistry} - * @throws PluginException if a plugin fails to initialize, a provider cannot be instantiated, or two plugins share - * the same id + * @throws PluginException if any plugin fails to initialize */ public static PluginRegistry load(PluginContext ctx) { List all = new ArrayList<>(); - Map byId = new HashMap<>(); - URLClassLoader externalCL = null; LOG.info("Loading plugins..."); - try { - // Phase 1: classpath / fat-jar plugins - ClassLoader contextCL = Thread.currentThread().getContextClassLoader(); - discover("classpath", ServiceLoader.load(KtestifyPlugin.class, contextCL), null, ctx, all, byId); + // Phase 1 — classpath / fat-jar plugins + loadFromClasspath(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); - } + // Phase 2 — external plugin directory + String pluginsDir = resolvePluginsDir(ctx); + loadFromDirectory(pluginsDir, ctx, all); if (all.isEmpty()) { LOG.info("No plugins loaded."); @@ -143,7 +107,7 @@ public static PluginRegistry load(PluginContext ctx) { all.stream().map(p -> p.getId() + "@" + p.getVersion()).collect(Collectors.joining(", "))); } - return new PluginRegistry(all, externalCL); + return new PluginRegistry(all); } // ------------------------------------------------------------------------- @@ -162,36 +126,35 @@ public List getPlugins() { /** * Returns the Cucumber glue packages contributed by all loaded plugins. * - *

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. + *

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. * - * @return an ordered, duplicate-free list of glue package names; may be empty, never {@code null} + * @return an ordered 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, then closes the external plugin {@link URLClassLoader} if - * one was created. + * Shuts down all plugins in reverse initialization order. * - *

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. + *

Exceptions thrown by individual plugins are caught, logged as warnings, and swallowed so the remaining plugins + * can still be shut down cleanly. */ public void shutdown() { - 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); + 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()); + } } } @@ -199,43 +162,14 @@ public void shutdown() { // Private helpers // ------------------------------------------------------------------------- - /** - * 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) { + /** 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); int before = target.size(); - 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() + "."); - } - + for (KtestifyPlugin plugin : loader) { LOG.info( - "[{}] Plugin discovered: {} v{} (author: {} <{}>)", - phase, + "[classpath] Plugin discovered: {} v{} (author: {} <{}>)", plugin.getId(), plugin.getVersion(), plugin.getAuthorName(), @@ -243,37 +177,35 @@ private static void discover( initPlugin(plugin, ctx); target.add(plugin); } - LOG.debug("Phase '{}': {} plugin(s) loaded.", phase, target.size() - before); + int loaded = target.size() - before; + LOG.debug("Phase 1 (classpath): {} plugin(s) discovered.", loaded); } /** - * 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 + * Phase 2: scans an external directory for {@code .jar} files, loads them via a {@link URLClassLoader}, and + * discovers plugins inside each JAR. */ - private static URLClassLoader createExternalClassLoader(String dirPath, ClassLoader parent) { + private static void loadFromDirectory(String dirPath, PluginContext ctx, List target) { if (dirPath == null || dirPath.isBlank()) { LOG.debug("Phase 2 (external): plugins dir not configured."); - return null; + return; } File dir = new File(dirPath); - if (!dir.isDirectory()) { + if (!dir.exists() || !dir.isDirectory()) { LOG.debug("Phase 2 (external): directory '{}' does not exist.", dirPath); - return null; + return; } 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 null; + LOG.debug("Phase 2 (external): no *.jar files found in '{}'", dirPath); + return; } - 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 { @@ -284,38 +216,30 @@ private static URLClassLoader createExternalClassLoader(String dirPath, ClassLoa }) .toArray(URL[]::new); - return new URLClassLoader("ktestify-external-plugins", urls, parent); - } - - /** 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); - } - } - } + URLClassLoader pluginCL = + new URLClassLoader(urls, Thread.currentThread().getContextClassLoader()); - /** 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); + ServiceLoader 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); } + 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 { - withContextClassLoader(plugin, () -> plugin.initialize(ctx)); + plugin.initialize(ctx); LOG.info("Plugin '{}' initialized.", plugin.getId()); } catch (PluginException e) { throw e; // already wrapped @@ -324,25 +248,12 @@ 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 d5c1b3f..720b06f 100644 --- a/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java +++ b/src/main/java/io/github/ktestify/utils/FieldMatcherUtils.java @@ -28,23 +28,12 @@ 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) { - 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]; + return content.split("\n")[line]; } /** diff --git a/src/main/java/io/github/ktestify/utils/FileUtils.java b/src/main/java/io/github/ktestify/utils/FileUtils.java index 2175809..d07e5a5 100644 --- a/src/main/java/io/github/ktestify/utils/FileUtils.java +++ b/src/main/java/io/github/ktestify/utils/FileUtils.java @@ -15,7 +15,6 @@ */ 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; @@ -44,7 +43,7 @@ public static File getFile(String fullPath) { * * @param file the file to read * @return the content of the file as a String - * @throws ComparisonException if the file cannot be read + * @throws RuntimeException if something went wrong */ public static String getFileContent(File file) { try (InputStream inputStream = new FileInputStream(file)) { @@ -56,7 +55,7 @@ public static String getFileContent(File file) { return content; } catch (IOException e) { - throw new ComparisonException("Failed to read file: " + file.getAbsolutePath(), e); + throw new RuntimeException(e); } } @@ -65,14 +64,15 @@ public static String getFileContent(File file) { * * @param file The file for which to open the InputStream. * @return An InputStream for the specified file. - * @throws ComparisonException If an IOException occurs when opening the InputStream. + * @throws RuntimeException 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 ComparisonException("Failed to open file input stream: " + file.getAbsolutePath(), e); + + throw new RuntimeException(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 ComparisonException If an IOException occurs during stream reading. + * @throws RuntimeException 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 ComparisonException("Failed to read from input stream", e); + throw new RuntimeException(e); } } diff --git a/src/main/java/io/github/ktestify/utils/XMLUtils.java b/src/main/java/io/github/ktestify/utils/XMLUtils.java index 8e9f395..8ed212a 100644 --- a/src/main/java/io/github/ktestify/utils/XMLUtils.java +++ b/src/main/java/io/github/ktestify/utils/XMLUtils.java @@ -19,8 +19,6 @@ 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; @@ -28,11 +26,16 @@ 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.*; +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; /** * Utility class for comparing XML documents, with optional element exclusion and XPath-based comparison. @@ -45,84 +48,6 @@ 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. * @@ -132,7 +57,7 @@ private static DocumentBuilderFactory newHardenedDocumentBuilderFactory() { public static Map getNamespacesFromString(String xmlContent) { Map namespaces = new HashMap<>(); try { - SAXParserFactory factory = newHardenedSAXParserFactory(); + SAXParserFactory factory = SAXParserFactory.newInstance(); SAXParser saxParser = factory.newSAXParser(); saxParser.parse(new InputSource(new StringReader(xmlContent)), new DefaultHandler() { @Override @@ -163,7 +88,6 @@ 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(); @@ -194,7 +118,6 @@ 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)) @@ -227,7 +150,6 @@ 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() @@ -307,7 +229,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; } @@ -315,32 +237,28 @@ 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. - // - // 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. + // to the excluded child. We mark it SIMILAR when the count delta equals the + // number of excluded elements that appear in either document. 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); - if (delta > 0 && delta <= excludedElements.size()) { + long excludedPresent = excludedElements.stream() + .filter(el -> xPathBelongsToParent(controlXPath, el) || xPathBelongsToParent(testXPath, el)) + .count(); + if (delta <= excludedPresent) { LOGGER.debug( - "Suppressing CHILD_NODELIST_LENGTH difference, " - + "delta {} within {} excluded element(s); " - + "non-excluded children will be caught by CHILD_LOOKUP.", + "Suppressing CHILD_NODELIST_LENGTH difference — " + + "delta {} covered by {} excluded element(s).", delta, - excludedElements.size()); + excludedPresent); 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; @@ -360,5 +278,16 @@ 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 2a0780f..1023495 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroDeserializer.java @@ -21,14 +21,25 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.*; +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.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.*; +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.generic.GenericData; import org.apache.avro.generic.GenericFixed; import org.apache.avro.generic.GenericRecord; @@ -77,7 +88,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() @@ -113,7 +124,7 @@ public static Object objectDeserializer(Object value, Schema schema) { } // ========================================================================= - // Private: primitive / compound + // Private — primitive / compound // ========================================================================= @SuppressWarnings("unchecked") @@ -126,25 +137,12 @@ 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 -> toByteArray((ByteBuffer) value); + case BYTES -> ((ByteBuffer) value).array(); 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, @@ -174,7 +172,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 cbad55b..d89de15 100644 --- a/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java +++ b/src/main/java/io/github/ktestify/utils/serdes/AvroUtils.java @@ -125,14 +125,7 @@ public static String convertMapToJsonString(Map map) { return "{}"; } - // 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(); + var gson = new GsonBuilder().setPrettyPrinting().create(); return gson.toJson(map); } @@ -293,7 +286,8 @@ 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. @@ -641,7 +635,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. * @@ -736,7 +730,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 @@ -1159,12 +1153,6 @@ 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 19835b3..5ef30e4 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -135,10 +135,6 @@ 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} @@ -177,7 +173,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 13d3bdb..fe196b4 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 8020eb9..5b70bb1 100644 --- a/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java +++ b/src/test/java/io/github/ktestify/io/inputs/DynamicVariableFactoryTest.java @@ -48,31 +48,8 @@ public void setUp() { @AfterEach public void tearDown() { - // 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()); + // Clean up after each test to avoid interference + DynamicVariableFactory.clearRegisteredVariables(); } @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 c2d02ff..d66499f 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,6 +16,7 @@ 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; @@ -145,10 +146,11 @@ public void testProcessWithInvalidFormat() { public void testProcessWithNullFormat() { try (MockedStatic mockedDateTime = Mockito.mockStatic(LocalDateTime.class)) { mockedDateTime.when(LocalDateTime::now).thenReturn(FIXED_DATETIME); - assertEquals( - FIXED_DATETIME.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), - timestampVariable.process(), - "Default process method should use ISO_LOCAL_DATE_TIME format"); + + assertThrows( + NullPointerException.class, + () -> timestampVariable.process(null), + "Should throw NullPointerException for null format"); } } diff --git a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java similarity index 93% rename from src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java rename to src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java index bd865c3..bba940e 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerITTests.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaConsumerTest.java @@ -43,7 +43,13 @@ 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.*; +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.extension.ExtendWith; /** @@ -52,24 +58,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 AvroKafkaConsumerITTests { +class AvroKafkaConsumerTest { 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") @@ -156,7 +162,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 = AvroKafkaConsumerITTests.class.getClassLoader().getResource("match/" + filename); + URL url = AvroKafkaConsumerTest.class.getClassLoader().getResource("match/" + filename); assertNotNull(url, "Test resource not found: match/" + filename); return url.getPath(); } @@ -198,7 +204,7 @@ private org.apache.kafka.clients.consumer.Consumer create // ========================================================================= @Nested - @DisplayName("Consume-only: no matcher") + @DisplayName("Consume-only — no matcher") class ConsumeOnly { @Test @@ -225,11 +231,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 @@ -274,7 +280,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter: expectedRecordKey") + @DisplayName("Key filter — expectedRecordKey") class KeyFilter { @Test @@ -301,7 +307,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. */ @@ -370,7 +376,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(); @@ -385,7 +391,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/AvroKafkaProducerITTests.java b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java similarity index 98% rename from src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerITTests.java rename to src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java index e1e2b09..f40d031 100644 --- a/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerITTests.java +++ b/src/test/java/io/github/ktestify/io/kafka/AvroKafkaProducerTest.java @@ -22,7 +22,6 @@ 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; @@ -33,14 +32,28 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.*; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; -import org.apache.kafka.clients.consumer.*; +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.producer.Producer; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; -import org.junit.jupiter.api.*; +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.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -66,7 +79,7 @@ */ @ExtendWith({KafkaTestExtension.class, SchemaRegistryTestExtension.class}) @DisplayName("AvroKafkaProducer Integration Tests") -class AvroKafkaProducerITTests { +class AvroKafkaProducerTest { private static final String TEST_TOPIC_PREFIX = "test-avro-producer-"; @@ -994,7 +1007,7 @@ void shouldThrowExceptionForNullPayloads() throws Exception { AvroKafkaProducer avroProducer = new AvroKafkaProducer( topic, null, producer, null, Map.of(), null, "com.example.avro.NoPayloadRecord"); - assertThrows(ProducerException.class, avroProducer::send); + assertThrows(IllegalStateException.class, avroProducer::send); } @Test diff --git a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java similarity index 92% rename from src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java rename to src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java index 03a0a7a..95bc220 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerITTests.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaConsumerTest.java @@ -34,21 +34,27 @@ 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.*; +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.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 RawKafkaConsumerITTests { +class RawKafkaConsumerTest { private static final String TOPIC_PREFIX = "test-raw-consumer-"; @@ -130,7 +136,7 @@ private Topic outputTopic() { /** Absolute path of a classpath resource under {@code match/}. */ private static String resourcePath(String filename) { - URL url = RawKafkaConsumerITTests.class.getClassLoader().getResource("match/" + filename); + URL url = RawKafkaConsumerTest.class.getClassLoader().getResource("match/" + filename); assertNotNull(url, "Test resource not found: match/" + filename); return url.getPath(); } @@ -151,7 +157,7 @@ private ConsumerContext ctx() { // ========================================================================= @Nested - @DisplayName("Consume-only: no matcher") + @DisplayName("Consume-only — no matcher") class ConsumeOnly { @Test @@ -166,7 +172,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( @@ -184,7 +190,7 @@ void throwsWhenTopicEmpty() { // ========================================================================= @Nested - @DisplayName("File matching: METHOD_MATCH_FILE") + @DisplayName("File matching — METHOD_MATCH_FILE") class FileMatching { @Test @@ -193,7 +199,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( - RawKafkaConsumerITTests.class + RawKafkaConsumerTest.class .getClassLoader() .getResourceAsStream("match/expected-order.json") .readAllBytes(), @@ -239,7 +245,7 @@ void doesNotMatchFile() throws Exception { // ========================================================================= @Nested - @DisplayName("Key filter: expectedRecordKey") + @DisplayName("Key filter — expectedRecordKey") class KeyFilter { @Test @@ -267,7 +273,7 @@ void picksRecordWithMatchingKey() throws Exception { // ========================================================================= @Nested - @DisplayName("XML matching: METHOD_MATCH_XML") + @DisplayName("XML matching — METHOD_MATCH_XML") class XmlMatching { @Test @@ -295,7 +301,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. */ @@ -344,7 +350,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) @@ -353,7 +359,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(); @@ -369,7 +375,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() @@ -386,12 +392,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. */ @@ -406,7 +412,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); @@ -416,7 +422,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( @@ -461,7 +467,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()) @@ -474,7 +480,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/RawKafkaProducerITTests.java b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java similarity index 97% rename from src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerITTests.java rename to src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java index 83956be..5230a87 100644 --- a/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerITTests.java +++ b/src/test/java/io/github/ktestify/io/kafka/RawKafkaProducerTest.java @@ -30,12 +30,25 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; -import java.util.*; -import org.apache.kafka.clients.consumer.*; +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 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.*; +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.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -55,7 +68,7 @@ */ @ExtendWith(KafkaTestExtension.class) @DisplayName("RawKafkaProducer Integration Tests") -class RawKafkaProducerITTests { +class RawKafkaProducerTest { private static final String TEST_TOPIC_PREFIX = "test-raw-producer-"; @@ -217,7 +230,7 @@ void shouldThrowExceptionForEmptyPayload() { // Then - empty string payload should throw IllegalStateException // because resolvePayload() treats empty string as no payload - assertThrows(ProducerException.class, rawProducer::send); + assertThrows(IllegalStateException.class, rawProducer::send); } } @@ -558,7 +571,7 @@ void shouldThrowExceptionWhenNoPayloadProvided() { RawKafkaProducer rawProducer = new RawKafkaProducer(context); // When/Then - assertThrows(ProducerException.class, rawProducer::send); + assertThrows(IllegalStateException.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 5d485c9..f4f69ef 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.ConfigException; +import io.github.ktestify.exceptions.ConsumerException; 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(ConfigException.class, () -> RecordMatcherFactory.forRaw("methodDoesNotExist")); + assertThrows(ConsumerException.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(ConfigException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); + assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro("methodDoesNotExist")); } @Test @DisplayName("throws ConsumerException for XML method (not supported for Avro)") void throwsForXml() { - assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); + assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XML)); } @Test @DisplayName("throws ConsumerException for XPath method (not supported for Avro)") void throwsForXPath() { - assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); + assertThrows(ConsumerException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_XPATH)); } @Test @DisplayName("throws ConsumerException for attribute method (raw transports only)") void throwsForAttributes() { - assertThrows(ConfigException.class, () -> RecordMatcherFactory.forAvro(METHOD_MATCH_ATTRIBUTES)); + assertThrows(ConsumerException.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 0a10bea..0f5c6f0 100644 --- a/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java +++ b/src/test/java/io/github/ktestify/match/impl/AttributeRecordMatcherTest.java @@ -147,12 +147,10 @@ void multipleMismatchesReported() { class Misconfiguration { @Test - @DisplayName("fails with noRecords when there is no record to inspect, like every other matcher") - void failsWithoutRecords() throws ComparisonException { + @DisplayName("throws ComparisonException when there is no record to inspect") + void throwsWithoutRecords() { MatchContext context = ctxWithAttributes(Map.of("statusCode", "200")); - MatchResult result = matcher.match(List.of(), context); - assertFalse(result.isPassed()); - assertEquals(MatchResult.noRecords().getDiff(), result.getDiff()); + assertThrows(ComparisonException.class, () -> matcher.match(List.of(), context)); } } } 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 152bc42..4ed40c3 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 deleted file mode 100644 index 79a6ba6..0000000 --- a/src/test/java/io/github/ktestify/match/impl/TombstoneMatcherTest.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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 21d18ce..6a61d36 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 4e2cd3b..91057dd 100644 --- a/src/test/java/io/github/ktestify/models/TopicTest.java +++ b/src/test/java/io/github/ktestify/models/TopicTest.java @@ -20,10 +20,14 @@ import com.typesafe.config.ConfigFactory; import io.github.ktestify.config.KtestifyConfig; import io.github.ktestify.exceptions.ConfigException; -import org.junit.jupiter.api.*; +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; /** - * 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)
    @@ -31,7 +35,7 @@
      * → inject the config namespace
      * 
    */ -@DisplayName("Topic.validateTopic: namespace auto-injection") +@DisplayName("Topic.validateTopic — namespace auto-injection") class TopicTest { // ----------------------------------------------------------------------- @@ -52,7 +56,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(); } @@ -169,7 +173,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 deleted file mode 100644 index 62eec80..0000000 --- a/src/test/java/io/github/ktestify/plugin/ClasspathTestPlugin.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 dc1a347..13665ab 100644 --- a/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java +++ b/src/test/java/io/github/ktestify/plugin/PluginRegistryTest.java @@ -20,15 +20,10 @@ 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; @@ -55,11 +50,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 @@ -69,13 +64,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() + "\"")); @@ -85,7 +80,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\"")); @@ -94,7 +89,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 = \"\"")); @@ -103,7 +98,7 @@ void blankPluginsDirSkipsExternalLoading() { } // ========================================================================= - // PluginRegistry: shutdown + // PluginRegistry — shutdown // ========================================================================= @Nested @@ -116,71 +111,10 @@ 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 @@ -188,7 +122,7 @@ private long countById(PluginRegistry registry, String id) { class GluePackagesTests { @Test - @DisplayName("returns list: never null") + @DisplayName("returns list — never null") void getGluePackagesNeverNull() { PluginRegistry registry = PluginRegistry.load(CTX); assertNotNull(registry.getGluePackages()); @@ -197,7 +131,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"); @@ -214,11 +148,11 @@ void filtersNullAndBlankGluePackages() { } // ========================================================================= - // KtestifyPlugin: default methods + // KtestifyPlugin — default methods // ========================================================================= @Nested - @DisplayName("KtestifyPlugin: default author methods") + @DisplayName("KtestifyPlugin — default author methods") class DefaultAuthorMethodsTests { @Test @@ -324,7 +258,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 { @@ -354,7 +288,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 3c71fd4..0341959 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 7b8f78a..3e7ba79 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 71a7b12..dca8656 100644 --- a/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java +++ b/src/test/java/io/github/ktestify/utils/FieldMatcherUtilsTest.java @@ -31,14 +31,7 @@ void testGetLine() { @Test void testGetLineOutOfBounds() { String content = "First line\nSecond line"; - 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)); + assertThrows(ArrayIndexOutOfBoundsException.class, () -> FieldMatcherUtils.getLine(content, 2)); } @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 deleted file mode 100644 index 3d105ef..0000000 --- a/src/test/java/io/github/ktestify/utils/serdes/AvroLogicalTypesSerializationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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 d31e2c4..e7bcd0c 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 deleted file mode 100644 index 4c24c55..0000000 --- a/src/test/resources/META-INF/services/io.github.ktestify.plugin.KtestifyPlugin +++ /dev/null @@ -1 +0,0 @@ -io.github.ktestify.plugin.ClasspathTestPlugin