diff --git a/.gitattributes b/.gitattributes index cb66d79..58f7dcc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ * text=auto eol=lf /.github export-ignore -/docs export-ignore +/scripts export-ignore /tests export-ignore /.editorconfig export-ignore /.gitattributes export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1828f20..bd03d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,14 @@ permissions: contents: read concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: PHP ${{ matrix.php }} runs-on: ubuntu-24.04 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -29,10 +30,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} coverage: none @@ -47,6 +48,10 @@ jobs: - name: Unit tests run: composer test + - name: Security audit + if: matrix.php == '8.4' + run: composer audit --no-interaction + - name: Coding standard if: matrix.php == '8.4' run: composer style @@ -54,3 +59,284 @@ jobs: - name: Static analysis if: matrix.php == '8.4' run: composer analyse + + lowest: + name: Lowest supported dependencies + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + + - name: Resolve lowest supported dependencies + run: >- + composer update + --no-interaction + --prefer-lowest + --prefer-stable + --ignore-platform-req=ext-getbiblesword + + - name: Unit tests + run: composer test + + coverage: + name: Coverage inventory + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.4" + coverage: xdebug + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-getbiblesword + + - name: Build coverage report + env: + XDEBUG_MODE: coverage + run: composer coverage + + - name: Build coverage inventory + run: composer coverage-inventory + + - name: Retain coverage evidence + if: always() && hashFiles('build/coverage*') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ github.sha }} + path: build/coverage* + if-no-files-found: error + retention-days: 30 + + distribution: + name: Composer distribution + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.4" + coverage: none + tools: composer:v2 + + - name: Build Composer archive + shell: bash + run: | + set -euo pipefail + version="$(tr -d '\r\n' < VERSION)" + mkdir -p build/dist + COMPOSER_ROOT_VERSION="$version" composer archive \ + --format=zip \ + --dir=build/dist \ + --file="getbible-scripture-${version}" + + - name: Install clean distribution + shell: bash + run: | + set -euo pipefail + archive="build/dist/getbible-scripture-$(tr -d '\r\n' < VERSION).zip" + test -s "$archive" + mkdir -p build/extracted + unzip -q "$archive" -d build/extracted + package_composer="$( + find build/extracted -maxdepth 2 -type f -name composer.json \ + -print -quit + )" + test -n "$package_composer" + package_root="$(dirname "$package_composer")" + composer validate --strict --working-dir="$package_root" + composer install \ + --working-dir="$package_root" \ + --no-dev \ + --no-interaction \ + --prefer-dist \ + --ignore-platform-req=ext-getbiblesword + php -r ' + require $argv[1]; + exit( + interface_exists( + GetBible\Scripture\Service\ScriptureInterface::class + ) ? 0 : 1 + ); + ' "$package_root/vendor/autoload.php" + + - name: Retain package evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: composer-package-${{ github.sha }} + path: build/dist/* + if-no-files-found: error + retention-days: 30 + + integration: + name: Released extension and local SWORD fixture + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.4" + coverage: none + tools: composer:v2 + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes \ + autoconf automake binutils build-essential cmake curl git \ + libbz2-dev libcurl4-openssl-dev libicu-dev liblzma-dev \ + libsword-utils libtool pkg-config unzip zlib1g-dev + + - name: Install published getbible/sword source through PIE + shell: bash + run: | + set -euo pipefail + sword_version="0.1.0" + sword_archive="php_getbiblesword-${sword_version}-src.tgz" + sword_release="https://github.com/getbible/sword/releases/download/v${sword_version}" + curl \ + --fail \ + --location \ + --proto '=https' \ + --retry 3 \ + --show-error \ + --silent \ + --tlsv1.2 \ + --output "$RUNNER_TEMP/pie.phar" \ + https://github.com/php/pie/releases/download/1.4.9/pie.phar + echo \ + '19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0 '"$RUNNER_TEMP"'/pie.phar' \ + | sha256sum --check --strict + curl \ + --fail \ + --location \ + --proto '=https' \ + --retry 3 \ + --show-error \ + --silent \ + --tlsv1.2 \ + --output "$RUNNER_TEMP/$sword_archive" \ + "$sword_release/$sword_archive" + curl \ + --fail \ + --location \ + --proto '=https' \ + --retry 3 \ + --show-error \ + --silent \ + --tlsv1.2 \ + --output "$RUNNER_TEMP/$sword_archive.sha256" \ + "$sword_release/$sword_archive.sha256" + ( + cd "$RUNNER_TEMP" + sha256sum --check --strict "$sword_archive.sha256" + ) + mkdir -p "$RUNNER_TEMP/getbiblesword" + tar \ + --extract \ + --gzip \ + --file "$RUNNER_TEMP/$sword_archive" \ + --directory "$RUNNER_TEMP/getbiblesword" + sword_root="$RUNNER_TEMP/getbiblesword/php_getbiblesword-${sword_version}" + test -f "$sword_root/composer.json" + test -f "$sword_root/vendor/getbiblesword/src/c_api.cpp" + sudo env COMPOSER_ALLOW_SUPERUSER=1 \ + php "$RUNNER_TEMP/pie.phar" repository:add path "$sword_root" + sudo env COMPOSER_ALLOW_SUPERUSER=1 \ + php "$RUNNER_TEMP/pie.phar" install \ + --no-cache \ + 'getbible/sword:*@dev' + php --ri getbiblesword + extension_path="$( + php -r ' + echo rtrim((string) ini_get("extension_dir"), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . "getbiblesword." + . PHP_SHLIB_SUFFIX; + ' + )" + test -f "$extension_path" + if ldd "$extension_path" \ + | grep -E 'lib(getbible)?sword[^[:space:]]*\.so'; then + echo 'The extension unexpectedly depends on a SWORD shared library.' >&2 + exit 1 + fi + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist + + - name: Create deterministic SWORD fixture + id: fixture + shell: bash + run: | + set -euo pipefail + sword_utils_version="$( + dpkg-query --show --showformat='${Version}' libsword-utils + )" + if [[ "$sword_utils_version" != 1.9.0+dfsg-* ]]; then + echo "Unexpected libsword-utils version: $sword_utils_version" >&2 + exit 1 + fi + bash scripts/create-native-fixture.sh "$RUNNER_TEMP/sword" + fixture_tree_sha256="$( + find "$RUNNER_TEMP/sword" -type f -printf '%P\0' \ + | sort --zero-terminated \ + | while IFS= read -r -d '' relative_path; do + printf '%s\0' "$relative_path" + sha256sum "$RUNNER_TEMP/sword/$relative_path" \ + | cut --delimiter=' ' --fields=1 \ + | tr --delete '\n' + printf '\0' + done \ + | sha256sum \ + | cut --delimiter=' ' --fields=1 + )" + { + printf 'source_sha256=%s\n' \ + '7ceaa7330fd234669dfc58c67294639a0a58a5a75c70efb4320a3f7adfc428a5' + printf 'tree_sha256=%s\n' "$fixture_tree_sha256" + printf 'sword_utils_version=%s\n' "$sword_utils_version" + } >> "$GITHUB_OUTPUT" + + - name: Run installed-module integration + env: + FIXTURE_SOURCE_SHA256: ${{ steps.fixture.outputs.source_sha256 }} + FIXTURE_TREE_SHA256: ${{ steps.fixture.outputs.tree_sha256 }} + SWORD_UTILS_VERSION: ${{ steps.fixture.outputs.sword_utils_version }} + run: >- + php scripts/integration-native.php + "$RUNNER_TEMP/sword" + "$RUNNER_TEMP/cache" + "build/integration-evidence.json" + + - name: Retain integration evidence + if: always() && hashFiles('build/integration-evidence.json') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-integration-${{ github.sha }} + path: build/integration-evidence.json + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a589e75 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: Exact stable version already recorded in VERSION and CHANGELOG.md + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + release: + name: Validate and publish + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: >- + ${{ github.event_name == 'workflow_dispatch' + && github.event.repository.default_branch + || github.ref }} + + - name: Set up PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: "8.4" + coverage: none + tools: composer:v2 + + - name: Resolve release + id: release + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + PUSHED_TAG: ${{ github.ref_name }} + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + version="$(tr -d '\r\n' < VERSION)" + tag="v$version" + + if [[ "$EVENT_NAME" == workflow_dispatch ]]; then + if [[ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]]; then + echo "Manual releases must run from $DEFAULT_BRANCH." >&2 + exit 1 + fi + if [[ "$REQUESTED_VERSION" != "$version" ]]; then + echo "Requested $REQUESTED_VERSION but VERSION contains $version." >&2 + exit 1 + fi + elif [[ "$PUSHED_TAG" != "$tag" ]]; then + echo "Pushed tag $PUSHED_TAG does not match VERSION $version." >&2 + exit 1 + fi + + bash scripts/validate-release.sh "$tag" + git fetch origin "$DEFAULT_BRANCH" + if ! git merge-base --is-ancestor HEAD "origin/$DEFAULT_BRANCH"; then + echo "Release commit is not contained in $DEFAULT_BRANCH." >&2 + exit 1 + fi + + { + printf 'version=%s\n' "$version" + printf 'tag=%s\n' "$tag" + } >> "$GITHUB_OUTPUT" + + - name: Validate Composer package + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-getbiblesword + + - name: Run release checks + run: | + composer check + composer audit --no-interaction + + - name: Build distribution + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + COMPOSER_ROOT_VERSION="$RELEASE_VERSION" composer archive \ + --format=zip \ + --dir=dist \ + --file="getbible-scripture-${RELEASE_VERSION}" + ( + cd dist + sha256sum "getbible-scripture-${RELEASE_VERSION}.zip" \ + > SHA256SUMS + sha256sum --check --strict SHA256SUMS + ) + + - name: Verify clean distribution + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/scripture-package" + unzip -q \ + "dist/getbible-scripture-${RELEASE_VERSION}.zip" \ + -d "$RUNNER_TEMP/scripture-package" + package_composer="$( + find "$RUNNER_TEMP/scripture-package" \ + -maxdepth 2 \ + -type f \ + -name composer.json \ + -print -quit + )" + test -n "$package_composer" + package_root="$(dirname "$package_composer")" + composer validate --strict --working-dir="$package_root" + composer install \ + --working-dir="$package_root" \ + --no-dev \ + --no-interaction \ + --prefer-dist \ + --ignore-platform-req=ext-getbiblesword + php -r ' + require $argv[1]; + exit( + interface_exists( + GetBible\Scripture\Service\ScriptureInterface::class + ) ? 0 : 1 + ); + ' "$package_root/vendor/autoload.php" + + - name: Create annotated tag + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + if git ls-remote --exit-code --tags origin \ + "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + echo "Tag $RELEASE_TAG already exists." >&2 + exit 1 + fi + git config user.name 'github-actions[bot]' + git config user.email \ + '41898282+github-actions[bot]@users.noreply.github.com' + git tag --annotate "$RELEASE_TAG" \ + --message "getbible/scripture $RELEASE_VERSION" + git push origin "refs/tags/$RELEASE_TAG" + + - name: Build release notes + shell: bash + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + awk -v heading="## [$RELEASE_VERSION]" ' + index($0, heading) == 1 { + found = 1 + next + } + found && /^## \[/ { + exit + } + found { + print + } + END { + if (!found) { + exit 2 + } + } + ' CHANGELOG.md > "$RUNNER_TEMP/release-notes.md" + test -s "$RUNNER_TEMP/release-notes.md" + + - name: Publish GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "GitHub release $RELEASE_TAG already exists." >&2 + exit 1 + fi + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "GetBible Scripture $RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index b925d2b..e3b3d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and the project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.0] - 2026-07-26 + ### Added - Initial Composer package structure. @@ -26,7 +28,34 @@ and the project uses [Semantic Versioning](https://semver.org/). - Joomla Console initialization, refresh, and status commands. - Joomla Scheduled Tasks callable integration. - Production cron, systemd, health-check, and scheduler documentation. +- Interactive, atomic application configuration through `scripture:setup`. +- Read-only runtime and deployment diagnostics through `scripture:doctor`. +- Reader leases, bounded generation retention, stale-staging cleanup, and + corruption recovery for immutable snapshots. +- Real released-extension integration against a locally compiled Public Domain + SWORD module, plus retained machine-readable evidence. +- Coverage inventory, lowest-dependency, security-audit, and clean Composer + distribution checks. +- Validated tag-driven GitHub release automation. +- Product shipping, installation, and deployment documentation. ### Changed - Project license aligned with the GPL-2.0-only native dependency. +- Native extension compatibility is restricted to the tested `0.1.x` line. +- Runtime configuration can be persisted in a versioned JSON document while + retaining explicit environment overrides. +- The convenient PIE installation path selects `getbible/sword` `0.1.1` or + later; an already-installed `0.1.0` runtime remains ABI-compatible. +- Public documentation focuses on supported integration and operational + constraints. + +### Security + +- Module identifiers, snapshot pointers, indexes, entry records, and filesystem + paths receive boundary validation before use. +- Snapshot recovery verifies bound hashes and preserves leased readers before + activation or cleanup. + +[Unreleased]: https://github.com/getbible/scripture/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/getbible/scripture/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a9897b..b9c84ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Install the native extension first, then install Composer dependencies: ```bash -pie install getbible/sword +pie install 'getbible/sword:^0.1.1' composer install composer check ``` @@ -17,6 +17,19 @@ composer install --ignore-platform-req=ext-getbiblesword composer check ``` +Generate the same coverage inventory retained by CI: + +```bash +XDEBUG_MODE=coverage composer coverage +composer coverage-inventory +``` + +Every public class and method must have intentional test evidence. New +filesystem, concurrency, native-boundary, configuration, or recovery behavior +also requires a failure-path test. The coverage inventory fails when any public +or protected implementation method was never invoked, in addition to enforcing +the repository's statement and method coverage floors. + ## Engineering rules - Preserve GPL-2.0-only SPDX headers on source files. diff --git a/LICENSE b/LICENSE index 6651c66..8663535 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,3 @@ -GetBible Scripture -SPDX-License-Identifier: GPL-2.0-only - GNU GENERAL PUBLIC LICENSE Version 2, June 1991 diff --git a/README.md b/README.md index 616443e..d2cdbd1 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,32 @@ entire Bible object graph into every PHP process. The package is deliberately Bible-only. Non-Bible SWORD modules remain visible in the low-level catalog but are not exposed as Scripture translations. -## Status +## Capabilities -Phases 0–3 are implemented. Installed SWORD modules are production-readable; -the package exposes capability-driven provisioning, bounded reader/writer -locking, durable interval state, per-module outcomes, Joomla Console commands, -and a Joomla Scheduled Tasks bridge. The current native ABI still reports -remote mutation as unavailable until its additive provisioning API is released. -See the -[roadmap](docs/roadmap.md) and [provisioning boundary](docs/provisioning.md). +Installed SWORD Bible modules are exposed through validated, immutable +snapshots. The package provides bounded reader/writer locking, durable refresh +state, per-module outcomes, Joomla Console commands, and a Joomla Scheduled +Tasks bridge. -## Requirements +The released native ABI lists and exports installed modules. It does not +download, update, or remove CrossWire modules. Those operations remain disabled +unless the application injects a provisioner that explicitly advertises and +implements them. See the [provisioning boundary](docs/provisioning.md). + +## Getting started + +### Product-distributed runtime + +People receiving an application should receive PHP, the native extension, the +Composer dependencies, and policy-approved SWORD modules as one tested runtime. +They should not need a compiler, PIE, or Composer on the production host. The +[shipping and deployment guide](docs/distribution.md) provides a concrete +container build and the equivalent installer contract for application +distributors. + +### Existing Composer project + +Direct library integration requires: - Linux with PHP 8.2, 8.3, 8.4, or 8.5. - The `getbiblesword` PHP extension, installed through PIE. @@ -33,12 +48,41 @@ See the - A readable SWORD module root. ```bash -pie install getbible/sword +pie install 'getbible/sword:^0.1.1' php --ri getbiblesword composer require getbible/scripture ``` -## Quick start +PIE owns the native-install confirmation and any administrator prompt. Version +`0.1.1` is the first extension release whose source asset follows PIE's normal +discovery convention. An already-installed `0.1.0` extension remains compatible +with this package, but its published source asset cannot be installed by that +one-command path. + +The extension check is intentionally strict. Composer treats PHP extensions as +platform requirements; it verifies that `ext-getbiblesword` is loaded but does +not install it. Composer also does not execute scripts declared by dependency +packages. See [installation and setup](docs/installation.md) for the exact +boundary and supported deployment choices. + +### Interactive setup + +After Composer installation, inspect the runtime and configure the library: + +```bash +vendor/bin/getbible-scripture scripture:doctor +vendor/bin/getbible-scripture scripture:setup +``` + +`scripture:setup` validates the loaded native runtime, module and cache paths, +installed Bible modules, refresh policy, and permissions. It does not invoke +PIE and cannot download CrossWire modules through ABI v1. + +Applications can perform the same persisted configuration update through +`SetupServiceInterface::apply()`, including immediate warm-up. See +[programmatic setup](docs/api.md#programmatic-setup). + +### Programmatic setup ```php get(ScriptureInterface::class); +$initialization = $scripture->initialize(['KJV']); + +if (!$initialization->succeeded()) { + throw new RuntimeException( + json_encode( + $initialization->toArray(), + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, + ), + ); +} + $kjv = $scripture->translation('KJV'); $john316 = $kjv->book('John')->chapter(3)->verse(16); $text = $john316->stripped(); @@ -88,11 +143,14 @@ The first request for an installed translation performs one full native export, validates it, and creates an immutable indexed generation. Later requests open that generation and hydrate only the requested objects. -Initialize configured translations and run interval-based maintenance: +Run interval-based maintenance from the application scheduler: ```php -$initialization = $scripture->initialize(); $maintenance = $scripture->refreshIfDue(); + +if (!$maintenance->succeeded()) { + // Send $maintenance->toArray() to the application's operational log. +} ``` No constructor performs network or full-module work. Both methods return @@ -116,8 +174,10 @@ projection. ## Configuration -Configuration precedence is explicit array values, environment values, and -documented defaults: +Configuration precedence depends on the entry point. Explicit configuration +objects always win. Interactive or programmatic setup applies request values, +then environment values, then persisted JSON, then documented defaults. +Normal container loading applies environment values over persisted JSON. | Key | Environment variable | Default | |---|---|---| @@ -135,13 +195,14 @@ See [configuration](docs/configuration.md) for operational details. ## Documentation - [Architecture](docs/architecture.md) +- [Installation and setup](docs/installation.md) - [Public API](docs/api.md) - [Configuration](docs/configuration.md) - [Contract v1 mapping](docs/contract-v1.md) - [Caching and refresh](docs/caching.md) - [Module provisioning](docs/provisioning.md) - [Production operations](docs/operations.md) -- [Roadmap](docs/roadmap.md) +- [Shipping and deployment](docs/distribution.md) - [Releasing](docs/releasing.md) ## License diff --git a/SECURITY.md b/SECURITY.md index 6d087cf..c978fe8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,9 @@ ## Supported versions -Security fixes are applied to the latest released minor line. Until `1.0.0`, -only the most recent `0.x` release is supported. +Security fixes are applied to the latest tagged release and to the default +branch. Older release lines are not supported unless a security advisory +explicitly states otherwise. ## Reporting @@ -27,7 +28,7 @@ success state before activating a snapshot. Raw and rendered markup is data, not trusted HTML. Callers must escape or sanitize it for the destination context. -Module installation is intentionally not implemented through ABI v1. A future -provisioner must add explicit repository policy, TLS requirements, archive -validation, licensing decisions, interprocess locks, staging, and atomic -activation. +Module installation is intentionally not implemented through ABI v1. Any +injected provisioner must add explicit repository policy, TLS requirements, +archive validation, licensing decisions, interprocess locks, staging, atomic +activation, and rollback. diff --git a/VERSION b/VERSION index 0d91a54..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +1.0.0 diff --git a/composer.json b/composer.json index 874c793..bc34726 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,9 @@ "license": "GPL-2.0-only", "homepage": "https://github.com/getbible/scripture", "support": { + "docs": "https://github.com/getbible/scripture/tree/main/docs", "issues": "https://github.com/getbible/scripture/issues", + "security": "https://github.com/getbible/scripture/security/policy", "source": "https://github.com/getbible/scripture" }, "authors": [ @@ -24,7 +26,7 @@ ], "require": { "php": ">=8.2 <8.6", - "ext-getbiblesword": ">=0.1.0", + "ext-getbiblesword": "^0.1.0", "ext-hash": "*", "ext-json": "*", "joomla/di": "^3.1.1 || ^4.0", @@ -39,7 +41,7 @@ "squizlabs/php_codesniffer": "^3.13 || ^4.0" }, "suggest": { - "getbible/sword": "Install the native PHP extension with: pie install getbible/sword" + "getbible/sword": "Install the native PHP extension with PIE: pie install 'getbible/sword:^0.1.1'" }, "autoload": { "psr-4": { @@ -64,6 +66,8 @@ "package-validate": "composer validate --strict", "style": "phpcs -p --standard=ruleset.xml src tests", "analyse": "phpstan analyse --no-progress", + "coverage": "phpunit --coverage-clover build/coverage.xml --coverage-text", + "coverage-inventory": "php scripts/coverage-inventory.php build/coverage.xml 94 87", "test": "phpunit" }, "config": { diff --git a/docs/api.md b/docs/api.md index 1510557..3ae741b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,10 +12,54 @@ $container = ContainerFactory::create($configuration); $scripture = $container->get(ScriptureInterface::class); ``` +To load the versioned JSON document written by `scripture:setup`: + +```php +$container = ContainerFactory::create( + configuration: null, + configurationPath: '/etc/getbible/scripture.json', +); +$scripture = $container->get(ScriptureInterface::class); +``` + Applications already using Joomla DI can register `ScriptureServiceProvider` with their own container and pre-register `Configuration` when they need custom values. +## Programmatic setup + +Applications can persist validated settings and warm installed translations +through the same service used by the interactive command: + +```php +use GetBible\Scripture\Setup\SetupRequest; +use GetBible\Scripture\Setup\SetupServiceInterface; + +/** @var SetupServiceInterface $setup */ +$setup = $container->get(SetupServiceInterface::class); + +$result = $setup->apply(new SetupRequest( + configurationPath: '/etc/getbible/scripture.json', + values: [ + 'module_path' => '/var/lib/getbible/sword', + 'cache_path' => '/var/cache/getbible/scripture', + 'modules' => ['KJV'], + 'auto_refresh' => true, + ], + warm: true, +)); + +if (!$result->succeeded()) { + throw new RuntimeException( + json_encode($result->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + ); +} +``` + +`SetupServiceInterface` never invokes PIE, Composer, a system package manager, +or a module download. It changes only the application-owned JSON configuration +and optionally warms modules already available to the native runtime. + ## Scripture service ```php @@ -60,8 +104,11 @@ $translation->books(); $translation->book('John'); $translation->bookByPosition(testament: 2, book: 4); $translation->verses('John', 3, 16, 18); -$translation->configEntries('DistributionLicense'); +$translation->configEntriesNamed('DistributionLicense'); $translation->rawExportPath(); +$translation->activatedAt(); +$translation->expiresAt(); +$translation->generationId(); ``` Configuration entries are ordered and repeated keys are preserved. @@ -142,5 +189,7 @@ The default Joomla dispatcher emits: - `onGetBibleScriptureMaintenanceCompleted` - `onGetBibleScriptureMaintenanceFailed` -Each event contains the module name and the relevant snapshot or exception. -Listeners must not mutate an active generation. +Event arguments contain the relevant module, operation, result, snapshot, or +exception for that lifecycle boundary. Listeners must not mutate an active +generation. Listener exceptions are contained so an observer cannot turn a +committed core operation into a reported failure. diff --git a/docs/architecture.md b/docs/architecture.md index 3a17174..d03916e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ extension class behind `ModuleExtractorInterface`. - LF framing; - consecutive sequence numbers; -- record phase order; +- record ordering state; - required record shapes; - canonical and hashed byte envelopes; - ordered annotation reconstruction; @@ -86,9 +86,9 @@ Introductions are indexed separately and never represented as verse zero. ### Provisioning Native ABI v1 cannot install or update modules. `ModuleProvisionerInterface` -and `ProvisioningCoordinatorInterface` allow the eventual native provisioning -implementation to be injected without changing the Scripture API. Capability -discovery keeps ABI limitations explicit. See [provisioning](provisioning.md). +and `ProvisioningCoordinatorInterface` accept an application-supplied +implementation without changing the Scripture API. Capability discovery keeps +ABI limitations explicit. See [provisioning](provisioning.md). ### Automated maintenance @@ -112,7 +112,7 @@ all invoke this same service. They do not reproduce lifecycle policy. refresh policy, and events are separate services. - Open/closed: ABI or persistence implementations can be added behind existing interfaces. -- Liskov substitution: test engines and future native engines obey the same +- Liskov substitution: test engines and native engine adapters obey the same streaming contract. - Interface segregation: callers depend on query, extraction, or provisioning contracts instead of one large manager. @@ -134,8 +134,8 @@ acquisition has a configurable timeout; it never waits indefinitely. The native extension serializes SWORD access within one PHP process. Different processes remain independent. -A future native module provisioner must additionally enforce repository, -staging, atomic-install, and rollback policy within its own boundary. Snapshot +Any native module provisioner must additionally enforce repository, staging, +atomic-install, and rollback policy within its own boundary. Snapshot immutability does not make an in-place native install safe. ## Failure behavior diff --git a/docs/caching.md b/docs/caching.md index d3fa7c4..697ba1e 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -17,6 +17,12 @@ When a translation is requested: Only the compact index is loaded into memory. Verse records are read by exact offset from `module.ndjson`. +## Process cache + +Each PHP process retains at most 32 open snapshot readers in least-recently-used +order. A cached reader is reused only while its generation and index digest +still match the active pointer. Eviction releases its shared generation lease. + ## Generation identity The validated footer `stream_sha256` names the generation. The generated time is @@ -50,6 +56,23 @@ can run `scripture:refresh --if-due` from cron or a systemd timer. See ## Cleanup -The first release retains prior immutable generations so active readers cannot -lose files. Bounded generation cleanup is a later hardening phase and must use -reader-aware retention. +The active generation and one valid prior generation are retained. Each open +snapshot holds a shared reader lease. Cleanup skips any old generation whose +lease cannot be acquired exclusively, so an active reader never loses the files +it already opened. + +Under the bounded warm lock, maintenance also removes abandoned +`.staging-*` and `.corrupt-*` directories plus `current.*.tmp` files after one +hour. + +## Recovery + +The active pointer binds the selected index SHA-256. The index binds the full +module file size and SHA-256, and every indexed entry binds its canonical record +SHA-256. Reads therefore detect pointer, index, stream, offset, and record +corruption before returning a domain object. + +When the active pointer or generation is invalid, the manager selects a valid +cached generation and repairs the pointer atomically. If no valid generation is +available, it performs a locked native export and activates it only after full +validation. Failed recovery leaves every previously valid generation unchanged. diff --git a/docs/configuration.md b/docs/configuration.md index 695661a..6fbbffb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,37 @@ A userland Composer package cannot register PHP INI directives. The package does not claim custom `ini_set()` support. +## Configuration file + +`scripture:setup` writes an application-owned JSON document using the contract +identifier `getbible.scripture.configuration/v1`. Supply its absolute path with +`--config` or `GETBIBLE_SCRIPTURE_CONFIG_PATH`; no implicit file location is +used. + +Setup precedence is: + +1. setup command options; +2. matching `GETBIBLE_SCRIPTURE_*` environment variables; +3. values already stored in the JSON document; +4. defaults. + +Normal `scripture:initialize`, `scripture:refresh`, and `scripture:status` +commands load the document when `GETBIBLE_SCRIPTURE_CONFIG_PATH` is set. Those +maintenance commands do not accept `--config`. + +Programmatic applications can load the same document explicitly: + +```php +$container = ContainerFactory::create( + configuration: null, + configurationPath: '/etc/getbible/scripture.json', +); +``` + +The repository accepts only allowlisted settings, refuses a symlink target, +persists through an atomic same-directory replacement, gives a new parent +directory mode `0700`, and gives a new file mode `0600`. + ## Settings ### `module_path` diff --git a/docs/contract-v1.md b/docs/contract-v1.md index a84eb36..fa86479 100644 --- a/docs/contract-v1.md +++ b/docs/contract-v1.md @@ -82,7 +82,7 @@ Repeated names and source order are not collapsed into associative arrays. ## Whole-stream validation JSON Schema validation alone is insufficient. The package independently checks -sequence, phase order, framing, byte envelopes, annotation reconstruction, +sequence, record ordering, framing, byte envelopes, annotation reconstruction, artifact state, counts, footer success, and the exact stream digest. The current reference Python validator can return process exit zero for a diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..147cec6 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,178 @@ +# Shipping and deployment + +## Product delivery + +An application distributor should deliver one tested runtime containing: + +- a supported PHP build; +- the released `getbible/sword` extension; +- the application's locked Composer dependencies; +- application-owned configuration; +- a writable snapshot cache; and +- only the SWORD modules the distributor is licensed to provide. + +PIE and native build tools belong in the product build pipeline, not in the +end-user onboarding flow. The deployed runtime must not compile or download code +when the application starts. + +## Container build pattern + +The following Dockerfile is a complete build pattern for a PHP CLI application +whose committed `composer.lock` includes `getbible/scripture`. It installs PIE +and the native extension while the image is built. It does not download Bible +modules; mount or copy a separately reviewed module root according to each +module's distribution terms. + +```dockerfile +# syntax=docker/dockerfile:1.7 +FROM php:8.4-cli-bookworm + +ARG PIE_VERSION=1.4.9 +ARG PIE_SHA256=19a31ddd4bfd08b9eb5eaad2e5f63e76e7919cae7683852da41c80da704ad6c0 + +ENV GETBIBLE_SCRIPTURE_CONFIG_PATH=/etc/getbible/scripture.json \ + GETBIBLE_SCRIPTURE_MODULE_PATH=/var/lib/getbible/sword \ + GETBIBLE_SCRIPTURE_CACHE_PATH=/var/cache/getbible/scripture + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + binutils \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + git \ + libbz2-dev \ + libcurl4-openssl-dev \ + libicu-dev \ + liblzma-dev \ + libtool \ + pkg-config \ + unzip \ + zlib1g-dev; \ + curl \ + --fail \ + --location \ + --proto '=https' \ + --retry 3 \ + --show-error \ + --silent \ + --tlsv1.2 \ + --output /tmp/pie.phar \ + "https://github.com/php/pie/releases/download/${PIE_VERSION}/pie.phar"; \ + echo "${PIE_SHA256} /tmp/pie.phar" | sha256sum --check --strict; \ + php /tmp/pie.phar install --no-cache 'getbible/sword:^0.1.1'; \ + php --ri getbiblesword; \ + rm -f /tmp/pie.phar; \ + rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2.10.2 /usr/bin/composer /usr/local/bin/composer + +WORKDIR /srv/application + +COPY . . + +RUN set -eux; \ + composer install \ + --no-dev \ + --no-interaction \ + --prefer-dist \ + --classmap-authoritative; \ + groupadd --system scripture; \ + useradd --system --gid scripture --home-dir /nonexistent scripture; \ + mkdir -p \ + /etc/getbible \ + /var/cache/getbible/scripture \ + /var/lib/getbible/sword; \ + chown -R scripture:scripture \ + /etc/getbible \ + /var/cache/getbible/scripture + +USER scripture:scripture + +ENTRYPOINT ["php", "vendor/bin/getbible-scripture"] +CMD ["scripture:doctor", "--json"] +``` + +Build the product image in CI: + +```bash +docker build --pull --tag registry.example/getbible-application:2026.07 . +``` + +Create persistent application-owned configuration and cache volumes: + +```bash +docker volume create getbible-scripture-config +docker volume create getbible-scripture-cache +``` + +Run setup once with the reviewed module root read-only: + +```bash +docker run --rm \ + --mount type=volume,src=getbible-scripture-config,dst=/etc/getbible \ + --mount type=bind,src=/srv/getbible/sword,dst=/var/lib/getbible/sword,readonly \ + --mount type=volume,src=getbible-scripture-cache,dst=/var/cache/getbible/scripture \ + registry.example/getbible-application:2026.07 \ + scripture:setup \ + --config=/etc/getbible/scripture.json \ + --module-path=/var/lib/getbible/sword \ + --cache-path=/var/cache/getbible/scripture \ + --refresh-interval=P1M \ + --lock-timeout=30 \ + --module=KJV \ + --auto-refresh \ + --json +``` + +The product can then run readiness checks without PIE or build tools: + +```bash +docker run --rm \ + --mount type=volume,src=getbible-scripture-config,dst=/etc/getbible \ + --mount type=bind,src=/srv/getbible/sword,dst=/var/lib/getbible/sword,readonly \ + --mount type=volume,src=getbible-scripture-cache,dst=/var/cache/getbible/scripture \ + registry.example/getbible-application:2026.07 \ + scripture:doctor --json +``` + +The image intentionally retains the native build packages. A distributor can +reduce image size with a carefully verified multi-stage build, but must copy +every runtime library reported by `ldd`, keep PHP ABI/ZTS compatibility exact, +and preserve the extension's INI activation. Omitting one of those checks can +produce an image that builds successfully but fails when PHP starts. + +## VM and operating-system installer contract + +An operating-system package or application installer should perform these +steps with administrator authorization: + +1. install a supported PHP runtime; +2. install the released `getbible/sword` extension for that exact PHP ABI; +3. verify `php --ri getbiblesword`; +4. install the application's locked Composer vendor tree; +5. install or mount reviewed SWORD modules under an application-owned root; +6. create the cache and configuration directories with least privilege; +7. run `scripture:setup` with explicit non-interactive values; +8. run `scripture:doctor --json` and stop on a non-zero exit; +9. install the scheduler described in [production operations](operations.md); +10. start the product only after every readiness check succeeds. + +The installer should record the application version, PHP version, extension +version, native product and ABI versions, contract identifier, module-source +provenance, and configuration checksum. + +## Module distribution + +CrossWire modules have independent licenses and disclaimers. A product +distributor must decide which modules may be redistributed and must preserve +their metadata and terms. The released native ABI does not provide repository, +license-acceptance, download, update, or atomic module-install operations. + +Do not make the product entry point download a raw module archive. Install +module content through a reviewed deployment step, mount it read-only for +application workers, and use Scripture snapshots for validated access. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..8536034 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,218 @@ +# Installation and setup + +## Choose the delivery model + +For an end-user product, distribute a complete runtime containing PHP, the +native extension, Composer dependencies, and the application's policy-approved +SWORD modules. The recipient should start the product without compiling a PHP +extension or running dependency-manager commands. See +[shipping and deployment](distribution.md). + +Developers adding the library to an existing Composer application install the +native prerequisite first: + +```bash +pie install 'getbible/sword:^0.1.1' +php --ri getbiblesword +composer require getbible/scripture +``` + +The released `getbible/sword` PIE package contains the pinned getBibleSword and +CrossWire SWORD sources. PIE builds and enables the extension for the selected +PHP installation. PIE, rather than Composer, owns the native-install +confirmation and any administrator prompt. + +Use extension package `0.1.1` or later for this one-command installation path. +The already-published `0.1.0` runtime satisfies this library's ABI requirement +when it is installed, but that release used a source-asset name that PIE cannot +discover through the normal package command. + +## Why Composer does not install the extension + +`ext-getbiblesword` is a Composer platform dependency. Platform dependencies +describe the PHP runtime that is executing Composer; Composer validates them but +does not download or enable extensions. The official explanation is in +[Composer platform dependencies](https://getcomposer.org/doc/articles/composer-platform-dependencies.md). + +Adding a `post-install-cmd` to this library would not help. Composer executes +scripts from the root application only and does not execute scripts declared by +a dependency. See +[Composer scripts](https://getcomposer.org/doc/articles/scripts.md#what-is-a-script). + +The root application can provide friendly Composer aliases without granting a +dependency permission to execute automatically: + +```json +{ + "scripts": { + "scripture:doctor": "@php vendor/bin/getbible-scripture scripture:doctor --json", + "scripture:setup": "@php vendor/bin/getbible-scripture scripture:setup" + } +} +``` + +The application operator can then run: + +```bash +composer scripture:doctor +composer scripture:setup +``` + +These aliases work because they belong to the root application's +`composer.json`; scripts declared by `getbible/scripture` are not inherited by +the consuming project. + +A Composer plugin is also unsuitable for native bootstrap. Plugins require +explicit approval by the root project, may be disabled, and run with the full +privileges of the Composer user. They cannot safely assume a compiler, operating +system package manager, administrator access, or permission to change the active +PHP configuration. Composer documents that security boundary in +[installing untrusted packages safely](https://getcomposer.org/doc/faqs/how-to-install-untrusted-packages-safely.md). + +PIE is the dedicated extension installer. Its supported installation methods +and container usage are documented by the +[PHP Installer for Extensions](https://php.github.io/pie/). + +## Application-owned bootstrap + +A product that is installed from source can still present one friendly setup +action. Its installer should coordinate the independent tools in this order: + +1. check `php --ri getbiblesword`; +2. if the extension is missing, explain the native change and ask the operator + for confirmation; +3. after confirmation, invoke + `pie install 'getbible/sword:^0.1.1'` for the selected PHP runtime; +4. start a new PHP process and verify `php --ri getbiblesword`; +5. run `composer install` or `composer require getbible/scripture`; +6. invoke `scripture:setup` and then `scripture:doctor`. + +The new PHP process is important: an extension enabled while Composer is +already running cannot be loaded into that existing process. The installer is +part of the root product, so it can express the product's supported operating +systems, privilege policy, module licenses, and rollback behavior. A reusable +Composer dependency cannot make those decisions for every consuming +application. + +For desktop products, operating-system packages, containers, and managed +hosting, perform these steps while building the distributable runtime. The +recipient then launches the product without PIE, Composer, a compiler, or +low-level commands. For a developer checkout, the three commands at the top of +this page are the shortest supported path. + +## Interactive application setup + +Once Composer has installed the library, inspect the runtime: + +```bash +vendor/bin/getbible-scripture scripture:doctor +``` + +Create an application-owned configuration interactively: + +```bash +vendor/bin/getbible-scripture scripture:setup +``` + +Setup asks for: + +- an explicit configuration-file path; +- the installed SWORD module root; +- the writable Scripture cache root; +- the refresh interval and lock timeout; +- installed translation identifiers; +- automatic snapshot refresh policy; and +- whether configured translations should be warmed immediately. + +The configuration path can instead be supplied with `--config` or +`GETBIBLE_SCRIPTURE_CONFIG_PATH`. There is no implicit configuration-file +location. Setup validates every value, refuses a symlink target, writes through +an atomic same-directory replacement, and restricts a new configuration file to +mode `0600`. + +For a reproducible non-interactive deployment: + +```bash +vendor/bin/getbible-scripture scripture:setup \ + --config=/etc/getbible/scripture.json \ + --module-path=/var/lib/getbible/sword \ + --cache-path=/var/cache/getbible/scripture \ + --refresh-interval=P1M \ + --lock-timeout=30 \ + --module=KJV \ + --auto-refresh \ + --json + +vendor/bin/getbible-scripture scripture:doctor \ + --config=/etc/getbible/scripture.json \ + --json +``` + +Use `--no-auto-refresh` to disable query-triggered snapshot rotation and +`--no-warm` to save valid settings without warming installed translations. +Non-interactive setup fails when no explicit configuration path is available. + +Setup never invokes PIE, Composer, a system package manager, privilege +escalation, a subprocess, or a network request. It configures and warms modules +that are already installed. Doctor is read-only. + +## Programmatic initialization + +Applications can construct the same services directly: + +```php + '/var/lib/getbible/sword', + 'cache_path' => '/var/cache/getbible/scripture', + 'refresh_interval' => 'P1M', + 'lock_timeout' => 30, + 'modules' => ['KJV'], + 'auto_refresh' => true, + 'provisioning_enabled' => false, + 'install_all' => false, +]); + +$container = ContainerFactory::create($configuration); + +/** @var ScriptureInterface $scripture */ +$scripture = $container->get(ScriptureInterface::class); +$result = $scripture->initialize(['KJV']); + +if (!$result->succeeded()) { + throw new RuntimeException( + json_encode( + $result->toArray(), + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, + ), + ); +} +``` + +`initialize()` validates and warms installed modules. With the released native +ABI, it does not install a missing module. Applications that inject a mutating +provisioner must explicitly enable provisioning and enforce the security and +licensing contract described in [module provisioning](provisioning.md). + +## Verification + +After setup: + +```bash +vendor/bin/getbible-scripture scripture:doctor --json +vendor/bin/getbible-scripture scripture:status +``` + +`scripture:doctor` returns non-zero when configuration parsing or native +compatibility is not ready. `scripture:status` additionally reports durable +maintenance health; a normal warmed setup also exercises module and cache +access. diff --git a/docs/operations.md b/docs/operations.md index 7212241..bfb6a10 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -39,6 +39,8 @@ Composer exposes the Joomla Console application as `vendor/bin/getbible-scripture`. ```bash +vendor/bin/getbible-scripture scripture:doctor --json +vendor/bin/getbible-scripture scripture:setup vendor/bin/getbible-scripture scripture:status vendor/bin/getbible-scripture scripture:initialize --module=KJV --module=WEB vendor/bin/getbible-scripture scripture:initialize --all @@ -46,10 +48,17 @@ vendor/bin/getbible-scripture scripture:refresh --module=KJV vendor/bin/getbible-scripture scripture:refresh --if-due ``` -Every command writes deterministic JSON. Initialization and refresh return exit -code `0` only when the complete result succeeds, and `1` for partial or complete -failure. Bootstrap and uncaught application failures use a non-zero Joomla -Console exit code. +Doctor is read-only. Setup saves validated application configuration and can +warm already-installed translations; it never installs the native extension or +downloads modules. Both accept `--config=/absolute/path.json`. Setup also uses +`GETBIBLE_SCRIPTURE_CONFIG_PATH` when the option is absent and prompts for a +path only in an interactive terminal. + +Commands emit deterministic JSON with `--json` or non-interactive input. +Interactive setup prints a concise summary followed by the same structured +result. Initialization and refresh return exit code `0` only when the complete +result succeeds, and `1` for partial or complete failure. Bootstrap, readiness, +and uncaught application failures use a non-zero Joomla Console exit code. `--all` remains an explicit operation. It also requires `GETBIBLE_SCRIPTURE_PROVISIONING_ENABLED=true` and a native backend that @@ -74,6 +83,17 @@ The PHP-FPM and scheduler users must share read access to the SWORD root and read/write access to the cache root. Run maintenance as the same operating system identity as the application whenever possible. +For file-based configuration, set: + +```dotenv +GETBIBLE_SCRIPTURE_CONFIG_PATH=/etc/getbible/scripture.json +``` + +The setup command writes only allowlisted settings through an atomic +same-directory replacement, refuses a symlink target, and gives a new file mode +`0600`. Environment values remain available for deployments that manage +configuration outside the application. + ## Cron The command contains its own bounded interprocess lock, so overlapping cron @@ -135,7 +155,7 @@ sudo systemctl enable --now getbible-scripture.timer systemctl list-timers getbible-scripture.timer ``` -If the future native provisioner writes to the SWORD root, change its systemd +If an injected native provisioner writes to the SWORD root, change its systemd path allowance from `ReadOnlyPaths` to the narrow required `ReadWritePaths`. ## Joomla Scheduled Tasks @@ -160,10 +180,13 @@ the CMS task can run daily without forcing a monthly refresh on every run. Use: ```bash +vendor/bin/getbible-scripture scripture:doctor --json vendor/bin/getbible-scripture scripture:status ``` -The JSON includes: +Doctor verifies configuration parsing, native extension compatibility, module +root access, cache access, installed modules, and warm-up readiness without +changing state. Status reports: - whether maintenance is due; - the next due time; diff --git a/docs/provisioning.md b/docs/provisioning.md index 96cc3cb..34b0503 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -33,13 +33,13 @@ status. Partial failure is therefore never hidden in a single boolean. The library takes a bounded exclusive application lock around provisioning. Native extraction takes the matching shared lock. This prevents cooperating -Scripture processes from reading the module root during mutation. The future +Scripture processes from reading the module root during mutation. An injected native backend must still provide repository validation, staged installation, atomic activation, and rollback guarantees. -## Required production workflow +## Provisioner security contract -The planned native provisioner must: +Every mutating provisioner must: 1. select an explicit repository; 2. enforce HTTPS/TLS policy; @@ -60,26 +60,11 @@ All-module installation must remain an explicit operation because translations have independent licenses and can consume substantial disk and network resources. -## Proposed additive native API - -Provisioning should be added without changing ABI v1 extraction symbols: - -```text -gbs_list_remote_modules_v2 -gbs_sync_repository_v2 -gbs_install_module_v2 -gbs_update_module_v2 -gbs_remove_module_v2 -``` - -The PHP extension should expose a separate installer/manager object rather than -adding network side effects to `GetBible\Sword\Engine`. - ## Public lifecycle ```php -$scripture->initialize(); // explicit install of configured Bible modules -$scripture->refresh(); // explicit remote sync + atomic update +$scripture->initialize(); // Provision when supported, then warm snapshots. +$scripture->refresh(); // Refresh sources when supported, then rebuild. $scripture->refreshIfDue(); ``` diff --git a/docs/releasing.md b/docs/releasing.md index 67c925b..fb76132 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,29 +1,69 @@ # Releasing -## Before `0.1.0` +## One-time repository setup -1. Complete the Phase 1 release gates in the roadmap. -2. Install `getbible/sword` through PIE in the integration job. -3. Verify the package name `getbible/scripture` on Packagist. -4. Configure Packagist's GitHub hook. -5. Confirm the repository ruleset protects `main` and release tags. +Before publishing any tag: + +1. register `https://github.com/getbible/scripture` as + `getbible/scripture` on Packagist; +2. enable Packagist's GitHub synchronization hook; +3. protect `main` and `v*.*.*` tags with repository rulesets; +4. require every CI job, including the released-extension native integration; +5. allow only the reviewed release workflow or release App to create protected + tags; and +6. enable private vulnerability reporting. + +Packagist reads package versions from Git tags. It does not need a package +upload or a publishing credential in the workflow. ## Release preparation 1. Update `VERSION`. -2. Move applicable changelog entries from `Unreleased` into the version. -3. Run: +2. Move applicable changelog entries from `Unreleased` into a dated + `## [VERSION] - YYYY-MM-DD` section. +3. Confirm `composer.json` declares the exact tested PHP, Joomla Framework, and + native-extension compatibility. +4. Run: ```bash composer check + composer audit ``` -4. Verify the installed-module integration workflow and retained evidence. -5. Merge through a maintainer-reviewed pull request. -6. Create an annotated `vVERSION` tag. +5. Review the latest coverage inventory. Every public class and method must + have intentional test evidence; exclusions require a documented reason. +6. Review the retained deterministic native integration evidence for real + module compilation, two verse lookups, metadata, initialization, and + maintenance status. This required check must not depend on a live module + mirror. +7. Verify the lowest-dependency and clean-distribution jobs. +8. Verify `pie install 'getbible/sword:^0.1.1'` resolves a published native + source package for a clean supported PHP installation. +9. Merge through a maintainer-reviewed pull request and wait for required + checks on `main`. + +Changing `VERSION` does not publish the package. + +## Automated release + +Run the **Release** workflow from the default branch and enter the exact version +already present in `VERSION` and `CHANGELOG.md`. The workflow: + +1. verifies stable semantic versioning and changelog alignment; +2. confirms the release commit is contained in the default branch; +3. runs Composer validation, tests, coding standards, static analysis, and the + dependency security audit; +4. builds and installs a clean Composer archive; +5. creates and pushes the annotated `vVERSION` tag; +6. creates the GitHub release; and +7. attaches the package archive and `SHA256SUMS`. + +A maintainer-created `v*.*.*` tag enters the same validation and publication +path. The tag must match `VERSION` exactly and point to a commit contained in +the default branch. -Packagist consumes the Composer package from the Git tag. The native extension -remains a separate PIE installation. +The release workflow does not register Packagist or alter its settings. +Packagist's configured GitHub hook consumes the validated tag. ## Compatibility declarations @@ -37,3 +77,22 @@ A release must state: - SWORD engine version. Do not infer contract compatibility from the package tag alone. + +## Post-release verification + +After Packagist has synchronized: + +```bash +composer clear-cache +composer show getbible/scripture --all +``` + +In a clean supported runtime with the native extension already installed: + +```bash +composer require getbible/scripture +vendor/bin/getbible-scripture scripture:doctor --json +``` + +Confirm that the resolved package tag, extension version, ABI, contract, +installed modules, and health output match the release record. diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 0f0835d..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,61 +0,0 @@ -# Roadmap - -## Phase 0 — foundation - -- GPL-compatible package identity. -- Composer and Joomla Framework dependencies. -- DI composition root. -- Configuration, exceptions, contracts, events, CI, and documentation. - -## Phase 1 — installed-module MVP (`0.1.0`) - -- Native engine adapter. -- Independent streaming v1 validator. -- Atomic indexed generations. -- Lazy Translation, Book, Chapter, Verse, range, metadata, annotation, and - attribute APIs. -- Monthly configurable snapshot rotation. -- Deterministic unit fixtures and KJV integration evidence. -- Packagist registration and first release. - -## Phase 2 — production provisioning boundary (`0.2.0`) - -- Explicit provisioning capability discovery. -- Selected/all-translation installation, refresh, and removal contracts. -- Per-module operation results and lifecycle events. -- Bounded shared/exclusive application locking around native readers and writers. -- Atomic catalog and snapshot invalidation after mutation. -- Exact ABI v1 unavailable adapter instead of unsafe archive downloading. - -The matching additive getBibleSword provisioning ABI and `getbible/sword` -installer object are external integration gates. Once released, their adapter -can replace `AbiV1ModuleProvisioner` without changing the public Scripture API. - -## Phase 3 — automated maintenance (`0.3.0`) - -- `initialize()`, `refresh()`, and `refreshIfDue()` over the native provisioner. -- Durable last-attempt, success, failure, and interval state. -- Non-overlapping maintenance runs with per-module failure isolation. -- Joomla Console initialization, refresh, and status commands. -- Joomla Scheduled Tasks callable integration. -- Systemd and cron deployment examples. -- Deterministic JSON results, exit codes, and lifecycle events. - -## Phase 4 — production hardening and `1.0.0` - -- Bounded APCu/LRU adapters and benchmarks. -- Reader-aware generation cleanup. -- Corruption and crash recovery. -- Concurrency, fuzz, and hostile-module tests. -- Multilingual and alternate-versification corpus. -- Stable public API and schema review. - -## Release gates - -The first stable release requires: - -- the upstream v1 schema classification review to be accepted; -- no failed contract, static-analysis, style, or unit checks; -- real KJV Revelation 1 and 22 parity; -- at least one full installed translation query test; and -- maintainer approval of module licensing behavior. diff --git a/llms.txt b/llms.txt index 0fbfe43..bc43ea3 100644 --- a/llms.txt +++ b/llms.txt @@ -17,8 +17,10 @@ Native dependency: - NDJSON contract: getbiblesword.ndjson/v1 - SWORD engine: 1.9.0 -Start with README.md and docs/architecture.md. The normative protocol source is -https://github.com/getbible/getbiblesword/blob/main/docs/contract-v1.md. +Start with README.md, docs/installation.md, and docs/architecture.md. Product +distributors should also read docs/distribution.md. The normative protocol +source is: +https://github.com/getbible/getbiblesword/blob/main/docs/contract-v1.md Important rules: - decoded Base64 bytes are authoritative; @@ -28,7 +30,17 @@ Important rules: - introductions are not verse zero; - raw annotation markup is untrusted and lexically segmented, not interpreted; - ABI v1 cannot install or update modules; -- constructors must not perform network operations. +- Composer verifies ext-getbiblesword but cannot install it; +- scripture:setup never invokes PIE, Composer, or module downloads; +- constructors must not perform network operations; - scheduled work must enter through MaintenanceServiceInterface; - only a completely successful maintenance run advances last_success_at; - remote module mutation requires both runtime policy and backend capability. + +Setup commands: +- vendor/bin/getbible-scripture scripture:doctor --json +- vendor/bin/getbible-scripture scripture:setup + +Programmatic bootstrap: +Configuration::fromEnvironment -> ContainerFactory::create -> +ScriptureInterface::initialize -> Translation/Book/Chapter/Verse. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7878707..cde12bd 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ 4) { + fwrite( + STDERR, + "Usage: php scripts/coverage-inventory.php CLOVER_XML [MIN_STATEMENT_PERCENT] [MIN_METHOD_PERCENT]\n", + ); + exit(2); +} + +$reportPath = $argv[1]; +$minimumStatements = percentage($argv[2] ?? '0', 'statement'); +$minimumMethods = percentage($argv[3] ?? '0', 'method'); + +if (!is_file($reportPath) || !is_readable($reportPath)) { + fwrite(STDERR, sprintf("Coverage report is not readable: %s\n", $reportPath)); + exit(2); +} + +libxml_use_internal_errors(true); +$report = simplexml_load_file($reportPath); + +if (!$report instanceof SimpleXMLElement) { + $errors = array_map( + static fn (LibXMLError $error): string => trim($error->message), + libxml_get_errors(), + ); + fwrite(STDERR, "Coverage report is invalid XML.\n"); + + foreach ($errors as $error) { + fwrite(STDERR, sprintf("- %s\n", $error)); + } + + exit(2); +} + +$projectMetrics = $report->xpath('/coverage/project/metrics'); +$fileNodes = $report->xpath('/coverage/project/package/file | /coverage/project/file'); + +if ($projectMetrics === false || count($projectMetrics) !== 1 || $fileNodes === false) { + fwrite(STDERR, "Coverage report does not contain the expected Clover project metrics.\n"); + exit(2); +} + +$metrics = $projectMetrics[0]->attributes(); + +if ($metrics === null) { + fwrite(STDERR, "Coverage report project metrics are missing.\n"); + exit(2); +} + +$statements = integerMetric($metrics, 'statements'); +$coveredStatements = integerMetric($metrics, 'coveredstatements'); +$methods = integerMetric($metrics, 'methods'); +$coveredMethods = integerMetric($metrics, 'coveredmethods'); +$statementPercent = ratio($coveredStatements, $statements); +$methodPercent = ratio($coveredMethods, $methods); +$files = []; +$nonPrivateMethods = 0; +$calledNonPrivateMethods = 0; +$uncalledNonPrivateMethods = []; + +foreach ($fileNodes as $fileNode) { + $attributes = $fileNode->attributes(); + $fileMetrics = $fileNode->metrics->attributes(); + + if ($attributes === null || $fileMetrics === null) { + continue; + } + + $name = (string) ($attributes['name'] ?? ''); + + if ($name === '') { + continue; + } + + $fileStatements = integerMetric($fileMetrics, 'statements'); + $fileCoveredStatements = integerMetric($fileMetrics, 'coveredstatements'); + $fileUncalledMethods = []; + + foreach ($fileNode->line as $lineNode) { + $line = $lineNode->attributes(); + + if ( + $line === null + || (string) ($line['type'] ?? '') !== 'method' + || (string) ($line['visibility'] ?? '') === 'private' + ) { + continue; + } + + $nonPrivateMethods++; + + if (integerAttribute($line, 'count') > 0) { + $calledNonPrivateMethods++; + continue; + } + + $method = [ + 'path' => relativePath($name), + 'line' => integerAttribute($line, 'num'), + 'method' => (string) ($line['name'] ?? ''), + 'visibility' => (string) ($line['visibility'] ?? ''), + ]; + $fileUncalledMethods[] = $method; + $uncalledNonPrivateMethods[] = $method; + } + + $files[] = [ + 'path' => relativePath($name), + 'statements' => $fileStatements, + 'covered_statements' => $fileCoveredStatements, + 'statement_percent' => ratio($fileCoveredStatements, $fileStatements), + 'methods' => integerMetric($fileMetrics, 'methods'), + 'covered_methods' => integerMetric($fileMetrics, 'coveredmethods'), + 'uncalled_non_private_methods' => $fileUncalledMethods, + ]; +} + +usort( + $files, + static fn (array $left, array $right): int => $left['path'] <=> $right['path'], +); + +$inventory = [ + 'generated_at' => gmdate(DATE_ATOM), + 'totals' => [ + 'files' => count($files), + 'statements' => $statements, + 'covered_statements' => $coveredStatements, + 'statement_percent' => $statementPercent, + 'methods' => $methods, + 'covered_methods' => $coveredMethods, + 'method_percent' => $methodPercent, + 'non_private_methods' => $nonPrivateMethods, + 'called_non_private_methods' => $calledNonPrivateMethods, + 'uncalled_non_private_methods' => count($uncalledNonPrivateMethods), + ], + 'minimums' => [ + 'statement_percent' => $minimumStatements, + 'method_percent' => $minimumMethods, + ], + 'uncalled_non_private_methods' => $uncalledNonPrivateMethods, + 'files' => $files, +]; + +$buildDirectory = dirname($reportPath); + +if (!is_dir($buildDirectory) && !mkdir($buildDirectory, 0775, true) && !is_dir($buildDirectory)) { + fwrite(STDERR, sprintf("Unable to create coverage output directory: %s\n", $buildDirectory)); + exit(2); +} + +$json = json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +$jsonPath = $buildDirectory . '/coverage-inventory.json'; + +if (file_put_contents($jsonPath, $json . PHP_EOL, LOCK_EX) === false) { + fwrite(STDERR, sprintf("Unable to write coverage inventory: %s\n", $jsonPath)); + exit(2); +} + +$summary = sprintf( + "Coverage inventory: %d files, %.2f%% statements (%d/%d), " + . "%.2f%% methods (%d/%d), non-private methods invoked %d/%d.\n", + count($files), + $statementPercent, + $coveredStatements, + $statements, + $methodPercent, + $coveredMethods, + $methods, + $calledNonPrivateMethods, + $nonPrivateMethods, +); + +fwrite(STDOUT, $summary); + +$summaryPath = getenv('GITHUB_STEP_SUMMARY'); + +if (is_string($summaryPath) && $summaryPath !== '') { + $markdown = sprintf( + "### Coverage inventory\n\n" + . "| Measure | Covered | Total | Percent |\n" + . "|---|---:|---:|---:|\n" + . "| Statements | %d | %d | %.2f%% |\n" + . "| Methods | %d | %d | %.2f%% |\n" + . "| Non-private methods invoked | %d | %d | %.2f%% |\n" + . "| Files in report | %d | — | — |\n", + $coveredStatements, + $statements, + $statementPercent, + $coveredMethods, + $methods, + $methodPercent, + $calledNonPrivateMethods, + $nonPrivateMethods, + ratio($calledNonPrivateMethods, $nonPrivateMethods), + count($files), + ); + file_put_contents($summaryPath, $markdown, FILE_APPEND | LOCK_EX); +} + +if ($uncalledNonPrivateMethods !== []) { + fwrite(STDERR, "Coverage minimum not met: public or protected methods were never invoked.\n"); + + foreach ($uncalledNonPrivateMethods as $method) { + fwrite( + STDERR, + sprintf( + "- %s:%d %s %s()\n", + $method['path'], + $method['line'], + $method['visibility'], + $method['method'], + ), + ); + } + + exit(1); +} + +if ($statementPercent < $minimumStatements || $methodPercent < $minimumMethods) { + fwrite( + STDERR, + sprintf( + "Coverage minimum not met: statements %.2f%%/%.2f%%, methods %.2f%%/%.2f%%.\n", + $statementPercent, + $minimumStatements, + $methodPercent, + $minimumMethods, + ), + ); + exit(1); +} + +/** + * Reads a non-negative integer from an arbitrary Clover attribute map. + */ +function integerAttribute(SimpleXMLElement $attributes, string $name): int +{ + $value = (string) ($attributes[$name] ?? ''); + + if ($value === '' || !ctype_digit($value)) { + fwrite(STDERR, sprintf("Coverage line attribute is missing or invalid: %s\n", $name)); + exit(2); + } + + return (int) $value; +} + +/** + * Parses and validates a percentage argument. + */ +function percentage(string $value, string $label): float +{ + if (!is_numeric($value)) { + fwrite(STDERR, sprintf("Minimum %s coverage is not numeric: %s\n", $label, $value)); + exit(2); + } + + $percentage = (float) $value; + + if ($percentage < 0.0 || $percentage > 100.0) { + fwrite(STDERR, sprintf("Minimum %s coverage must be between 0 and 100.\n", $label)); + exit(2); + } + + return $percentage; +} + +/** + * Reads a non-negative integer from a Clover metrics element. + */ +function integerMetric(SimpleXMLElement $metrics, string $name): int +{ + $value = (string) ($metrics[$name] ?? ''); + + if ($value === '' || !ctype_digit($value)) { + fwrite(STDERR, sprintf("Coverage metric is missing or invalid: %s\n", $name)); + exit(2); + } + + return (int) $value; +} + +/** + * Calculates a stable percentage for one coverage measure. + */ +function ratio(int $covered, int $total): float +{ + return $total === 0 ? 100.0 : round(($covered / $total) * 100, 2); +} + +/** + * Makes project files readable in generated inventory artifacts. + */ +function relativePath(string $path): string +{ + $projectRoot = dirname(__DIR__) . DIRECTORY_SEPARATOR; + + return str_starts_with($path, $projectRoot) + ? substr($path, strlen($projectRoot)) + : $path; +} diff --git a/scripts/create-native-fixture.sh b/scripts/create-native-fixture.sh new file mode 100755 index 0000000..81934eb --- /dev/null +++ b/scripts/create-native-fixture.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only + +set -euo pipefail +IFS=$'\n\t' + +if (($# != 1)); then + echo "Usage: $0 OUTPUT_SWORD_ROOT" >&2 + exit 2 +fi + +readonly output_root=$1 +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root +readonly data_root="$output_root/modules/texts/rawtext/scripturefixture" +readonly imp_source="$repository_root/tests/Fixtures/Native/verse.imp" +readonly imp2vs='/usr/bin/imp2vs' +readonly expected_source_sha256='7ceaa7330fd234669dfc58c67294639a0a58a5a75c70efb4320a3f7adfc428a5' + +if [[ -e "$output_root" ]]; then + echo "Fixture output already exists: $output_root" >&2 + exit 2 +fi +if [[ ! -x "$imp2vs" ]]; then + echo "The SWORD imp2vs utility is unavailable: $imp2vs" >&2 + exit 2 +fi +echo "$expected_source_sha256 $imp_source" | sha256sum --check --strict + +export LC_ALL=C +export TZ=UTC +umask 022 +mkdir -p -- "$output_root/mods.d" "$data_root" +"$imp2vs" "$imp_source" -o "$data_root" /dev/null + +generated_file="$(find "$data_root" -type f -size +0c -print -quit)" +readonly generated_file + +if [[ -z "$generated_file" ]]; then + echo 'imp2vs did not create any non-empty module data.' >&2 + exit 1 +fi +empty_file="$(find "$data_root" -type f -empty -print -quit)" +readonly empty_file + +if [[ -n "$empty_file" ]]; then + echo "imp2vs created an empty module-data file: $empty_file" >&2 + exit 1 +fi + +printf '%s\n' \ + '[ScriptureFixture]' \ + 'DataPath=./modules/texts/rawtext/scripturefixture/' \ + 'ModDrv=RawText' \ + 'SourceType=OSIS' \ + 'Encoding=UTF-8' \ + 'Lang=en' \ + 'Description=GetBible Scripture native integration fixture' \ + 'DistributionLicense=Public Domain' \ + > "$output_root/mods.d/scripturefixture.conf" diff --git a/scripts/integration-kjv.php b/scripts/integration-kjv.php new file mode 100644 index 0000000..0933aa8 --- /dev/null +++ b/scripts/integration-kjv.php @@ -0,0 +1,128 @@ + $moduleRoot, + 'cache_path' => $cacheRoot, + 'refresh_interval' => 'P1M', + 'auto_refresh' => true, + 'modules' => ['KJV'], + 'provisioning_enabled' => false, + 'install_all' => false, +]); +$container = ContainerFactory::create($configuration); + +/** @var ScriptureInterface $scripture */ +$scripture = $container->get(ScriptureInterface::class); +$initialization = $scripture->initialize(['KJV']); + +if (!$initialization->succeeded()) { + fwrite( + STDERR, + json_encode($initialization->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL, + ); + exit(1); +} + +$translation = $scripture->translation('KJV'); +$revelation = $translation->book('Rev'); +$chapterOne = $revelation->chapter(1)->verses(); +$chapterTwentyTwo = $revelation->chapter(22)->verses(); + +if (count($chapterOne) !== 20 || count($chapterTwentyTwo) !== 21) { + fwrite( + STDERR, + sprintf( + "Unexpected KJV Revelation verse counts: chapter 1=%d, chapter 22=%d.\n", + count($chapterOne), + count($chapterTwentyTwo), + ), + ); + exit(1); +} + +$john316 = $scripture->verse('KJV', 'John', 3, 16); +$johnText = $john316->stripped()?->bytes() ?? $john316->raw()->bytes(); + +if ($johnText === '') { + fwrite(STDERR, "KJV John 3:16 resolved to an empty value.\n"); + exit(1); +} + +$evidence = [ + 'extension_version' => phpversion('getbiblesword'), + 'native_product_version' => Engine::productVersion(), + 'native_abi_version' => Engine::abiVersion(), + 'contract' => Engine::contractIdentifier(), + 'source_archive_sha256' => getenv('KJV_SOURCE_SHA256') ?: null, + 'module' => $translation->moduleName(), + 'classification' => $translation->metadata()->classification(), + 'book_count' => count($translation->books()), + 'revelation' => [ + 'name' => $revelation->name()->requireUtf8(), + 'abbreviation' => $revelation->abbreviation()->requireUtf8(), + 'chapter_1_verse_count' => count($chapterOne), + 'chapter_22_verse_count' => count($chapterTwentyTwo), + ], + 'john_3_16_sha256' => hash('sha256', $johnText), + 'initialization' => $initialization->toArray(), + 'status' => $scripture->maintenanceStatus()->toArray(), +]; +$evidenceDirectory = dirname($evidencePath); + +if ( + !is_dir($evidenceDirectory) + && !mkdir($evidenceDirectory, 0775, true) + && !is_dir($evidenceDirectory) +) { + fwrite(STDERR, sprintf("Unable to create evidence directory: %s\n", $evidenceDirectory)); + exit(1); +} + +$json = json_encode($evidence, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + +if (file_put_contents($evidencePath, $json . PHP_EOL, LOCK_EX) === false) { + fwrite(STDERR, sprintf("Unable to write integration evidence: %s\n", $evidencePath)); + exit(1); +} + +fwrite( + STDOUT, + sprintf( + "Validated KJV through %s: Revelation 1 (%d verses), Revelation 22 (%d verses).\n", + Engine::contractIdentifier(), + count($chapterOne), + count($chapterTwentyTwo), + ), +); diff --git a/scripts/integration-native.php b/scripts/integration-native.php new file mode 100644 index 0000000..bd2e4f1 --- /dev/null +++ b/scripts/integration-native.php @@ -0,0 +1,149 @@ + $moduleRoot, + 'cache_path' => $cacheRoot, + 'refresh_interval' => 'P1M', + 'auto_refresh' => true, + 'modules' => ['ScriptureFixture'], + 'provisioning_enabled' => false, + 'install_all' => false, +]); +$container = ContainerFactory::create($configuration); + +/** @var ScriptureInterface $scripture */ +$scripture = $container->get(ScriptureInterface::class); +$initialization = $scripture->initialize(['ScriptureFixture']); + +if (!$initialization->succeeded()) { + fwrite( + STDERR, + json_encode($initialization->toArray(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL, + ); + exit(1); +} + +$translation = $scripture->translation('ScriptureFixture'); +$genesis = $scripture->verse('ScriptureFixture', 'Gen', 1, 1); +$john = $scripture->verse('ScriptureFixture', 'John', 1, 1); +$genesisText = $genesis->stripped()?->requireUtf8() ?? $genesis->raw()->requireUtf8(); +$johnText = $john->stripped()?->requireUtf8() ?? $john->raw()->requireUtf8(); + +if ( + !str_contains($genesisText, 'In the beginning') + || $johnText !== 'In the beginning was the Word.' +) { + fwrite(STDERR, "The native fixture returned unexpected Scripture text.\n"); + exit(1); +} + +$metadata = $translation->metadata(); +$licenseEntries = $translation->configEntriesNamed('DistributionLicense'); +$license = isset($licenseEntries[0]) + ? $licenseEntries[0]->value()->requireUtf8() + : null; + +if ($metadata->classification() !== 'bible' || $license !== 'Public Domain') { + fwrite(STDERR, "The native fixture returned unexpected module metadata.\n"); + exit(1); +} + +$fixtureSourcePath = dirname(__DIR__) . '/tests/Fixtures/Native/verse.imp'; +$fixtureSourceSha256 = hash_file('sha256', $fixtureSourcePath); +$expectedFixtureSourceSha256 = getenv('FIXTURE_SOURCE_SHA256'); +$fixtureTreeSha256 = getenv('FIXTURE_TREE_SHA256'); +$swordUtilsVersion = getenv('SWORD_UTILS_VERSION'); +$repositoryCommit = getenv('GITHUB_SHA'); + +if ( + !is_string($fixtureSourceSha256) + || $fixtureSourceSha256 !== $expectedFixtureSourceSha256 + || !is_string($fixtureTreeSha256) + || preg_match('/^[a-f0-9]{64}$/D', $fixtureTreeSha256) !== 1 + || !is_string($swordUtilsVersion) + || !str_starts_with($swordUtilsVersion, '1.9.0+dfsg-') + || !is_string($repositoryCommit) + || preg_match('/^[a-f0-9]{40}$/D', $repositoryCommit) !== 1 +) { + fwrite(STDERR, "The native fixture provenance evidence is invalid.\n"); + exit(1); +} + +$evidence = [ + 'extension_version' => phpversion('getbiblesword'), + 'native_product_version' => Engine::productVersion(), + 'native_abi_version' => Engine::abiVersion(), + 'contract' => Engine::contractIdentifier(), + 'fixture_source_sha256' => $fixtureSourceSha256, + 'fixture_tree_sha256' => $fixtureTreeSha256, + 'sword_utils_version' => $swordUtilsVersion, + 'repository_commit' => $repositoryCommit, + 'module' => $translation->moduleName(), + 'classification' => $metadata->classification(), + 'distribution_license' => $license, + 'book_count' => count($translation->books()), + 'genesis_1_1_sha256' => hash('sha256', $genesisText), + 'john_1_1_sha256' => hash('sha256', $johnText), + 'initialization' => $initialization->toArray(), + 'status' => $scripture->maintenanceStatus()->toArray(), +]; +$evidenceDirectory = dirname($evidencePath); + +if ( + !is_dir($evidenceDirectory) + && !mkdir($evidenceDirectory, 0775, true) + && !is_dir($evidenceDirectory) +) { + fwrite(STDERR, sprintf("Unable to create evidence directory: %s\n", $evidenceDirectory)); + exit(1); +} + +$json = json_encode($evidence, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + +if (file_put_contents($evidencePath, $json . PHP_EOL, LOCK_EX) === false) { + fwrite(STDERR, sprintf("Unable to write integration evidence: %s\n", $evidencePath)); + exit(1); +} + +fwrite( + STDOUT, + sprintf( + "Validated %s through %s with two Public Domain verses.\n", + $translation->moduleName(), + Engine::contractIdentifier(), + ), +); diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh new file mode 100755 index 0000000..76c6865 --- /dev/null +++ b/scripts/validate-release.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only + +set -euo pipefail +IFS=$'\n\t' + +if (($# > 1)); then + echo "Usage: $0 [EXPECTED_TAG]" >&2 + exit 2 +fi + +readonly expected_tag=${1:-} +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root +readonly version_file="$repository_root/VERSION" +readonly changelog="$repository_root/CHANGELOG.md" + +if [[ ! -f "$version_file" || ! -f "$changelog" ]]; then + echo "VERSION and CHANGELOG.md are required for a release." >&2 + exit 1 +fi + +version="$(tr -d '\r\n' < "$version_file")" +readonly version +readonly tag="v$version" + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "VERSION is not a stable semantic version: $version" >&2 + exit 1 +fi + +if [[ -n "$expected_tag" && "$expected_tag" != "$tag" ]]; then + echo "Tag $expected_tag does not match VERSION $version." >&2 + exit 1 +fi + +escaped_version=${version//./\\.} + +if ! grep --extended-regexp --quiet \ + "^## \\[$escaped_version\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" \ + "$changelog"; then + echo "CHANGELOG.md has no dated [$version] release section." >&2 + exit 1 +fi + +printf 'Validated release metadata for %s (%s).\n' "$version" "$tag" diff --git a/src/Catalog/ModuleCatalog.php b/src/Catalog/ModuleCatalog.php index fedfffb..acff184 100644 --- a/src/Catalog/ModuleCatalog.php +++ b/src/Catalog/ModuleCatalog.php @@ -10,7 +10,9 @@ use GetBible\Scripture\Domain\TranslationMetadata; use GetBible\Scripture\Exception\ContractException; use GetBible\Scripture\Exception\TranslationNotFoundException; +use GetBible\Scripture\Infrastructure\Lock\ModuleRootLockInterface; use GetBible\Scripture\Infrastructure\Sword\ModuleExtractorInterface; +use GetBible\Scripture\Module\ModuleIdentifier; /** * Process-local catalog backed by a validated native list stream. @@ -19,6 +21,16 @@ */ final class ModuleCatalog implements ModuleCatalogInterface { + /** + * Maximum process-local catalog reuse period in nanoseconds. + * + * A short bounded cache avoids repeatedly listing modules during one + * request while ensuring long-running workers observe external changes. + * + * @since 1.0.0 + */ + private const CACHE_TTL_NANOSECONDS = 5_000_000_000; + /** * Cached installed Bible modules. * @@ -27,17 +39,27 @@ final class ModuleCatalog implements ModuleCatalogInterface */ private ?array $translations = null; + /** + * Monotonic deadline for the process-local catalog. + * + * @var int + * @since 1.0.0 + */ + private int $expiresAt = 0; + /** * Creates the catalog service. * * @param ModuleExtractorInterface $extractor Native stream adapter. * @param ContractV1ValidatorInterface $validator Stream validator. + * @param ModuleRootLockInterface $moduleRootLock SWORD root reader/writer lock. * * @since 0.1.0 */ public function __construct( private ModuleExtractorInterface $extractor, private ContractV1ValidatorInterface $validator, + private ModuleRootLockInterface $moduleRootLock, ) { } @@ -49,7 +71,7 @@ public function __construct( */ public function translations(): array { - if ($this->translations !== null) { + if ($this->translations !== null && hrtime(true) < $this->expiresAt) { return $this->translations; } @@ -60,7 +82,9 @@ public function translations(): array } try { - $written = $this->extractor->streamModules($stream); + $written = $this->moduleRootLock->read( + fn (): int => $this->extractor->streamModules($stream), + ); if (fflush($stream) === false || fseek($stream, 0) !== 0) { throw new \RuntimeException('Unable to rewind the native module-list stream.'); @@ -81,11 +105,45 @@ public function translations(): array throw new ContractException('The native module catalog emitted a non-list stream.'); } - $this->translations = array_values(array_filter( + $translations = array_values(array_filter( $result->modules(), static fn (TranslationMetadata $metadata): bool => $metadata->isBible(), )); + $identifiers = []; + + foreach ($translations as $translation) { + $nativeIdentifier = $translation->name()->bytes(); + + try { + $identifier = ModuleIdentifier::normalize($nativeIdentifier); + } catch (\InvalidArgumentException $exception) { + throw new ContractException( + 'The native catalog contains an unsafe Bible module identifier.', + 0, + $exception, + ); + } + + if ($identifier !== $nativeIdentifier) { + throw new ContractException( + 'The native catalog contains a non-canonical Bible module identifier.', + ); + } + + if (isset($identifiers[$identifier])) { + throw new ContractException(sprintf( + 'The native catalog contains duplicate Bible module "%s".', + $identifier, + )); + } + + $identifiers[$identifier] = true; + } + + $this->translations = $translations; + $this->expiresAt = hrtime(true) + self::CACHE_TTL_NANOSECONDS; + return $this->translations; } @@ -99,6 +157,8 @@ public function translations(): array */ public function translation(string $module): TranslationMetadata { + $module = ModuleIdentifier::normalize($module); + foreach ($this->translations() as $translation) { if ($translation->name()->bytes() === $module) { return $translation; @@ -120,5 +180,6 @@ public function translation(string $module): TranslationMetadata public function clear(): void { $this->translations = null; + $this->expiresAt = 0; } } diff --git a/src/Configuration/Configuration.php b/src/Configuration/Configuration.php index 9184421..f0f537d 100644 --- a/src/Configuration/Configuration.php +++ b/src/Configuration/Configuration.php @@ -6,6 +6,7 @@ namespace GetBible\Scripture\Configuration; +use GetBible\Scripture\Module\ModuleIdentifier; use Joomla\Registry\Registry; /** @@ -136,6 +137,45 @@ public static function fromEnvironment(array $values = []): self ])); } + /** + * Creates configuration with environment values overriding persisted JSON. + * + * This preserves {@see fromEnvironment()} for callers whose explicit values + * must remain authoritative while giving deployment environment variables + * precedence over application-owned persisted settings. + * + * @param array|null> $persisted Persisted settings. + * + * @return self + * @since 1.0.0 + */ + public static function fromPersisted(array $persisted): self + { + return self::fromEnvironment(array_replace($persisted, self::environmentValues())); + } + + /** + * Creates configuration from explicit, environment, and persisted layers. + * + * Precedence is explicit setup values, deployment environment, persisted + * JSON, and defaults. Each layer is merged before normalization so a caller + * can override one setting without replacing unrelated persisted values. + * + * @param array|null> $persisted Persisted settings. + * @param array|null> $explicit Explicit overrides. + * + * @return self + * @since 1.0.0 + */ + public static function fromLayers(array $persisted, array $explicit = []): self + { + return self::fromEnvironment(array_replace( + $persisted, + self::environmentValues(), + $explicit, + )); + } + /** * Returns the explicit SWORD root or null for native resolution. * @@ -345,6 +385,35 @@ private static function environment(string $name): ?string return is_string($value) && trim($value) !== '' ? trim($value) : null; } + /** + * Returns defined per-setting environment overrides. + * + * @return array + * @since 1.0.0 + */ + private static function environmentValues(): array + { + $environment = [ + 'module_path' => self::environment('GETBIBLE_SCRIPTURE_MODULE_PATH'), + 'cache_path' => self::environment('GETBIBLE_SCRIPTURE_CACHE_PATH'), + 'refresh_interval' => self::environment('GETBIBLE_SCRIPTURE_REFRESH_INTERVAL'), + 'auto_refresh' => self::environment('GETBIBLE_SCRIPTURE_AUTO_REFRESH'), + 'lock_timeout' => self::environment('GETBIBLE_SCRIPTURE_LOCK_TIMEOUT'), + 'modules' => self::environment('GETBIBLE_SCRIPTURE_MODULES'), + 'provisioning_enabled' => self::environment('GETBIBLE_SCRIPTURE_PROVISIONING_ENABLED'), + 'install_all' => self::environment('GETBIBLE_SCRIPTURE_INSTALL_ALL'), + ]; + $defined = []; + + foreach ($environment as $key => $value) { + if ($value !== null) { + $defined[$key] = $value; + } + } + + return $defined; + } + /** * Parses a strict boolean configuration value. * @@ -451,13 +520,7 @@ private static function moduleList(bool|int|string|array|null $value): array throw new \InvalidArgumentException('Every configured module identifier must be a string.'); } - $module = trim($module); - - if ($module === '' || str_contains($module, "\0")) { - throw new \InvalidArgumentException( - 'Configured module identifiers must be non-empty and contain no NUL bytes.', - ); - } + $module = ModuleIdentifier::normalize($module); $normalized[$module] = $module; } diff --git a/src/Configuration/ConfigurationPath.php b/src/Configuration/ConfigurationPath.php new file mode 100644 index 0000000..ae0cd32 --- /dev/null +++ b/src/Configuration/ConfigurationPath.php @@ -0,0 +1,73 @@ +|null> + * @since 1.0.0 + */ + public function load(): array; + + /** + * Atomically persists one immutable validated configuration. + * + * @param Configuration $configuration Validated configuration. + * + * @return void + * @since 1.0.0 + */ + public function save(Configuration $configuration): void; +} diff --git a/src/Configuration/JsonConfigurationRepository.php b/src/Configuration/JsonConfigurationRepository.php new file mode 100644 index 0000000..9067fb0 --- /dev/null +++ b/src/Configuration/JsonConfigurationRepository.php @@ -0,0 +1,328 @@ +path; + } + + /** + * Reports whether a durable configuration file currently exists. + * + * @return bool + * @since 1.0.0 + */ + public function exists(): bool + { + return $this->path !== null && is_file($this->path) && !is_link($this->path); + } + + /** + * Loads and verifies the persisted setting map. + * + * @return array|null> + * @since 1.0.0 + */ + public function load(): array + { + if ($this->path === null || !file_exists($this->path)) { + return []; + } + + if (is_link($this->path) || !is_file($this->path) || !is_readable($this->path)) { + throw new \RuntimeException('The Scripture configuration path is not a readable regular file.'); + } + + $json = file_get_contents($this->path); + + if (!is_string($json)) { + throw new \RuntimeException('The Scripture configuration file could not be read.'); + } + + try { + $document = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new \UnexpectedValueException( + 'The Scripture configuration file is not valid JSON.', + 0, + $exception, + ); + } + + if (!is_array($document) || array_is_list($document) || ($document['format'] ?? null) !== self::FORMAT) { + throw new \UnexpectedValueException('The Scripture configuration document format is invalid.'); + } + + $settings = $document['settings'] ?? null; + + if (!is_array($settings) || array_is_list($settings)) { + throw new \UnexpectedValueException('The Scripture configuration settings map is invalid.'); + } + + return $this->validateSettings($settings); + } + + /** + * Atomically persists one immutable validated configuration. + * + * @param Configuration $configuration Validated configuration. + * + * @return void + * @since 1.0.0 + */ + public function save(Configuration $configuration): void + { + $path = $this->path; + + if ($path === null) { + throw new \LogicException( + 'A configuration path must be supplied explicitly or through ' + . ConfigurationPath::ENVIRONMENT_VARIABLE . '.', + ); + } + + if (is_link($path)) { + throw new \RuntimeException('Refusing to replace a symbolic-link configuration path.'); + } + + $directory = dirname($path); + $this->ensureDirectory($directory); + $settings = $this->configurationSettings($configuration); + $payload = json_encode( + [ + 'format' => self::FORMAT, + 'settings' => $settings, + ], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ) . "\n"; + $temporary = tempnam($directory, '.scripture-config-'); + + if (!is_string($temporary)) { + throw new \RuntimeException('A temporary Scripture configuration file could not be created.'); + } + + try { + if (!chmod($temporary, 0600)) { + throw new \RuntimeException('Restrictive permissions could not be applied to staged configuration.'); + } + + $stream = fopen($temporary, 'wb'); + + if ($stream === false) { + throw new \RuntimeException('The staged Scripture configuration could not be opened.'); + } + + try { + $written = fwrite($stream, $payload); + + if ($written !== strlen($payload) || !fflush($stream)) { + throw new \RuntimeException('The staged Scripture configuration could not be written completely.'); + } + + if (function_exists('fsync') && !fsync($stream)) { + throw new \RuntimeException('The staged Scripture configuration could not be synchronized.'); + } + } finally { + fclose($stream); + } + + if (!rename($temporary, $path)) { + throw new \RuntimeException('The Scripture configuration could not be activated atomically.'); + } + + if (!chmod($path, 0600)) { + throw new \RuntimeException('Restrictive permissions could not be applied to configuration.'); + } + } finally { + if (is_file($temporary)) { + unlink($temporary); + } + } + } + + /** + * Creates the configuration parent with restrictive permissions. + * + * @param string $directory Parent directory. + * + * @return void + * @since 1.0.0 + */ + private function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new \RuntimeException('The Scripture configuration directory could not be created.'); + } + + if (is_link($directory) || !is_dir($directory) || !is_writable($directory)) { + throw new \RuntimeException('The Scripture configuration directory is not a writable regular directory.'); + } + } + + /** + * Converts validated configuration into an allowlisted scalar map. + * + * @param Configuration $configuration Validated immutable configuration. + * + * @return array|null> + * @since 1.0.0 + */ + private function configurationSettings(Configuration $configuration): array + { + return [ + 'module_path' => $configuration->modulePath(), + 'cache_path' => $configuration->cachePath(), + 'refresh_interval' => $configuration->refreshIntervalSpec(), + 'auto_refresh' => $configuration->autoRefresh(), + 'lock_timeout' => $configuration->lockTimeout(), + 'modules' => $configuration->modules(), + 'provisioning_enabled' => $configuration->provisioningEnabled(), + 'install_all' => $configuration->installAll(), + ]; + } + + /** + * Runtime-verifies the persisted setting map and rejects unknown keys. + * + * @param array $settings Untrusted decoded settings. + * + * @return array|null> + * @since 1.0.0 + */ + private function validateSettings(array $settings): array + { + /** @var array|null> $validated */ + $validated = []; + + foreach ($settings as $key => $value) { + if (!is_string($key)) { + throw new \UnexpectedValueException('The Scripture configuration contains an unknown setting.'); + } + + switch ($key) { + case 'modules': + if (!is_array($value) || !array_is_list($value)) { + throw new \UnexpectedValueException( + 'The persisted Scripture setting "modules" must be a list of strings.', + ); + } + + $items = []; + + foreach ($value as $item) { + if (!is_string($item)) { + throw new \UnexpectedValueException( + 'The persisted Scripture setting "modules" must contain only strings.', + ); + } + + $items[] = $item; + } + + $validated[$key] = $items; + break; + + case 'module_path': + if ($value !== null && !is_string($value)) { + throw new \UnexpectedValueException( + 'The persisted Scripture setting "module_path" must be a string or null.', + ); + } + + $validated[$key] = $value; + break; + + case 'cache_path': + case 'refresh_interval': + if (!is_string($value)) { + throw new \UnexpectedValueException(sprintf( + 'The persisted Scripture setting "%s" must be a string.', + $key, + )); + } + + $validated[$key] = $value; + break; + + case 'auto_refresh': + case 'provisioning_enabled': + case 'install_all': + if (!is_bool($value)) { + throw new \UnexpectedValueException(sprintf( + 'The persisted Scripture setting "%s" must be a boolean.', + $key, + )); + } + + $validated[$key] = $value; + break; + + case 'lock_timeout': + if (!is_int($value)) { + throw new \UnexpectedValueException( + 'The persisted Scripture setting "lock_timeout" must be an integer.', + ); + } + + $validated[$key] = $value; + break; + + default: + throw new \UnexpectedValueException( + 'The Scripture configuration contains an unknown setting.', + ); + } + } + + try { + Configuration::fromEnvironment($validated); + } catch (\InvalidArgumentException $exception) { + throw new \UnexpectedValueException( + 'The persisted Scripture settings are invalid: ' . $exception->getMessage(), + 0, + $exception, + ); + } + + return $validated; + } +} diff --git a/src/Configuration/JsonConfigurationRepositoryFactory.php b/src/Configuration/JsonConfigurationRepositoryFactory.php new file mode 100644 index 0000000..694f28c --- /dev/null +++ b/src/Configuration/JsonConfigurationRepositoryFactory.php @@ -0,0 +1,28 @@ +setName('GetBible Scripture'); $application->setVersion(self::version()); $application->setDispatcher(ContainerService::get($container, DispatcherInterface::class)); - $maintenance = ContainerService::get($container, MaintenanceServiceInterface::class); + $setup = ContainerService::get($container, SetupServiceInterface::class); - $application->addCommand(new InitializeCommand($maintenance)); - $application->addCommand(new RefreshCommand($maintenance)); - $application->addCommand(new StatusCommand($maintenance)); + $application->addCommand(new DoctorCommand($setup)); + $application->addCommand(new SetupCommand($setup)); + + try { + $maintenance = ContainerService::get($container, MaintenanceServiceInterface::class); + $application->addCommand(new InitializeCommand($maintenance)); + $application->addCommand(new RefreshCommand($maintenance)); + $application->addCommand(new StatusCommand($maintenance)); + } catch (NativeEngineException) { + // Doctor and setup remain available to diagnose a missing native runtime. + } return $application; } diff --git a/src/Console/DoctorCommand.php b/src/Console/DoctorCommand.php new file mode 100644 index 0000000..c99220e --- /dev/null +++ b/src/Console/DoctorCommand.php @@ -0,0 +1,154 @@ +setDescription('Inspect Scripture configuration and native runtime prerequisites.'); + $this->addOption( + 'config', + 'c', + InputOption::VALUE_REQUIRED, + 'Absolute JSON configuration path; otherwise use GETBIBLE_SCRIPTURE_CONFIG_PATH.', + ); + $this->addOption( + 'json', + null, + InputOption::VALUE_NONE, + 'Emit deterministic JSON.', + ); + } + + /** + * Executes read-only diagnostics. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * + * @return int + * @since 1.0.0 + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $path = $this->stringOption($input, 'config'); + $runtime = $this->setup->inspect(); + $configuration = null; + $errors = []; + + try { + $resolvedPath = ConfigurationPath::resolve($path); + $current = $this->setup->configuration($resolvedPath); + $configuration = [ + 'module_path' => $current->modulePath(), + 'cache_path' => $current->cachePath(), + 'refresh_interval' => $current->refreshIntervalSpec(), + 'auto_refresh' => $current->autoRefresh(), + 'lock_timeout' => $current->lockTimeout(), + 'modules' => $current->modules(), + 'provisioning_enabled' => $current->provisioningEnabled(), + 'install_all' => $current->installAll(), + ]; + } catch (\Throwable $exception) { + $resolvedPath = $path; + $errors[] = $exception->getMessage(); + } + + $result = [ + 'operation' => 'doctor', + 'succeeded' => $runtime->ready() && $errors === [], + 'configuration_path' => $resolvedPath, + 'configuration' => $configuration, + 'runtime' => $runtime->toArray(), + 'errors' => $errors, + ]; + + if ($input->getOption('json') === true || !$input->isInteractive()) { + $output->writeln($this->json($result)); + } else { + $output->writeln( + $result['succeeded'] + ? 'Scripture is ready.' + : 'Scripture is not ready.', + ); + $output->writeln($this->json($result)); + } + + return $result['succeeded'] ? 0 : 1; + } + + /** + * Returns a trimmed string option. + * + * @param InputInterface $input Bound command input. + * @param string $name Option name. + * + * @return string|null + * @since 1.0.0 + */ + private function stringOption(InputInterface $input, string $name): ?string + { + $value = $input->getOption($name); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + /** + * Encodes deterministic readable JSON. + * + * @param array $value Result map. + * + * @return string + * @since 1.0.0 + */ + private function json(array $value): string + { + return (string) json_encode( + $value, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ); + } +} diff --git a/src/Console/SetupCommand.php b/src/Console/SetupCommand.php new file mode 100644 index 0000000..a95a602 --- /dev/null +++ b/src/Console/SetupCommand.php @@ -0,0 +1,399 @@ +setDescription('Persist Scripture settings and warm installed translations.'); + $this->addOption( + 'config', + 'c', + InputOption::VALUE_REQUIRED, + 'Absolute JSON configuration path; otherwise use GETBIBLE_SCRIPTURE_CONFIG_PATH.', + ); + $this->addOption('module-path', null, InputOption::VALUE_REQUIRED, 'Explicit installed SWORD module root.'); + $this->addOption('cache-path', null, InputOption::VALUE_REQUIRED, 'Writable Scripture snapshot cache root.'); + $this->addOption( + 'refresh-interval', + null, + InputOption::VALUE_REQUIRED, + 'Positive ISO-8601 refresh interval.', + ); + $this->addOption( + 'lock-timeout', + null, + InputOption::VALUE_REQUIRED, + 'Positive lifecycle lock timeout in seconds.', + ); + $this->addOption( + 'module', + 'm', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Installed translation to configure and warm; repeat for multiple modules.', + ); + $this->addOption('auto-refresh', null, InputOption::VALUE_NONE, 'Enable stale snapshot refresh on query.'); + $this->addOption('no-auto-refresh', null, InputOption::VALUE_NONE, 'Disable stale snapshot refresh on query.'); + $this->addOption('no-warm', null, InputOption::VALUE_NONE, 'Persist settings without warming snapshots.'); + $this->addOption('json', null, InputOption::VALUE_NONE, 'Emit deterministic JSON.'); + } + + /** + * Executes setup without mutating system dependencies. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * + * @return int + * @since 1.0.0 + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + try { + $path = $this->configurationPath($input, $output); + $current = $this->setup->configuration($path); + $values = $this->values($input, $output, $current); + $warm = $input->getOption('no-warm') !== true; + + if ($input->isInteractive() && $input->getOption('no-warm') !== true) { + $warm = $this->confirmation( + $input, + $output, + 'Warm the configured installed translations now?', + true, + ); + } + + $result = $this->setup->apply(new SetupRequest($path, $values, $warm)); + $payload = $result->toArray(); + $succeeded = $result->succeeded(); + } catch (\Throwable $exception) { + $payload = [ + 'operation' => 'setup', + 'succeeded' => false, + 'configuration_path' => $this->stringOption($input, 'config'), + 'configuration' => null, + 'runtime' => $this->setup->inspect()->toArray(), + 'warm_requested' => $input->getOption('no-warm') !== true, + 'maintenance' => null, + 'errors' => [$exception->getMessage()], + ]; + $succeeded = false; + } + + if ($input->getOption('json') === true || !$input->isInteractive()) { + $output->writeln($this->json($payload)); + } else { + $output->writeln( + $succeeded + ? 'Scripture setup completed.' + : 'Scripture setup failed.', + ); + $output->writeln($this->json($payload)); + } + + return $succeeded ? 0 : 1; + } + + /** + * Resolves or interactively requests the durable configuration path. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * + * @return string + * @since 1.0.0 + */ + private function configurationPath(InputInterface $input, OutputInterface $output): string + { + $path = ConfigurationPath::resolve($this->stringOption($input, 'config')); + + if ($path === null && $input->isInteractive()) { + $path = ConfigurationPath::resolve($this->question( + $input, + $output, + 'Absolute Scripture configuration file path', + null, + )); + } + + if ($path === null) { + throw new \InvalidArgumentException( + 'Supply --config or set ' . ConfigurationPath::ENVIRONMENT_VARIABLE . '.', + ); + } + + return $path; + } + + /** + * Collects explicit options and optional interactive values. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * @param Configuration $current Current configuration. + * + * @return array|null> + * @since 1.0.0 + */ + private function values( + InputInterface $input, + OutputInterface $output, + Configuration $current, + ): array { + if ($input->getOption('auto-refresh') === true && $input->getOption('no-auto-refresh') === true) { + throw new \InvalidArgumentException('--auto-refresh and --no-auto-refresh cannot be combined.'); + } + + $values = []; + $options = [ + 'module-path' => 'module_path', + 'cache-path' => 'cache_path', + 'refresh-interval' => 'refresh_interval', + 'lock-timeout' => 'lock_timeout', + ]; + + foreach ($options as $option => $setting) { + $value = $this->stringOption($input, $option); + + if ($value !== null) { + $values[$setting] = $value; + } + } + + $modules = ModuleOption::normalize($input->getOption('module')); + + if ($modules !== []) { + $values['modules'] = $modules; + } + + if ($input->getOption('auto-refresh') === true) { + $values['auto_refresh'] = true; + } elseif ($input->getOption('no-auto-refresh') === true) { + $values['auto_refresh'] = false; + } + + if (!$input->isInteractive()) { + return $values; + } + + $values['module_path'] = $this->question( + $input, + $output, + 'Installed SWORD module root', + $this->stringValue($values['module_path'] ?? $current->modulePath()), + ); + $values['cache_path'] = $this->question( + $input, + $output, + 'Scripture cache root', + $this->stringValue($values['cache_path'] ?? $current->cachePath()), + ); + $values['refresh_interval'] = $this->question( + $input, + $output, + 'Snapshot refresh interval', + $this->stringValue($values['refresh_interval'] ?? $current->refreshIntervalSpec()), + ); + $values['lock_timeout'] = $this->question( + $input, + $output, + 'Lifecycle lock timeout in seconds', + $this->stringValue($values['lock_timeout'] ?? (string) $current->lockTimeout()), + ); + $currentModules = $values['modules'] ?? $current->modules(); + $moduleText = $this->question( + $input, + $output, + 'Installed translation identifiers, comma separated', + implode(',', $currentModules), + ); + $values['modules'] = $moduleText === null ? [] : $moduleText; + + if (!isset($values['auto_refresh'])) { + $values['auto_refresh'] = $this->confirmation( + $input, + $output, + 'Refresh stale snapshots on query?', + $current->autoRefresh(), + ); + } + + return $values; + } + + /** + * Asks one interactive text question. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * @param string $label Prompt label. + * @param string|null $default Default answer. + * + * @return string|null + * @since 1.0.0 + */ + private function question( + InputInterface $input, + OutputInterface $output, + string $label, + ?string $default, + ): ?string { + $answer = $this->questionHelper()->ask($input, $output, new Question($label . ': ', $default)); + + if ($answer === null) { + return null; + } + + if (!is_string($answer)) { + throw new \UnexpectedValueException(sprintf('The "%s" answer must be text.', $label)); + } + + return trim($answer); + } + + /** + * Asks one interactive confirmation. + * + * @param InputInterface $input Bound command input. + * @param OutputInterface $output Command output. + * @param string $label Prompt label. + * @param bool $default Default answer. + * + * @return bool + * @since 1.0.0 + */ + private function confirmation( + InputInterface $input, + OutputInterface $output, + string $label, + bool $default, + ): bool { + $answer = $this->questionHelper()->ask( + $input, + $output, + new ConfirmationQuestion($label . ' ', $default), + ); + + return $answer === true; + } + + /** + * Resolves the Symfony question helper used by Joomla Console. + * + * @return QuestionHelper + * @since 1.0.0 + */ + private function questionHelper(): QuestionHelper + { + $helperSet = $this->getHelperSet(); + + if ($helperSet === null) { + throw new \LogicException('The Joomla Console question helper is unavailable.'); + } + + $helper = $helperSet->get('question'); + + if (!$helper instanceof QuestionHelper) { + throw new \LogicException('The Joomla Console question helper is unavailable.'); + } + + return $helper; + } + + /** + * Returns a trimmed string option. + * + * @param InputInterface $input Bound command input. + * @param string $name Option name. + * + * @return string|null + * @since 1.0.0 + */ + private function stringOption(InputInterface $input, string $name): ?string + { + $value = $input->getOption($name); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + /** + * Returns a nullable setting as a text default. + * + * @param mixed $value Candidate value. + * + * @return string|null + * @since 1.0.0 + */ + private function stringValue(mixed $value): ?string + { + return is_string($value) ? $value : null; + } + + /** + * Encodes deterministic readable JSON. + * + * @param array $value Result map. + * + * @return string + * @since 1.0.0 + */ + private function json(array $value): string + { + return (string) json_encode( + $value, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ); + } +} diff --git a/src/Contract/ContractV1Validator.php b/src/Contract/ContractV1Validator.php index 79239a3..c05c627 100644 --- a/src/Contract/ContractV1Validator.php +++ b/src/Contract/ContractV1Validator.php @@ -276,7 +276,10 @@ public function validate(mixed $stream, ?RecordObserverInterface $observer = nul if ($type !== 'footer') { $this->validateEmbeddedByteValues($record, $type); hash_update($streamHash, $line); - $recordCounts[$type] = ($recordCounts[$type] ?? 0) + 1; + + if ($type !== 'diagnostic') { + $recordCounts[$type] = ($recordCounts[$type] ?? 0) + 1; + } } $observer?->onRecord($record, $offset, $length); diff --git a/src/Contract/VerseScope.php b/src/Contract/VerseScope.php index 6bbed0c..743b76b 100644 --- a/src/Contract/VerseScope.php +++ b/src/Contract/VerseScope.php @@ -84,6 +84,16 @@ public static function fromArray(array $scope): self $osis = self::requiredByteValue($scope['osis_reference'] ?? null, 'osis_reference'); $versification = self::requiredByteValue($scope['versification'] ?? null, 'versification'); + self::validateCoordinates( + $scope['testament'], + $scope['book'], + $scope['chapter'], + $scope['verse'], + $intro, + $bookAbbreviation, + $bookName, + ); + return new self( $scope['testament'], $scope['book'], @@ -269,4 +279,53 @@ private static function optionalByteValue(mixed $value, string $field): ?ByteVal return self::requiredByteValue($value, $field); } + + /** + * Requires coordinates to agree with the producer's introduction scope. + * + * @param int $testament Testament position. + * @param int $book Book position. + * @param int $chapter Chapter position. + * @param int $verse Verse position. + * @param string $introductionScope Introduction classification. + * @param ByteValue|null $bookAbbreviation Optional book abbreviation. + * @param ByteValue|null $bookName Optional book name. + * + * @return void + * @since 1.0.0 + */ + private static function validateCoordinates( + int $testament, + int $book, + int $chapter, + int $verse, + string $introductionScope, + ?ByteValue $bookAbbreviation, + ?ByteValue $bookName, + ): void { + $valid = match ($introductionScope) { + 'module' => $testament === 0 && $book === 0 && $chapter === 0 && $verse === 0, + 'testament' => $testament > 0 && $book === 0 && $chapter === 0 && $verse === 0, + 'book' => $testament > 0 && $book > 0 && $chapter === 0 && $verse === 0, + 'chapter' => $testament > 0 && $book > 0 && $chapter > 0 && $verse === 0, + 'verse' => $testament > 0 && $book > 0 && $chapter > 0 && $verse > 0, + default => false, + }; + + if (!$valid) { + throw new ContractException('Verse scope coordinates do not agree with intro_scope.'); + } + + if ( + $book > 0 + && ( + $bookAbbreviation === null + || $bookName === null + || $bookAbbreviation->bytes() === '' + || $bookName->bytes() === '' + ) + ) { + throw new ContractException('A book-scoped verse key requires book name and abbreviation values.'); + } + } } diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 38236d1..a57cfd5 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -7,6 +7,7 @@ namespace GetBible\Scripture\DependencyInjection; use GetBible\Scripture\Configuration\Configuration; +use GetBible\Scripture\Configuration\JsonConfigurationRepositoryFactory; use Joomla\DI\Container; /** @@ -20,16 +21,24 @@ final class ContainerFactory * Creates and configures a new Joomla container. * * @param Configuration|null $configuration Optional explicit configuration. + * @param string|null $configurationPath Optional durable JSON configuration path. * * @return Container * @since 0.1.0 */ - public static function create(?Configuration $configuration = null): Container - { + public static function create( + ?Configuration $configuration = null, + ?string $configurationPath = null, + ): Container { + if ($configuration === null) { + $repository = (new JsonConfigurationRepositoryFactory())->create($configurationPath); + $configuration = Configuration::fromPersisted($repository->load()); + } + $container = new Container(); $container->share( Configuration::class, - $configuration ?? Configuration::fromEnvironment(), + $configuration, true, ); $container->registerServiceProvider(new ScriptureServiceProvider()); diff --git a/src/DependencyInjection/ScriptureServiceProvider.php b/src/DependencyInjection/ScriptureServiceProvider.php index 4aedcb3..444df53 100644 --- a/src/DependencyInjection/ScriptureServiceProvider.php +++ b/src/DependencyInjection/ScriptureServiceProvider.php @@ -11,6 +11,10 @@ use GetBible\Scripture\Clock\ClockInterface; use GetBible\Scripture\Clock\SystemClock; use GetBible\Scripture\Configuration\Configuration; +use GetBible\Scripture\Configuration\ConfigurationPath; +use GetBible\Scripture\Configuration\ConfigurationRepositoryFactoryInterface; +use GetBible\Scripture\Configuration\ConfigurationRepositoryInterface; +use GetBible\Scripture\Configuration\JsonConfigurationRepositoryFactory; use GetBible\Scripture\Contract\ContractV1Validator; use GetBible\Scripture\Contract\ContractV1ValidatorInterface; use GetBible\Scripture\Infrastructure\Sword\ModuleExtractorInterface; @@ -32,6 +36,12 @@ use GetBible\Scripture\Service\ScriptureInterface; use GetBible\Scripture\Snapshot\SnapshotManagerInterface; use GetBible\Scripture\Snapshot\TranslationSnapshotManager; +use GetBible\Scripture\Setup\ApplicationWarmerInterface; +use GetBible\Scripture\Setup\FreshContainerApplicationWarmer; +use GetBible\Scripture\Setup\RuntimePrerequisiteInspector; +use GetBible\Scripture\Setup\RuntimePrerequisiteInspectorInterface; +use GetBible\Scripture\Setup\SetupService; +use GetBible\Scripture\Setup\SetupServiceInterface; use Joomla\DI\Container; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\Dispatcher; @@ -74,6 +84,38 @@ public function register(Container $container) ); } + $container->share( + ConfigurationRepositoryFactoryInterface::class, + static fn (): ConfigurationRepositoryFactoryInterface => new JsonConfigurationRepositoryFactory(), + true, + ); + $container->share( + ConfigurationRepositoryInterface::class, + static fn (Container $container): ConfigurationRepositoryInterface => ContainerService::get( + $container, + ConfigurationRepositoryFactoryInterface::class, + )->create(ConfigurationPath::resolve()), + true, + ); + $container->share( + RuntimePrerequisiteInspectorInterface::class, + static fn (): RuntimePrerequisiteInspectorInterface => new RuntimePrerequisiteInspector(), + true, + ); + $container->share( + ApplicationWarmerInterface::class, + static fn (): ApplicationWarmerInterface => new FreshContainerApplicationWarmer(), + true, + ); + $container->share( + SetupServiceInterface::class, + static fn (Container $container): SetupServiceInterface => new SetupService( + ContainerService::get($container, ConfigurationRepositoryFactoryInterface::class), + ContainerService::get($container, RuntimePrerequisiteInspectorInterface::class), + ContainerService::get($container, ApplicationWarmerInterface::class), + ), + true, + ); $container->share( ModuleExtractorInterface::class, static fn (Container $container): ModuleExtractorInterface => new SwordEngineAdapter( @@ -112,6 +154,7 @@ public function register(Container $container) static fn (Container $container): ModuleCatalogInterface => new ModuleCatalog( ContainerService::get($container, ModuleExtractorInterface::class), ContainerService::get($container, ContractV1ValidatorInterface::class), + ContainerService::get($container, ModuleRootLockInterface::class), ), true, ); @@ -176,5 +219,6 @@ public function register(Container $container) true, ); $container->alias(Scripture::class, ScriptureInterface::class); + $container->alias(SetupService::class, SetupServiceInterface::class); } } diff --git a/src/Domain/Book.php b/src/Domain/Book.php index 77bbe53..43844e3 100644 --- a/src/Domain/Book.php +++ b/src/Domain/Book.php @@ -40,6 +40,10 @@ public function __construct( private SnapshotIndex $snapshot, private string $bookKey, ) { + if ($snapshot->metadata()->name()->bytes() !== $translation->moduleName()) { + throw new \InvalidArgumentException('A Book snapshot must belong to its parent Translation.'); + } + $this->metadata = $snapshot->bookMetadata($bookKey); } diff --git a/src/Domain/Chapter.php b/src/Domain/Chapter.php index 30fa6bb..f42b1b7 100644 --- a/src/Domain/Chapter.php +++ b/src/Domain/Chapter.php @@ -32,6 +32,19 @@ public function __construct( private string $bookKey, private int $number, ) { + if ($number < 1 || !in_array($number, $snapshot->chapterNumbers($bookKey), true)) { + throw new InvalidReferenceException('A Chapter requires a positive indexed chapter number.'); + } + + $metadata = $snapshot->bookMetadata($bookKey); + + if ( + $metadata['testament'] !== $book->testament() + || $metadata['position'] !== $book->position() + || $snapshot->metadata()->name()->bytes() !== $book->translation()->moduleName() + ) { + throw new \InvalidArgumentException('A Chapter snapshot must belong to its parent Book.'); + } } /** diff --git a/src/Domain/Translation.php b/src/Domain/Translation.php index e4ee530..e4cbe19 100644 --- a/src/Domain/Translation.php +++ b/src/Domain/Translation.php @@ -7,6 +7,7 @@ namespace GetBible\Scripture\Domain; use GetBible\Scripture\Exception\ReferenceNotFoundException; +use GetBible\Scripture\Module\ModuleIdentifier; use GetBible\Scripture\Snapshot\SnapshotIndex; /** @@ -28,6 +29,13 @@ public function __construct( private string $moduleName, private SnapshotIndex $snapshot, ) { + $this->moduleName = ModuleIdentifier::normalize($moduleName); + + if ($snapshot->metadata()->name()->bytes() !== $this->moduleName) { + throw new \InvalidArgumentException( + 'A Translation module identifier must match its snapshot metadata.', + ); + } } /** @@ -205,4 +213,15 @@ public function expiresAt(): \DateTimeImmutable { return $this->snapshot->expiresAt(); } + + /** + * Returns the immutable content-addressed snapshot generation identifier. + * + * @return string + * @since 1.0.0 + */ + public function generationId(): string + { + return $this->snapshot->generationId(); + } } diff --git a/src/Event/LifecycleEventDispatcher.php b/src/Event/LifecycleEventDispatcher.php new file mode 100644 index 0000000..d04fd12 --- /dev/null +++ b/src/Event/LifecycleEventDispatcher.php @@ -0,0 +1,56 @@ + $arguments Event arguments. + * + * @return \Throwable|null Listener failure, when one occurred. + * @since 1.0.0 + */ + public static function dispatch( + DispatcherInterface $dispatcher, + string $name, + array $arguments, + ): ?\Throwable { + try { + $dispatcher->dispatch($name, new Event($name, $arguments)); + + return null; + } catch (\Throwable $exception) { + return $exception; + } + } + + /** + * Prevents instantiation of this event-boundary utility. + * + * @since 1.0.0 + */ + private function __construct() + { + } +} diff --git a/src/Infrastructure/Lock/GenerationLease.php b/src/Infrastructure/Lock/GenerationLease.php new file mode 100644 index 0000000..13eb7fc --- /dev/null +++ b/src/Infrastructure/Lock/GenerationLease.php @@ -0,0 +1,148 @@ +handle = $handle; + } + + /** + * Releases the shared reader lease. + * + * @since 1.0.0 + */ + public function __destruct() + { + $this->release(); + } + + /** + * Releases the shared reader lease idempotently. + * + * @return void + * @since 1.0.0 + */ + public function release(): void + { + if (!is_resource($this->handle)) { + return; + } + + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + + /** + * Runs cleanup only when no reader currently leases the generation. + * + * @param string $moduleRoot Controlled per-module cache root. + * @param string $generation Validated generation identifier. + * @param callable(): void $cleanup Generation cleanup callback. + * + * @return bool Whether the exclusive lease was acquired. + * @since 1.0.0 + */ + public static function cleanup( + string $moduleRoot, + string $generation, + callable $cleanup, + ): bool { + $path = self::path($moduleRoot, $generation); + $handle = fopen($path, 'c+b'); + + if (!is_resource($handle)) { + throw new \RuntimeException(sprintf('Unable to open generation cleanup lease "%s".', $path)); + } + + $acquired = false; + + try { + $acquired = flock($handle, LOCK_EX | LOCK_NB); + + if (!$acquired) { + return false; + } + + $cleanup(); + + return true; + } finally { + if ($acquired) { + flock($handle, LOCK_UN); + } + + fclose($handle); + } + } + + /** + * Returns a controlled lease path and creates its directory. + * + * @param string $moduleRoot Controlled per-module cache root. + * @param string $generation Validated generation identifier. + * + * @return string + * @since 1.0.0 + */ + private static function path(string $moduleRoot, string $generation): string + { + if (preg_match('/^[0-9a-f]{64}$/D', $generation) !== 1) { + throw new \InvalidArgumentException('A generation lease requires a SHA-256 identifier.'); + } + + $directory = $moduleRoot . '/leases'; + + if (!Folder::create($directory, 0750)) { + throw new \RuntimeException(sprintf('Unable to create generation lease directory "%s".', $directory)); + } + + return $directory . '/' . $generation . '.lock'; + } +} diff --git a/src/Maintenance/MaintenanceService.php b/src/Maintenance/MaintenanceService.php index 32f90a8..a01eb90 100644 --- a/src/Maintenance/MaintenanceService.php +++ b/src/Maintenance/MaintenanceService.php @@ -10,12 +10,13 @@ use GetBible\Scripture\Clock\ClockInterface; use GetBible\Scripture\Configuration\Configuration; use GetBible\Scripture\Event\EventName; +use GetBible\Scripture\Event\LifecycleEventDispatcher; use GetBible\Scripture\Infrastructure\Lock\MaintenanceLockInterface; +use GetBible\Scripture\Module\ModuleIdentifier; use GetBible\Scripture\Provisioning\ProvisioningCoordinatorInterface; use GetBible\Scripture\Provisioning\ProvisioningResult; use GetBible\Scripture\Snapshot\SnapshotManagerInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Event\Event; /** * Default production maintenance orchestrator with per-module failure isolation. @@ -454,32 +455,35 @@ private function execute(string $operation, callable $callback): MaintenanceResu { $result = $this->lock->run(function () use ($operation, $callback): MaintenanceResult { $startedAt = $this->clock->now(); - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::MAINTENANCE_STARTED, - new Event(EventName::MAINTENANCE_STARTED, [ + [ 'operation' => $operation, 'started_at' => $startedAt, - ]), + ], ); try { $result = $callback($startedAt); - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::MAINTENANCE_COMPLETED, - new Event(EventName::MAINTENANCE_COMPLETED, [ + [ 'operation' => $operation, 'result' => $result, - ]), + ], ); return $result; } catch (\Throwable $exception) { - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::MAINTENANCE_FAILED, - new Event(EventName::MAINTENANCE_FAILED, [ + [ 'operation' => $operation, 'exception' => $exception, - ]), + ], ); throw $exception; @@ -500,12 +504,7 @@ private function installedModules(): array $installed = []; foreach ($this->catalog->translations() as $translation) { - $module = $translation->name()->bytes(); - - if ($module === '' || str_contains($module, "\0")) { - throw new \UnexpectedValueException('The native catalog returned an invalid module identifier.'); - } - + $module = ModuleIdentifier::normalize($translation->name()->bytes()); $installed[$module] = true; } @@ -529,14 +528,7 @@ private function normalizeModules(array $modules): array throw new \InvalidArgumentException('Maintenance module identifiers must be strings.'); } - $module = trim($module); - - if ($module === '' || str_contains($module, "\0")) { - throw new \InvalidArgumentException( - 'Maintenance module identifiers must be non-empty and contain no NUL bytes.', - ); - } - + $module = ModuleIdentifier::normalize($module); $normalized[$module] = $module; } diff --git a/src/Module/ModuleIdentifier.php b/src/Module/ModuleIdentifier.php new file mode 100644 index 0000000..f25ba17 --- /dev/null +++ b/src/Module/ModuleIdentifier.php @@ -0,0 +1,85 @@ + self::MAX_LENGTH + || preg_match('/^[A-Za-z0-9_.+-]+$/D', $identifier) !== 1 + ) { + throw new \InvalidArgumentException(sprintf( + 'Invalid SWORD module identifier "%s".', + self::printable($identifier), + )); + } + + return $identifier; + } + + /** + * Creates a bounded printable diagnostic for an invalid identifier. + * + * @param string $identifier Invalid identifier bytes. + * + * @return string + * @since 1.0.0 + */ + private static function printable(string $identifier): string + { + $printable = preg_replace('/[^\x20-\x7E]/', '?', $identifier); + + if (!is_string($printable)) { + return ''; + } + + return strlen($printable) > self::MAX_LENGTH + ? substr($printable, 0, self::MAX_LENGTH) . '...' + : $printable; + } + + /** + * Prevents instantiation of this validation utility. + * + * @since 1.0.0 + */ + private function __construct() + { + } +} diff --git a/src/Provisioning/ProvisioningCoordinator.php b/src/Provisioning/ProvisioningCoordinator.php index 7d1eb18..ed64c5c 100644 --- a/src/Provisioning/ProvisioningCoordinator.php +++ b/src/Provisioning/ProvisioningCoordinator.php @@ -8,10 +8,11 @@ use GetBible\Scripture\Catalog\ModuleCatalogInterface; use GetBible\Scripture\Event\EventName; +use GetBible\Scripture\Event\LifecycleEventDispatcher; use GetBible\Scripture\Infrastructure\Lock\ModuleRootLockInterface; +use GetBible\Scripture\Module\ModuleIdentifier; use GetBible\Scripture\Snapshot\SnapshotManagerInterface; use Joomla\Event\DispatcherInterface; -use Joomla\Event\Event; /** * Serializes module mutation and invalidates process caches after success. @@ -135,13 +136,14 @@ public function remove(string $module): ProvisioningResult */ private function execute(string $operation, array $modules, callable $callback): ProvisioningResult { - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::PROVISIONING_STARTED, - new Event(EventName::PROVISIONING_STARTED, [ + [ 'operation' => $operation, 'modules' => $modules, 'capabilities' => $this->capabilities(), - ]), + ], ); try { @@ -149,24 +151,26 @@ private function execute(string $operation, array $modules, callable $callback): $this->catalog->clear(); $this->snapshots->clear(); - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::PROVISIONING_COMPLETED, - new Event(EventName::PROVISIONING_COMPLETED, [ + [ 'operation' => $operation, 'modules' => $modules, 'result' => $result, - ]), + ], ); return $result; } catch (\Throwable $exception) { - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::PROVISIONING_FAILED, - new Event(EventName::PROVISIONING_FAILED, [ + [ 'operation' => $operation, 'modules' => $modules, 'exception' => $exception, - ]), + ], ); throw $exception; @@ -191,12 +195,7 @@ private function normalizeModules(array $modules, bool $allowEmpty): array throw new \InvalidArgumentException('Module identifiers must be strings.'); } - $module = trim($module); - - if ($module === '' || str_contains($module, "\0")) { - throw new \InvalidArgumentException('Module identifiers must be non-empty strings without NUL bytes.'); - } - + $module = ModuleIdentifier::normalize($module); $normalized[$module] = $module; } diff --git a/src/Service/Scripture.php b/src/Service/Scripture.php index 1ce8923..dc85743 100644 --- a/src/Service/Scripture.php +++ b/src/Service/Scripture.php @@ -16,6 +16,7 @@ use GetBible\Scripture\Maintenance\MaintenanceResult; use GetBible\Scripture\Maintenance\MaintenanceServiceInterface; use GetBible\Scripture\Maintenance\MaintenanceStatus; +use GetBible\Scripture\Module\ModuleIdentifier; /** * Default dependency-injected implementation of the Bible application API. @@ -24,6 +25,13 @@ */ final class Scripture implements ScriptureInterface { + /** + * Maximum process-local Translation objects retained by the facade. + * + * @since 1.0.0 + */ + private const TRANSLATION_CACHE_LIMIT = 32; + /** * Process-local translation objects. * @@ -134,7 +142,27 @@ public function translations(): array */ public function translation(string $module): Translation { - return $this->translations[$module] ??= new Translation($module, $this->snapshots->get($module)); + $module = ModuleIdentifier::normalize($module); + $snapshot = $this->snapshots->get($module); + $translation = $this->translations[$module] ?? null; + + if ( + $translation === null + || $translation->generationId() !== $snapshot->generationId() + || $translation->activatedAt() != $snapshot->activatedAt() + || $translation->expiresAt() != $snapshot->expiresAt() + ) { + $translation = new Translation($module, $snapshot); + } + + unset($this->translations[$module]); + $this->translations[$module] = $translation; + + if (count($this->translations) > self::TRANSLATION_CACHE_LIMIT) { + array_shift($this->translations); + } + + return $translation; } /** @@ -191,9 +219,15 @@ public function verses( */ public function refreshTranslation(string $module): Translation { + $module = ModuleIdentifier::normalize($module); $translation = new Translation($module, $this->snapshots->refresh($module)); + unset($this->translations[$module]); $this->translations[$module] = $translation; + if (count($this->translations) > self::TRANSLATION_CACHE_LIMIT) { + array_shift($this->translations); + } + return $translation; } diff --git a/src/Setup/ApplicationWarmerInterface.php b/src/Setup/ApplicationWarmerInterface.php new file mode 100644 index 0000000..f3871d3 --- /dev/null +++ b/src/Setup/ApplicationWarmerInterface.php @@ -0,0 +1,29 @@ + $modules Explicit targets, or an empty list for configured targets. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + public function warm(Configuration $configuration, array $modules = []): MaintenanceResult; +} diff --git a/src/Setup/FreshContainerApplicationWarmer.php b/src/Setup/FreshContainerApplicationWarmer.php new file mode 100644 index 0000000..a6527c5 --- /dev/null +++ b/src/Setup/FreshContainerApplicationWarmer.php @@ -0,0 +1,38 @@ + $modules Explicit targets, or an empty list for configured targets. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + public function warm(Configuration $configuration, array $modules = []): MaintenanceResult + { + $container = ContainerFactory::create($configuration); + $maintenance = ContainerService::get($container, MaintenanceServiceInterface::class); + + return $maintenance->initialize($modules, false); + } +} diff --git a/src/Setup/RuntimePrerequisiteInspector.php b/src/Setup/RuntimePrerequisiteInspector.php new file mode 100644 index 0000000..e9df2c2 --- /dev/null +++ b/src/Setup/RuntimePrerequisiteInspector.php @@ -0,0 +1,63 @@ +getMessage(), + ); + } + } +} diff --git a/src/Setup/RuntimePrerequisiteInspectorInterface.php b/src/Setup/RuntimePrerequisiteInspectorInterface.php new file mode 100644 index 0000000..09e5409 --- /dev/null +++ b/src/Setup/RuntimePrerequisiteInspectorInterface.php @@ -0,0 +1,23 @@ +extensionLoaded + && $this->engineClassAvailable + && $this->extensionVersion !== null + && $this->extensionVersion !== '' + && $this->abiVersion === self::EXPECTED_ABI + && $this->contractIdentifier === self::EXPECTED_CONTRACT + && $this->productVersion !== null + && $this->productVersion !== '' + && $this->error === null; + } + + /** + * Reports whether the native extension is loaded. + * + * @return bool + * @since 1.0.0 + */ + public function extensionLoaded(): bool + { + return $this->extensionLoaded; + } + + /** + * Reports whether the native Engine class is available. + * + * @return bool + * @since 1.0.0 + */ + public function engineClassAvailable(): bool + { + return $this->engineClassAvailable; + } + + /** + * Returns the loaded PHP extension version. + * + * @return string|null + * @since 1.0.0 + */ + public function extensionVersion(): ?string + { + return $this->extensionVersion; + } + + /** + * Returns the active native ABI version. + * + * @return int|null + * @since 1.0.0 + */ + public function abiVersion(): ?int + { + return $this->abiVersion; + } + + /** + * Returns the active native contract identifier. + * + * @return string|null + * @since 1.0.0 + */ + public function contractIdentifier(): ?string + { + return $this->contractIdentifier; + } + + /** + * Returns the embedded getBibleSword product version. + * + * @return string|null + * @since 1.0.0 + */ + public function productVersion(): ?string + { + return $this->productVersion; + } + + /** + * Returns the inspection failure. + * + * @return string|null + * @since 1.0.0 + */ + public function error(): ?string + { + return $this->error; + } + + /** + * Returns a deterministic serialization-safe compatibility report. + * + * @return array{ + * ready: bool, + * extension_loaded: bool, + * engine_class_available: bool, + * extension_version: string|null, + * abi: array{expected: int, actual: int|null, compatible: bool}, + * contract: array{expected: string, actual: string|null, compatible: bool}, + * product_version: string|null, + * error: string|null + * } + * @since 1.0.0 + */ + public function toArray(): array + { + return [ + 'ready' => $this->ready(), + 'extension_loaded' => $this->extensionLoaded, + 'engine_class_available' => $this->engineClassAvailable, + 'extension_version' => $this->extensionVersion, + 'abi' => [ + 'expected' => self::EXPECTED_ABI, + 'actual' => $this->abiVersion, + 'compatible' => $this->abiVersion === self::EXPECTED_ABI, + ], + 'contract' => [ + 'expected' => self::EXPECTED_CONTRACT, + 'actual' => $this->contractIdentifier, + 'compatible' => $this->contractIdentifier === self::EXPECTED_CONTRACT, + ], + 'product_version' => $this->productVersion, + 'error' => $this->error, + ]; + } +} diff --git a/src/Setup/SetupRequest.php b/src/Setup/SetupRequest.php new file mode 100644 index 0000000..84844cb --- /dev/null +++ b/src/Setup/SetupRequest.php @@ -0,0 +1,64 @@ +|null> $values Setting overrides. + * @param bool $warm Whether installed configured translations should be warmed. + * + * @since 1.0.0 + */ + public function __construct( + private ?string $configurationPath, + private array $values, + private bool $warm = true, + ) { + } + + /** + * Returns the explicit durable configuration path. + * + * @return string|null + * @since 1.0.0 + */ + public function configurationPath(): ?string + { + return $this->configurationPath; + } + + /** + * Returns setting overrides. + * + * @return array|null> + * @since 1.0.0 + */ + public function values(): array + { + return $this->values; + } + + /** + * Reports whether installed translations should be warmed. + * + * @return bool + * @since 1.0.0 + */ + public function warm(): bool + { + return $this->warm; + } +} diff --git a/src/Setup/SetupResult.php b/src/Setup/SetupResult.php new file mode 100644 index 0000000..16cc112 --- /dev/null +++ b/src/Setup/SetupResult.php @@ -0,0 +1,166 @@ + + * @since 1.0.0 + */ + private array $errors; + + /** + * Creates a complete setup result. + * + * @param string $configurationPath Durable configuration path. + * @param Configuration $configuration Persisted immutable configuration. + * @param RuntimePrerequisiteReport $runtime Native runtime report. + * @param bool $warmRequested Whether warming was requested. + * @param MaintenanceResult|null $maintenance Optional warming result. + * @param list $errors Ordered errors. + * + * @since 1.0.0 + */ + public function __construct( + private string $configurationPath, + private Configuration $configuration, + private RuntimePrerequisiteReport $runtime, + private bool $warmRequested, + private ?MaintenanceResult $maintenance, + array $errors = [], + ) { + $validated = []; + + foreach ($errors as $error) { + if (trim($error) === '') { + throw new \InvalidArgumentException('Setup errors must be non-empty strings.'); + } + + $validated[] = $error; + } + + $this->errors = $validated; + } + + /** + * Reports whether configuration and requested warming succeeded. + * + * @return bool + * @since 1.0.0 + */ + public function succeeded(): bool + { + return $this->errors === [] + && (!$this->warmRequested || ($this->maintenance !== null && $this->maintenance->succeeded())); + } + + /** + * Returns the durable configuration path. + * + * @return string + * @since 1.0.0 + */ + public function configurationPath(): string + { + return $this->configurationPath; + } + + /** + * Returns the persisted immutable configuration. + * + * @return Configuration + * @since 1.0.0 + */ + public function configuration(): Configuration + { + return $this->configuration; + } + + /** + * Returns the native runtime report captured during setup. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + public function runtime(): RuntimePrerequisiteReport + { + return $this->runtime; + } + + /** + * Reports whether warming was requested. + * + * @return bool + * @since 1.0.0 + */ + public function warmRequested(): bool + { + return $this->warmRequested; + } + + /** + * Returns the optional warming outcome. + * + * @return MaintenanceResult|null + * @since 1.0.0 + */ + public function maintenance(): ?MaintenanceResult + { + return $this->maintenance; + } + + /** + * Returns ordered errors. + * + * @return list + * @since 1.0.0 + */ + public function errors(): array + { + return $this->errors; + } + + /** + * Returns a deterministic serialization-safe setup result. + * + * @return array + * @since 1.0.0 + */ + public function toArray(): array + { + return [ + 'operation' => 'setup', + 'succeeded' => $this->succeeded(), + 'configuration_path' => $this->configurationPath, + 'configuration' => [ + 'module_path' => $this->configuration->modulePath(), + 'cache_path' => $this->configuration->cachePath(), + 'refresh_interval' => $this->configuration->refreshIntervalSpec(), + 'auto_refresh' => $this->configuration->autoRefresh(), + 'lock_timeout' => $this->configuration->lockTimeout(), + 'modules' => $this->configuration->modules(), + 'provisioning_enabled' => $this->configuration->provisioningEnabled(), + 'install_all' => $this->configuration->installAll(), + ], + 'runtime' => $this->runtime->toArray(), + 'warm_requested' => $this->warmRequested, + 'maintenance' => $this->maintenance?->toArray(), + 'errors' => $this->errors, + ]; + } +} diff --git a/src/Setup/SetupService.php b/src/Setup/SetupService.php new file mode 100644 index 0000000..1f90787 --- /dev/null +++ b/src/Setup/SetupService.php @@ -0,0 +1,117 @@ +inspector->inspect(); + } + + /** + * Resolves current persisted and environment-backed configuration. + * + * @param string|null $configurationPath Explicit durable path. + * + * @return Configuration + * @since 1.0.0 + */ + public function configuration(?string $configurationPath = null): Configuration + { + return Configuration::fromPersisted( + $this->repositories->create($configurationPath)->load(), + ); + } + + /** + * Validates, atomically persists, and optionally warms configuration. + * + * Persisted settings are applied before warming so a failed warm remains + * reproducible and can be retried with the same configuration. + * + * @param SetupRequest $request Setup request. + * + * @return SetupResult + * @since 1.0.0 + */ + public function apply(SetupRequest $request): SetupResult + { + $repository = $this->repositories->create($request->configurationPath()); + $path = $repository->path(); + + if ($path === null) { + throw new \InvalidArgumentException( + 'Supply an absolute configuration path or set GETBIBLE_SCRIPTURE_CONFIG_PATH.', + ); + } + + $configuration = Configuration::fromLayers( + $repository->load(), + $request->values(), + ); + $repository->save($configuration); + $runtime = $this->inspector->inspect(); + $maintenance = null; + $errors = []; + + if ($request->warm()) { + if (!$runtime->ready()) { + $errors[] = 'The active PHP runtime is not ready for Scripture warming.'; + } else { + try { + $maintenance = $this->warmer->warm($configuration, $configuration->modules()); + } catch (\Throwable $exception) { + $errors[] = 'Scripture warming failed: ' . $exception->getMessage(); + } + } + } + + return new SetupResult( + $path, + $configuration, + $runtime, + $request->warm(), + $maintenance, + $errors, + ); + } +} diff --git a/src/Setup/SetupServiceInterface.php b/src/Setup/SetupServiceInterface.php new file mode 100644 index 0000000..76b5b71 --- /dev/null +++ b/src/Setup/SetupServiceInterface.php @@ -0,0 +1,45 @@ + $index Decoded index. * @param \DateTimeImmutable $activatedAt Activation time. * @param \DateTimeImmutable $expiresAt Freshness deadline. + * @param string $generation Content-addressed generation identifier. + * @param string $indexSha256 Verified serialized index digest. + * @param GenerationLease $lease Active generation reader lease. * * @since 0.1.0 */ @@ -45,7 +56,11 @@ private function __construct( private array $index, private \DateTimeImmutable $activatedAt, private \DateTimeImmutable $expiresAt, + private string $generation, + private string $indexSha256, + GenerationLease $lease, ) { + $this->lease = $lease; } /** @@ -54,6 +69,7 @@ private function __construct( * @param string $generationPath Generation directory. * @param \DateTimeImmutable $activatedAt Activation time. * @param \DateTimeImmutable $expiresAt Freshness deadline. + * @param string|null $expectedIndexSha256 Optional pointer-bound index digest. * * @return self * @since 0.1.0 @@ -62,15 +78,48 @@ public static function open( string $generationPath, \DateTimeImmutable $activatedAt, \DateTimeImmutable $expiresAt, + ?string $expectedIndexSha256 = null, ): self { + $generation = basename($generationPath); + + if ( + preg_match('/^[0-9a-f]{64}$/D', $generation) !== 1 + || !is_dir($generationPath) + || is_link($generationPath) + || $expiresAt <= $activatedAt + || ( + $expectedIndexSha256 !== null + && preg_match('/^[0-9a-f]{64}$/D', $expectedIndexSha256) !== 1 + ) + ) { + throw new ContractException('Snapshot generation identity or activation interval is invalid.'); + } + + $lease = new GenerationLease(dirname($generationPath, 2), $generation); $indexPath = $generationPath . '/index.json'; $modulePath = $generationPath . '/module.ndjson'; - $json = file_get_contents($indexPath); - if ($json === false || !is_file($modulePath)) { + if ( + !is_file($indexPath) + || is_link($indexPath) + || !is_file($modulePath) + || is_link($modulePath) + ) { throw new ContractException(sprintf('Snapshot generation "%s" is incomplete.', $generationPath)); } + $json = file_get_contents($indexPath); + + if (!is_string($json)) { + throw new ContractException(sprintf('Snapshot generation "%s" is unreadable.', $generationPath)); + } + + $indexSha256 = hash('sha256', $json); + + if ($expectedIndexSha256 !== null && !hash_equals($expectedIndexSha256, $indexSha256)) { + throw new ContractException('Snapshot index digest does not match its active pointer.'); + } + try { $index = json_decode($json, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $exception) { @@ -83,16 +132,41 @@ public static function open( ($index['format'] ?? null) !== 'getbible.scripture.snapshot/v1' || !is_string($index['stream_sha256'] ?? null) || preg_match('/^[0-9a-f]{64}$/D', $index['stream_sha256']) !== 1 + || !hash_equals($generation, $index['stream_sha256']) + || !is_int($index['module_file_size'] ?? null) + || $index['module_file_size'] < 1 + || !is_string($index['module_file_sha256'] ?? null) + || preg_match('/^[0-9a-f]{64}$/D', $index['module_file_sha256']) !== 1 ) { throw new ContractException('Snapshot index structure is invalid.'); } + $moduleSize = filesize($modulePath); + $moduleSha256 = hash_file('sha256', $modulePath); + + if ( + !is_int($moduleSize) + || $moduleSize !== $index['module_file_size'] + || !is_string($moduleSha256) + || !hash_equals($index['module_file_sha256'], $moduleSha256) + ) { + throw new ContractException('Snapshot module stream does not match its integrity manifest.'); + } + StructuredData::object($index['module'] ?? null, 'Snapshot module metadata'); StructuredData::list($index['config_entries'] ?? null, 'Snapshot configuration entries'); StructuredData::list($index['introductions'] ?? null, 'Snapshot introductions'); StructuredData::object($index['books'] ?? null, 'Snapshot books'); - return new self($generationPath, $index, $activatedAt, $expiresAt); + return new self( + $generationPath, + $index, + $activatedAt, + $expiresAt, + $generation, + $indexSha256, + $lease, + ); } /** @@ -105,6 +179,8 @@ public function __destruct() if (is_resource($this->stream)) { fclose($this->stream); } + + $this->lease?->release(); } /** @@ -120,6 +196,28 @@ public function metadata(): TranslationMetadata ); } + /** + * Returns the content-addressed generation identifier. + * + * @return string + * @since 1.0.0 + */ + public function generationId(): string + { + return $this->generation; + } + + /** + * Returns the verified serialized index digest. + * + * @return string + * @since 1.0.0 + */ + public function indexSha256(): string + { + return $this->indexSha256; + } + /** * Returns all ordered interpreted configuration entries. * @@ -276,7 +374,21 @@ public function verse(string $bookKey, int $chapter, int $verse, int $suffix = 0 )); } - return Verse::fromRecord($this->readRecord($location)); + $object = Verse::fromRecord($this->readRecord($location)); + $scope = $object->scope(); + [$testament, $book] = $this->bookCoordinates($bookKey); + + if ( + $scope->testament() !== $testament + || $scope->book() !== $book + || $scope->chapter() !== $chapter + || $scope->verse() !== $verse + || $scope->suffix() !== $suffix + ) { + throw new ContractException('Snapshot verse location does not match its indexed coordinate.'); + } + + return $object; } /** @@ -331,7 +443,21 @@ public function verses( $verses = []; foreach ($coordinates as $coordinate) { - $verses[] = Verse::fromRecord($this->readRecord($coordinate['location'])); + $object = Verse::fromRecord($this->readRecord($coordinate['location'])); + $scope = $object->scope(); + [$testament, $book] = $this->bookCoordinates($bookKey); + + if ( + $scope->testament() !== $testament + || $scope->book() !== $book + || $scope->chapter() !== $chapter + || $scope->verse() !== $coordinate['verse'] + || $scope->suffix() !== $coordinate['suffix'] + ) { + throw new ContractException('Snapshot verse range location does not match its indexed coordinate.'); + } + + $verses[] = $object; } return $verses; @@ -372,7 +498,32 @@ public function introductions(?string $bookKey = null, ?int $chapter = null): ar throw new ContractException('Snapshot introduction location is invalid.'); } - $introductions[] = Introduction::fromRecord($this->readRecord($location)); + $introduction = Introduction::fromRecord($this->readRecord($location)); + $scope = $introduction->scope(); + + if ($bookKey === null) { + $matches = $scope->book() === 0; + } else { + [$testament, $book] = $this->bookCoordinates($bookKey); + $matches = $scope->testament() === $testament + && $scope->book() === $book + && ( + ($chapter === null && $scope->chapter() === 0) + || ( + $chapter !== null + && $scope->chapter() === $chapter + && $scope->verse() === 0 + ) + ); + } + + if (!$matches) { + throw new ContractException( + 'Snapshot introduction location does not match its indexed coordinate.', + ); + } + + $introductions[] = $introduction; } return $introductions; @@ -489,8 +640,16 @@ private function readRecord(array $location): array $location = StructuredData::object($location, 'Snapshot record location'); $offset = $location['offset'] ?? null; $length = $location['length'] ?? null; + $sha256 = $location['sha256'] ?? null; - if (!is_int($offset) || $offset < 0 || !is_int($length) || $length < 2) { + if ( + !is_int($offset) + || $offset < 0 + || !is_int($length) + || $length < 2 + || !is_string($sha256) + || preg_match('/^[0-9a-f]{64}$/D', $sha256) !== 1 + ) { throw new ContractException('Snapshot record location is invalid.'); } @@ -526,6 +685,42 @@ private function readRecord(array $location): array throw new ContractException('Snapshot location does not reference an entry record.'); } + try { + $canonical = json_encode( + $record, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + } catch (\JsonException $exception) { + throw new ContractException('Snapshot record cannot be integrity-checked.', 0, $exception); + } + + if (!hash_equals($sha256, hash('sha256', $canonical))) { + throw new ContractException('Snapshot record does not match its indexed digest.'); + } + return $record; } + + /** + * Parses a validated compound book key. + * + * @param string $bookKey Compound testament/book key. + * + * @return array{int, int} + * @since 1.0.0 + */ + private function bookCoordinates(string $bookKey): array + { + $parts = explode(':', $bookKey, 2); + + if ( + count($parts) !== 2 + || preg_match('/^[1-9][0-9]*$/D', $parts[0]) !== 1 + || preg_match('/^[1-9][0-9]*$/D', $parts[1]) !== 1 + ) { + throw new ContractException('Snapshot book key is invalid.'); + } + + return [(int) $parts[0], (int) $parts[1]]; + } } diff --git a/src/Snapshot/SnapshotRecordObserver.php b/src/Snapshot/SnapshotRecordObserver.php index 3639ef4..b77df83 100644 --- a/src/Snapshot/SnapshotRecordObserver.php +++ b/src/Snapshot/SnapshotRecordObserver.php @@ -16,7 +16,7 @@ /** * Builds the compact random-access index while records are being validated. * - * @phpstan-type RecordLocation array{offset: int, length: int} + * @phpstan-type RecordLocation array{offset: int, length: int, sha256: string} * @phpstan-type ChapterIndex array{ * number: int, * introductions: list, @@ -68,6 +68,14 @@ final class SnapshotRecordObserver implements RecordObserverInterface */ private array $introductions = []; + /** + * Book lookup aliases keyed by ASCII-lowercased exact bytes. + * + * @var array + * @since 1.0.0 + */ + private array $bookAliases = []; + /** * Observes a validated record and retains only query index data. * @@ -110,7 +118,20 @@ public function onRecord(array $record, int $offset, int $length): void } $scope = VerseScope::fromArray($record['scope']); - $location = ['offset' => $offset, 'length' => $length]; + try { + $canonical = json_encode( + $record, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + } catch (\JsonException $exception) { + throw new ContractException('Unable to calculate the snapshot record digest.', 0, $exception); + } + + $location = [ + 'offset' => $offset, + 'length' => $length, + 'sha256' => hash('sha256', $canonical), + ]; if ($scope->book() === 0) { $this->introductions[] = $location; @@ -128,6 +149,8 @@ public function onRecord(array $record, int $offset, int $length): void throw new ContractException('A scoped book entry is missing its name or abbreviation.'); } + $this->registerBookAlias($bookKey, $name->bytes()); + $this->registerBookAlias($bookKey, $abbreviation->bytes()); $this->books[$bookKey] = [ 'testament' => $scope->testament(), 'position' => $scope->book(), @@ -137,6 +160,25 @@ public function onRecord(array $record, int $offset, int $length): void 'introductions' => [], 'chapters' => [], ]; + } else { + $book = $this->books[$bookKey]; + $name = $scope->bookName(); + $abbreviation = $scope->bookAbbreviation(); + + if ( + $name === null + || $abbreviation === null + || $name->bytes() !== ByteValue::fromArray($book['name'], "books.$bookKey.name")->bytes() + || $abbreviation->bytes() + !== ByteValue::fromArray($book['abbreviation'], "books.$bookKey.abbreviation")->bytes() + || $scope->versification()->bytes() + !== ByteValue::fromArray($book['versification'], "books.$bookKey.versification")->bytes() + ) { + throw new ContractException(sprintf( + 'Book metadata changes within snapshot book "%s".', + $bookKey, + )); + } } if ($scope->chapter() === 0) { @@ -200,4 +242,30 @@ public function index(string $streamSha256, \DateTimeImmutable $generatedAt): ar 'books' => $this->books, ]; } + + /** + * Rejects aliases that would resolve ambiguously through Translation::book(). + * + * @param string $bookKey Compound testament/book key. + * @param string $alias Exact book name or abbreviation bytes. + * + * @return void + * @since 1.0.0 + */ + private function registerBookAlias(string $bookKey, string $alias): void + { + $key = strtolower($alias); + $existing = $this->bookAliases[$key] ?? null; + + if ($existing !== null && $existing !== $bookKey) { + throw new ContractException(sprintf( + 'Book alias "%s" is ambiguous between "%s" and "%s".', + $alias, + $existing, + $bookKey, + )); + } + + $this->bookAliases[$key] = $bookKey; + } } diff --git a/src/Snapshot/TranslationSnapshotManager.php b/src/Snapshot/TranslationSnapshotManager.php index 9ab6a93..f66afac 100644 --- a/src/Snapshot/TranslationSnapshotManager.php +++ b/src/Snapshot/TranslationSnapshotManager.php @@ -11,11 +11,14 @@ use GetBible\Scripture\Configuration\Configuration; use GetBible\Scripture\Contract\ContractV1ValidatorInterface; use GetBible\Scripture\Event\EventName; +use GetBible\Scripture\Event\LifecycleEventDispatcher; use GetBible\Scripture\Exception\ContractException; +use GetBible\Scripture\Infrastructure\Lock\BoundedFileLock; +use GetBible\Scripture\Infrastructure\Lock\GenerationLease; use GetBible\Scripture\Infrastructure\Sword\ModuleExtractorInterface; use GetBible\Scripture\Infrastructure\Lock\ModuleRootLockInterface; +use GetBible\Scripture\Module\ModuleIdentifier; use Joomla\Event\DispatcherInterface; -use Joomla\Event\Event; use Joomla\Filesystem\Folder; /** @@ -25,6 +28,27 @@ */ final class TranslationSnapshotManager implements SnapshotManagerInterface { + /** + * Number of unleased immutable generations retained per module. + * + * @since 1.0.0 + */ + private const RETAIN_GENERATIONS = 2; + + /** + * Age after which interrupted staging and pointer files are scavenged. + * + * @since 1.0.0 + */ + private const STALE_TEMPORARY_SECONDS = 3600; + + /** + * Maximum process-local open snapshot readers. + * + * @since 1.0.0 + */ + private const SNAPSHOT_CACHE_LIMIT = 32; + /** * Process-local open snapshot objects by module. * @@ -67,14 +91,14 @@ public function __construct( */ public function get(string $module): SnapshotIndex { - $module = $this->validateModule($module); - $snapshot = $this->snapshots[$module] ?? $this->openCurrent($module); + $module = ModuleIdentifier::normalize($module); + $snapshot = $this->openCurrent($module, $this->snapshots[$module] ?? null); if ( $snapshot !== null && (!$this->configuration->autoRefresh() || !$snapshot->isExpired($this->clock->now())) ) { - return $this->snapshots[$module] = $snapshot; + return $this->remember($module, $snapshot); } return $this->warm($module, false); @@ -90,24 +114,27 @@ public function get(string $module): SnapshotIndex */ public function refresh(string $module): SnapshotIndex { - $module = $this->validateModule($module); - $this->dispatcher->dispatch( + $module = ModuleIdentifier::normalize($module); + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::REFRESH_STARTED, - new Event(EventName::REFRESH_STARTED, ['module' => $module]), + ['module' => $module], ); try { $snapshot = $this->warm($module, true); - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::REFRESH_COMPLETED, - new Event(EventName::REFRESH_COMPLETED, ['module' => $module, 'snapshot' => $snapshot]), + ['module' => $module, 'snapshot' => $snapshot], ); return $snapshot; } catch (\Throwable $exception) { - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::REFRESH_FAILED, - new Event(EventName::REFRESH_FAILED, ['module' => $module, 'exception' => $exception]), + ['module' => $module, 'exception' => $exception], ); throw $exception; @@ -143,54 +170,67 @@ private function warm(string $module, bool $force): SnapshotIndex throw new \RuntimeException(sprintf('Unable to create snapshot root "%s".', $moduleRoot)); } - $lock = fopen($moduleRoot . '/warm.lock', 'c+b'); - - if (!is_resource($lock) || !flock($lock, LOCK_EX)) { - if (is_resource($lock)) { - fclose($lock); - } + $lock = new BoundedFileLock( + $moduleRoot . '/warm.lock', + $this->configuration->lockTimeout(), + ); - throw new \RuntimeException(sprintf('Unable to acquire the "%s" snapshot lock.', $module)); - } + return $lock->synchronized(LOCK_EX, function () use ($module, $moduleRoot, $force): SnapshotIndex { + $this->scavengeInterruptedWrites($moduleRoot); - try { if (!$force) { - $current = $this->openCurrent($module); + $current = $this->openCurrent($module, $this->snapshots[$module] ?? null); if ( $current !== null && (!$this->configuration->autoRefresh() || !$current->isExpired($this->clock->now())) ) { - return $this->snapshots[$module] = $current; + return $this->remember($module, $current); + } + + if ($current === null) { + $recovered = $this->recoverGeneration($module, $moduleRoot); + + if ( + $recovered !== null + && ( + !$this->configuration->autoRefresh() + || !$recovered->isExpired($this->clock->now()) + ) + ) { + $this->activate($moduleRoot, $recovered); + + return $this->remember($module, $recovered); + } } } - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::WARM_STARTED, - new Event(EventName::WARM_STARTED, ['module' => $module]), + ['module' => $module], ); try { $snapshot = $this->buildGeneration($module, $moduleRoot); - $this->snapshots[$module] = $snapshot; - $this->dispatcher->dispatch( + $this->remember($module, $snapshot); + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::WARM_COMPLETED, - new Event(EventName::WARM_COMPLETED, ['module' => $module, 'snapshot' => $snapshot]), + ['module' => $module, 'snapshot' => $snapshot], ); return $snapshot; } catch (\Throwable $exception) { - $this->dispatcher->dispatch( + LifecycleEventDispatcher::dispatch( + $this->dispatcher, EventName::WARM_FAILED, - new Event(EventName::WARM_FAILED, ['module' => $module, 'exception' => $exception]), + ['module' => $module, 'exception' => $exception], ); throw $exception; } - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } + }); } /** @@ -261,10 +301,19 @@ private function buildGeneration(string $module, string $moduleRoot): SnapshotIn } $index = $observer->index($result->streamSha256(), $now); + $moduleSha256 = hash_file('sha256', $moduleFile); + + if (!is_string($moduleSha256)) { + throw new \RuntimeException('Unable to hash the staged native module stream.'); + } + + $index['module_file_size'] = $written; + $index['module_file_sha256'] = $moduleSha256; $indexJson = json_encode( $index, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ) . "\n"; + $indexSha256 = hash('sha256', $indexJson); $this->writeSynchronized($staging . '/index.json', $indexJson); fclose($stream); $stream = null; @@ -273,30 +322,36 @@ private function buildGeneration(string $module, string $moduleRoot): SnapshotIn $destination = $moduleRoot . '/generations/' . $generation; if (is_dir($destination)) { - $snapshot = SnapshotIndex::open($destination, $now, $expires); - Folder::delete($staging); + try { + $existingIndex = file_get_contents($destination . '/index.json'); + + if (!is_string($existingIndex)) { + throw new ContractException('Existing snapshot generation index is unreadable.'); + } + + $snapshot = SnapshotIndex::open( + $destination, + $now, + $expires, + hash('sha256', $existingIndex), + ); + Folder::delete($staging); + } catch (ContractException) { + $this->repairGeneration($moduleRoot, $generation, $staging, $destination); + $snapshot = SnapshotIndex::open($destination, $now, $expires, $indexSha256); + } } elseif (!rename($staging, $destination)) { throw new \RuntimeException('Unable to atomically commit the staged snapshot generation.'); } else { - $snapshot = SnapshotIndex::open($destination, $now, $expires); + $snapshot = SnapshotIndex::open($destination, $now, $expires, $indexSha256); } - $pointer = [ - 'format' => 'getbible.scripture.current/v1', - 'generation' => $generation, - 'activated_at' => $now->format(DATE_ATOM), - 'expires_at' => $expires->format(DATE_ATOM), - ]; - $pointerJson = json_encode( - $pointer, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, - ) . "\n"; - $pointerTemp = $moduleRoot . '/current.' . bin2hex(random_bytes(8)) . '.tmp'; - $this->writeSynchronized($pointerTemp, $pointerJson); + $this->activate($moduleRoot, $snapshot); - if (!rename($pointerTemp, $moduleRoot . '/current.json')) { - @unlink($pointerTemp); - throw new \RuntimeException('Unable to atomically activate the snapshot generation.'); + try { + $this->cleanupGenerations($moduleRoot, $generation); + } catch (\Throwable) { + // Cleanup is retried after a later activation and cannot invalidate this committed generation. } return $snapshot; @@ -317,11 +372,12 @@ private function buildGeneration(string $module, string $moduleRoot): SnapshotIn * Opens the active generation when its pointer is complete and valid. * * @param string $module Exact installed module identifier. + * @param SnapshotIndex|null $cached Optional process-local reader. * * @return SnapshotIndex|null * @since 0.1.0 */ - private function openCurrent(string $module): ?SnapshotIndex + private function openCurrent(string $module, ?SnapshotIndex $cached = null): ?SnapshotIndex { $moduleRoot = $this->moduleRoot($module); $json = @file_get_contents($moduleRoot . '/current.json'); @@ -341,6 +397,8 @@ private function openCurrent(string $module): ?SnapshotIndex || ($pointer['format'] ?? null) !== 'getbible.scripture.current/v1' || !is_string($pointer['generation'] ?? null) || preg_match('/^[0-9a-f]{64}$/D', $pointer['generation']) !== 1 + || !is_string($pointer['index_sha256'] ?? null) + || preg_match('/^[0-9a-f]{64}$/D', $pointer['index_sha256']) !== 1 || !is_string($pointer['activated_at'] ?? null) || !is_string($pointer['expires_at'] ?? null) ) { @@ -348,13 +406,24 @@ private function openCurrent(string $module): ?SnapshotIndex } try { - $activated = new \DateTimeImmutable($pointer['activated_at']); - $expires = new \DateTimeImmutable($pointer['expires_at']); + $activated = $this->parseTimestamp($pointer['activated_at']); + $expires = $this->parseTimestamp($pointer['expires_at']); + + if ( + $cached !== null + && $cached->generationId() === $pointer['generation'] + && $cached->indexSha256() === $pointer['index_sha256'] + && $cached->activatedAt() == $activated + && $cached->expiresAt() == $expires + ) { + return $cached; + } return SnapshotIndex::open( $moduleRoot . '/generations/' . $pointer['generation'], $activated, $expires, + $pointer['index_sha256'], ); } catch (\Throwable) { return null; @@ -375,25 +444,297 @@ private function moduleRoot(string $module): string } /** - * Validates a traversal-safe native module identifier. + * Atomically activates a verified snapshot reader. * - * @param string $module Candidate identifier. + * @param string $moduleRoot Controlled per-module cache root. + * @param SnapshotIndex $snapshot Verified generation. * - * @return string - * @since 0.1.0 + * @return void + * @since 1.0.0 + */ + private function activate(string $moduleRoot, SnapshotIndex $snapshot): void + { + $pointer = [ + 'format' => 'getbible.scripture.current/v1', + 'generation' => $snapshot->generationId(), + 'index_sha256' => $snapshot->indexSha256(), + 'activated_at' => $snapshot->activatedAt()->format(DATE_ATOM), + 'expires_at' => $snapshot->expiresAt()->format(DATE_ATOM), + ]; + + try { + $pointerJson = json_encode( + $pointer, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, + ) . "\n"; + } catch (\JsonException $exception) { + throw new \RuntimeException('Unable to serialize the active snapshot pointer.', 0, $exception); + } + + $temporary = $moduleRoot . '/current.' . bin2hex(random_bytes(8)) . '.tmp'; + $this->writeSynchronized($temporary, $pointerJson); + + if (!rename($temporary, $moduleRoot . '/current.json')) { + @unlink($temporary); + throw new \RuntimeException('Unable to atomically activate the snapshot generation.'); + } + } + + /** + * Finds the newest complete cached generation after pointer corruption. + * + * @param string $module Exact installed module identifier. + * @param string $moduleRoot Controlled per-module cache root. + * + * @return SnapshotIndex|null + * @since 1.0.0 */ - private function validateModule(string $module): string + private function recoverGeneration(string $module, string $moduleRoot): ?SnapshotIndex { + $generations = $this->generationDirectories($moduleRoot); + + foreach ($generations as $generation => $modifiedAt) { + $path = $moduleRoot . '/generations/' . $generation; + $json = @file_get_contents($path . '/index.json'); + + if (!is_string($json)) { + continue; + } + + $activated = (new \DateTimeImmutable('@' . $modifiedAt)) + ->setTimezone(new \DateTimeZone('UTC')); + $expires = $activated->add($this->configuration->refreshInterval()); + + try { + $snapshot = SnapshotIndex::open( + $path, + $activated, + $expires, + hash('sha256', $json), + ); + + if ($snapshot->metadata()->name()->bytes() !== $module) { + continue; + } + + return $snapshot; + } catch (\Throwable) { + continue; + } + } + + return null; + } + + /** + * Replaces a corrupt same-hash generation when it has no active readers. + * + * @param string $moduleRoot Controlled per-module cache root. + * @param string $generation Content-addressed generation identifier. + * @param string $staging Complete staged generation. + * @param string $destination Corrupt committed generation. + * + * @return void + * @since 1.0.0 + */ + private function repairGeneration( + string $moduleRoot, + string $generation, + string $staging, + string $destination, + ): void { + $repaired = GenerationLease::cleanup( + $moduleRoot, + $generation, + function () use ($destination, $staging, $generation): void { + $quarantine = dirname($destination) . '/.corrupt-' + . $generation . '-' . bin2hex(random_bytes(6)); + + if (!rename($destination, $quarantine)) { + throw new \RuntimeException('Unable to quarantine a corrupt snapshot generation.'); + } + + if (!rename($staging, $destination)) { + if (!rename($quarantine, $destination)) { + throw new \RuntimeException( + 'Unable to replace or restore a corrupt snapshot generation.', + ); + } + + throw new \RuntimeException('Unable to replace a corrupt snapshot generation.'); + } + + Folder::delete($quarantine); + }, + ); + + if (!$repaired) { + throw new \RuntimeException( + 'A corrupt snapshot generation cannot be repaired while it has active readers.', + ); + } + } + + /** + * Deletes unleased historical generations after successful activation. + * + * @param string $moduleRoot Controlled per-module cache root. + * @param string $current Active generation identifier. + * + * @return void + * @since 1.0.0 + */ + private function cleanupGenerations(string $moduleRoot, string $current): void + { + $generations = $this->generationDirectories($moduleRoot); + $retained = [$current => true]; + + foreach (array_keys($generations) as $generation) { + if (isset($retained[$generation])) { + continue; + } + + if (count($retained) < self::RETAIN_GENERATIONS) { + $retained[$generation] = true; + continue; + } + + GenerationLease::cleanup( + $moduleRoot, + $generation, + static function () use ($moduleRoot, $generation): void { + $path = $moduleRoot . '/generations/' . $generation; + + if (is_dir($path)) { + Folder::delete($path); + } + }, + ); + } + } + + /** + * Returns committed generation directories newest first. + * + * @param string $moduleRoot Controlled per-module cache root. + * + * @return array + * @since 1.0.0 + */ + private function generationDirectories(string $moduleRoot): array + { + $path = $moduleRoot . '/generations'; + + if (!is_dir($path)) { + return []; + } + + $generations = []; + + foreach (new \DirectoryIterator($path) as $item) { + if ( + $item->isDot() + || !$item->isDir() + || $item->isLink() + || preg_match('/^[0-9a-f]{64}$/D', $item->getFilename()) !== 1 + ) { + continue; + } + + $generations[$item->getFilename()] = $item->getMTime(); + } + + arsort($generations, SORT_NUMERIC); + + return $generations; + } + + /** + * Removes abandoned staging, quarantine, and pointer files. + * + * @param string $moduleRoot Controlled per-module cache root. + * + * @return void + * @since 1.0.0 + */ + private function scavengeInterruptedWrites(string $moduleRoot): void + { + $cutoff = time() - self::STALE_TEMPORARY_SECONDS; + $generations = $moduleRoot . '/generations'; + + if (is_dir($generations)) { + foreach (new \DirectoryIterator($generations) as $item) { + $name = $item->getFilename(); + + if ( + $item->isDot() + || !$item->isDir() + || $item->isLink() + || $item->getMTime() > $cutoff + || ( + !str_starts_with($name, '.staging-') + && !str_starts_with($name, '.corrupt-') + ) + ) { + continue; + } + + Folder::delete($item->getPathname()); + } + } + + foreach (glob($moduleRoot . '/current.*.tmp') ?: [] as $temporary) { + $modifiedAt = filemtime($temporary); + + if (is_int($modifiedAt) && $modifiedAt <= $cutoff && is_file($temporary) && !is_link($temporary)) { + @unlink($temporary); + } + } + } + + /** + * Parses an exact RFC 3339 timestamp emitted by this package. + * + * @param string $value Serialized timestamp. + * + * @return \DateTimeImmutable + * @since 1.0.0 + */ + private function parseTimestamp(string $value): \DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat(DATE_ATOM, $value); + $errors = \DateTimeImmutable::getLastErrors(); + if ( - $module === '' - || $module === '.' - || $module === '..' - || preg_match('/^[A-Za-z0-9_.+-]+$/D', $module) !== 1 + !$date instanceof \DateTimeImmutable + || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + || $date->format(DATE_ATOM) !== $value ) { - throw new \InvalidArgumentException(sprintf('Invalid SWORD module identifier "%s".', $module)); + throw new ContractException('Snapshot pointer timestamp is invalid.'); + } + + return $date; + } + + /** + * Retains a bounded least-recently-used process-local snapshot reader. + * + * @param string $module Exact module identifier. + * @param SnapshotIndex $snapshot Verified snapshot reader. + * + * @return SnapshotIndex + * @since 1.0.0 + */ + private function remember(string $module, SnapshotIndex $snapshot): SnapshotIndex + { + unset($this->snapshots[$module]); + $this->snapshots[$module] = $snapshot; + + if (count($this->snapshots) > self::SNAPSHOT_CACHE_LIMIT) { + array_shift($this->snapshots); } - return $module; + return $snapshot; } /** diff --git a/tests/Fixtures/Native/GetBible/Sword/Engine.php b/tests/Fixtures/Native/GetBible/Sword/Engine.php new file mode 100644 index 0000000..8d068aa --- /dev/null +++ b/tests/Fixtures/Native/GetBible/Sword/Engine.php @@ -0,0 +1,197 @@ +modulePath ?? '/test/sword'; + } + + /** + * Writes a valid list stream containing the fixture translation. + * + * @param resource $destination Writable stream. + * + * @return int + * @since 1.0.0 + */ + public function streamModules(mixed $destination): int + { + $lines = file(NativeRuntimeState::$fixturePath); + + if (!is_array($lines)) { + throw new \RuntimeException('Unable to read the fake native fixture.'); + } + + $header = StructuredData::object( + json_decode($lines[0], true, 512, JSON_THROW_ON_ERROR), + 'Fixture header', + ); + $module = StructuredData::object( + json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR), + 'Fixture module', + ); + $header['command'] = 'list'; + unset($header['artifact_chunk_size']); + $header['sequence'] = 0; + $module['sequence'] = 1; + $serialized = [ + json_encode( + $header, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n", + json_encode( + $module, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n", + ]; + $footer = [ + 'artifact_bytes' => 0, + 'artifacts' => 0, + 'counts' => ['header' => 1, 'module' => 1], + 'diagnostics' => ['error' => 0, 'info' => 0, 'warning' => 0], + 'entries' => 0, + 'sequence' => 2, + 'stream_sha256' => hash('sha256', implode('', $serialized)), + 'success' => true, + 'type' => 'footer', + ]; + $serialized[] = json_encode( + $footer, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + + return $this->write($destination, implode('', $serialized)); + } + + /** + * Writes the complete fixture extraction stream. + * + * @param string $module Exact module identifier. + * @param resource $destination Writable stream. + * @param int $artifactChunkSize Requested chunk size. + * + * @return int + * @since 1.0.0 + */ + public function streamModule( + string $module, + mixed $destination, + int $artifactChunkSize = 1048576, + ): int { + if ($module !== 'TestBible' || $artifactChunkSize < 1) { + throw new \InvalidArgumentException('Unexpected fake extraction request.'); + } + + $contents = file_get_contents(NativeRuntimeState::$fixturePath); + + if (!is_string($contents)) { + throw new \RuntimeException('Unable to read the fake native fixture.'); + } + + return $this->write($destination, $contents); + } + + /** + * Throws the configured metadata failure. + * + * @return void + * @since 1.0.0 + */ + private static function guardMetadata(): void + { + if (NativeRuntimeState::$throwMetadata) { + throw new \RuntimeException('native metadata failed'); + } + } + + /** + * Writes complete contents to a destination. + * + * @param resource $destination Writable stream. + * @param string $contents Complete stream bytes. + * + * @return int + * @since 1.0.0 + */ + private function write(mixed $destination, string $contents): int + { + $written = fwrite($destination, $contents); + + if ($written !== strlen($contents)) { + throw new \RuntimeException('Unable to write the fake native stream.'); + } + + return $written; + } +} diff --git a/tests/Fixtures/Native/README.md b/tests/Fixtures/Native/README.md new file mode 100644 index 0000000..0659ab9 --- /dev/null +++ b/tests/Fixtures/Native/README.md @@ -0,0 +1,14 @@ +# Native integration fixture + +`verse.imp` is a deliberately minimal SWORD IMP module source authored for +this repository. It contains two short Public Domain Bible-text samples and no +third-party module archive. + +Required CI compiles the source locally with the Ubuntu Noble +`libsword-utils` 1.9.0 `imp2vs` utility, then reads the resulting module through +the released `getbiblesword` PHP extension and the complete Scripture package. +The source SHA-256 is pinned by `scripts/create-native-fixture.sh`, and CI +retains the generated module-tree hash with its integration evidence. + +The fixture is distributed under the repository's GPL-2.0-only license. The +underlying two Bible phrases are Public Domain. diff --git a/tests/Fixtures/Native/verse.imp b/tests/Fixtures/Native/verse.imp new file mode 100644 index 0000000..813d116 --- /dev/null +++ b/tests/Fixtures/Native/verse.imp @@ -0,0 +1,4 @@ +$$$Gen.1.1 +In the beginning test. +$$$John.1.1 +In the beginning was the Word. diff --git a/tests/Support/SnapshotFixtureFactory.php b/tests/Support/SnapshotFixtureFactory.php new file mode 100644 index 0000000..af89cef --- /dev/null +++ b/tests/Support/SnapshotFixtureFactory.php @@ -0,0 +1,98 @@ +validate($stream, $observer); + } finally { + fclose($stream); + } + + $index = $observer->index($result->streamSha256(), $activatedAt); + $index['module_file_size'] = strlen($contents); + $index['module_file_sha256'] = hash('sha256', $contents); + $indexJson = json_encode( + $index, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + $generationPath = $cachePath + . '/translations/TestBible/generations/' + . $result->streamSha256(); + + if (!is_dir($generationPath) && !mkdir($generationPath, 0700, true) && !is_dir($generationPath)) { + throw new \RuntimeException('Unable to create the snapshot fixture generation.'); + } + + if ( + file_put_contents($generationPath . '/module.ndjson', $contents) !== strlen($contents) + || file_put_contents($generationPath . '/index.json', $indexJson) !== strlen($indexJson) + ) { + throw new \RuntimeException('Unable to write the snapshot fixture generation.'); + } + + return SnapshotIndex::open( + $generationPath, + $activatedAt, + $expiresAt, + hash('sha256', $indexJson), + ); + } + + /** + * Prevents instantiation of this fixtures-only utility. + * + * @since 1.0.0 + */ + private function __construct() + { + } +} diff --git a/tests/Unit/Catalog/ModuleCatalogFailureTest.php b/tests/Unit/Catalog/ModuleCatalogFailureTest.php new file mode 100644 index 0000000..c4760f6 --- /dev/null +++ b/tests/Unit/Catalog/ModuleCatalogFailureTest.php @@ -0,0 +1,312 @@ +catalog($this->listStream([$this->moduleRecord()])); + + try { + $catalog->translation('Missing'); + self::fail('An absent translation must fail.'); + } catch (TranslationNotFoundException) { + self::addToAssertionCount(1); + } + + $this->expectException(\InvalidArgumentException::class); + $catalog->translation('../TestBible'); + } + + /** + * Verifies non-Bible modules are not exposed by the Bible-only catalog. + * + * @return void + * @since 1.0.0 + */ + public function testFiltersNonBibleModules(): void + { + $module = $this->moduleRecord(); + $module['classification'] = 'dictionary_or_lexicon'; + + self::assertSame([], $this->catalog($this->listStream([$module]))->translations()); + } + + /** + * Verifies unsafe and duplicate native identifiers cannot enter cache keys. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsUnsafeAndDuplicateNativeIdentifiers(): void + { + $unsafe = $this->moduleRecord(); + $unsafe['name'] = $this->byteValue('../Bible'); + $nonCanonical = $this->moduleRecord(); + $nonCanonical['name'] = $this->byteValue(' TestBible '); + + foreach ( + [ + $this->listStream([$unsafe]), + $this->listStream([$nonCanonical]), + $this->listStream([$this->moduleRecord(), $this->moduleRecord()]), + ] as $stream + ) { + try { + $this->catalog($stream)->translations(); + self::fail('Unsafe or duplicate native identifiers must fail.'); + } catch (ContractException) { + self::addToAssertionCount(1); + } + } + } + + /** + * Verifies the native byte count is authenticated before validation. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMismatchedNativeByteCount(): void + { + $stream = $this->listStream([$this->moduleRecord()]); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('byte count'); + + $this->catalog($stream, strlen($stream) + 1)->translations(); + } + + /** + * Verifies the catalog accepts only a validated list operation. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsValidatedNonListOperation(): void + { + $extractor = $this->createStub(ModuleExtractorInterface::class); + $extractor->method('streamModules')->willReturn(0); + $validator = $this->createStub(ContractV1ValidatorInterface::class); + $validator->method('validate')->willReturn(new ValidationResult('extract', [], [])); + $lock = $this->createStub(ModuleRootLockInterface::class); + $lock->method('read')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $catalog = new ModuleCatalog($extractor, $validator, $lock); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('non-list'); + $catalog->translations(); + } + + /** + * Creates a fixture-backed catalog. + * + * @param string $contents Complete native stream. + * @param int|null $reportedBytes Optional extractor byte count. + * + * @return ModuleCatalog + * @since 1.0.0 + */ + private function catalog(string $contents, ?int $reportedBytes = null): ModuleCatalog + { + $extractor = new class ($contents, $reportedBytes) implements ModuleExtractorInterface { + /** + * Creates a deterministic stream extractor. + * + * @param string $contents Complete native stream. + * @param int|null $reportedBytes Optional reported byte count. + */ + public function __construct( + private string $contents, + private ?int $reportedBytes, + ) { + } + + /** + * Returns the fake native module root. + * + * @return string + */ + public function modulePath(): string + { + return '/test/modules'; + } + + /** + * Writes the configured stream. + * + * @param resource $destination Destination stream. + * + * @return int + */ + public function streamModules(mixed $destination): int + { + if (fwrite($destination, $this->contents) !== strlen($this->contents)) { + throw new \RuntimeException('Unable to write the catalog fixture.'); + } + + return $this->reportedBytes ?? strlen($this->contents); + } + + /** + * Rejects unused extraction. + * + * @param string $module Module identifier. + * @param resource $destination Destination stream. + * @param int $artifactChunkSize Artifact chunk size. + * + * @return int + */ + public function streamModule( + string $module, + mixed $destination, + int $artifactChunkSize = 1048576, + ): int { + throw new \LogicException('Not used.'); + } + }; + $lock = new class () implements ModuleRootLockInterface { + /** + * Runs one read operation directly. + * + * @template T + * @param callable(): T $operation Operation. + * @return T + */ + public function read(callable $operation): mixed + { + return $operation(); + } + + /** + * Runs one write operation directly. + * + * @template T + * @param callable(): T $operation Operation. + * @return T + */ + public function write(callable $operation): mixed + { + return $operation(); + } + }; + + return new ModuleCatalog($extractor, new ContractV1Validator(), $lock); + } + + /** + * Returns the fixture module record. + * + * @return array + * @since 1.0.0 + */ + private function moduleRecord(): array + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + + return StructuredData::object( + json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR), + 'Fixture module', + ); + } + + /** + * Builds a deterministic native list stream. + * + * @param list> $modules Module records. + * + * @return string + * @since 1.0.0 + */ + private function listStream(array $modules): string + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $header = StructuredData::object( + json_decode($lines[0], true, 512, JSON_THROW_ON_ERROR), + 'Fixture header', + ); + $header['command'] = 'list'; + unset($header['artifact_chunk_size']); + $records = [$header]; + + foreach ($modules as $module) { + $records[] = $module; + } + + $serialized = []; + + foreach ($records as $sequence => $record) { + $record['sequence'] = $sequence; + $serialized[] = json_encode( + $record, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + } + + $footer = [ + 'artifact_bytes' => 0, + 'artifacts' => 0, + 'counts' => ['header' => 1, 'module' => count($modules)], + 'diagnostics' => ['error' => 0, 'info' => 0, 'warning' => 0], + 'entries' => 0, + 'sequence' => count($records), + 'stream_sha256' => hash('sha256', implode('', $serialized)), + 'success' => true, + 'type' => 'footer', + ]; + $serialized[] = json_encode( + $footer, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + + return implode('', $serialized); + } + + /** + * Creates a valid byte-value envelope. + * + * @param string $bytes Exact bytes. + * + * @return array{base64: string, encoding: string, sha256: string, size: int, utf8: string} + * @since 1.0.0 + */ + private function byteValue(string $bytes): array + { + return [ + 'base64' => base64_encode($bytes), + 'encoding' => 'base64', + 'sha256' => hash('sha256', $bytes), + 'size' => strlen($bytes), + 'utf8' => $bytes, + ]; + } +} diff --git a/tests/Unit/Catalog/ModuleCatalogTest.php b/tests/Unit/Catalog/ModuleCatalogTest.php new file mode 100644 index 0000000..55d3f79 --- /dev/null +++ b/tests/Unit/Catalog/ModuleCatalogTest.php @@ -0,0 +1,207 @@ +reads; + $this->reading = true; + + try { + return $operation(); + } finally { + $this->reading = false; + } + } + + /** + * Executes an exclusive callback. + * + * @template T + * @param callable(): T $operation Callback. + * @return T + */ + public function write(callable $operation): mixed + { + return $operation(); + } + }; + $stream = $this->listStream(); + $extractor = new class ( + $stream, + static fn (): bool => $lock->reading, + ) implements ModuleExtractorInterface { + /** + * Native list calls. + * + * @var int + */ + public int $calls = 0; + + /** + * Creates the deterministic extractor. + * + * @param string $stream Complete list stream. + * @param \Closure(): bool $isReading Shared-lock state. + */ + public function __construct( + private string $stream, + private \Closure $isReading, + ) { + } + + /** + * Returns the fake module root. + * + * @return string + */ + public function modulePath(): string + { + return '/test/modules'; + } + + /** + * Writes the fixture under the asserted read lock. + * + * @param resource $destination Destination. + * @return int + */ + public function streamModules(mixed $destination): int + { + if (($this->isReading)() !== true) { + throw new \RuntimeException('Catalog extraction was not read locked.'); + } + + ++$this->calls; + $written = fwrite($destination, $this->stream); + + if ($written !== strlen($this->stream)) { + throw new \RuntimeException('Unable to write the catalog fixture.'); + } + + return $written; + } + + /** + * Rejects unused module extraction. + * + * @param string $module Module identifier. + * @param resource $destination Destination. + * @param int $artifactChunkSize Chunk size. + * @return int + */ + public function streamModule( + string $module, + mixed $destination, + int $artifactChunkSize = 1048576, + ): int { + throw new \LogicException('Not used.'); + } + }; + $catalog = new ModuleCatalog($extractor, new ContractV1Validator(), $lock); + + self::assertSame('TestBible', $catalog->translations()[0]->name()->requireUtf8()); + self::assertSame('TestBible', $catalog->translation(' TestBible ')->name()->requireUtf8()); + self::assertSame(1, $extractor->calls); + self::assertSame(1, $lock->reads); + + $catalog->clear(); + $catalog->translations(); + + self::assertSame(2, $extractor->calls); + self::assertSame(2, $lock->reads); + } + + /** + * Builds a complete deterministic list operation stream. + * + * @return string + * @since 1.0.0 + */ + private function listStream(): string + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $header = StructuredData::object( + json_decode($lines[0], true, 512, JSON_THROW_ON_ERROR), + 'Fixture header', + ); + $module = StructuredData::object( + json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR), + 'Fixture module', + ); + $header['command'] = 'list'; + unset($header['artifact_chunk_size']); + $header['sequence'] = 0; + $module['sequence'] = 1; + $serialized = [ + json_encode($header, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n", + json_encode($module, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n", + ]; + $footer = [ + 'artifact_bytes' => 0, + 'artifacts' => 0, + 'counts' => ['header' => 1, 'module' => 1], + 'diagnostics' => ['error' => 0, 'info' => 0, 'warning' => 0], + 'entries' => 0, + 'sequence' => 2, + 'stream_sha256' => hash('sha256', implode('', $serialized)), + 'success' => true, + 'type' => 'footer', + ]; + $serialized[] = json_encode( + $footer, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + + return implode('', $serialized); + } +} diff --git a/tests/Unit/Configuration/ConfigurationEdgeCasesTest.php b/tests/Unit/Configuration/ConfigurationEdgeCasesTest.php new file mode 100644 index 0000000..0553700 --- /dev/null +++ b/tests/Unit/Configuration/ConfigurationEdgeCasesTest.php @@ -0,0 +1,255 @@ + ' /srv/sword ', + 'cache_path' => '/srv/scripture-cache///', + 'refresh_interval' => 'P14D', + 'auto_refresh' => 0, + 'lock_timeout' => 17, + 'modules' => [' WEB ', 'KJV', 'WEB'], + 'provisioning_enabled' => 1, + 'install_all' => true, + ]); + + self::assertSame('/srv/sword', $configuration->modulePath()); + self::assertSame('/srv/scripture-cache', $configuration->cachePath()); + self::assertSame('P14D', $configuration->refreshIntervalSpec()); + self::assertSame(14, $configuration->refreshInterval()->d); + self::assertFalse($configuration->autoRefresh()); + self::assertSame(17, $configuration->lockTimeout()); + self::assertSame(['WEB', 'KJV'], $configuration->modules()); + self::assertTrue($configuration->provisioningEnabled()); + self::assertTrue($configuration->installAll()); + self::assertSame( + '/srv/scripture-cache/maintenance/state.json', + $configuration->maintenanceStatePath(), + ); + } + + /** + * Verifies callers cannot mutate the immutable configuration through Registry. + * + * @return void + * @since 1.0.0 + */ + public function testRegistryReturnsDefensiveCopy(): void + { + $configuration = Configuration::fromEnvironment([ + 'cache_path' => '/original/cache', + ]); + $registry = $configuration->registry(); + $registry->set('cache_path', '/mutated/cache'); + + self::assertSame('/original/cache', $configuration->cachePath()); + self::assertSame('/mutated/cache', $registry->get('cache_path')); + } + + /** + * Verifies XDG cache policy provides the deterministic default. + * + * @return void + * @since 1.0.0 + */ + public function testUsesXdgDefaultCachePath(): void + { + $previousXdg = getenv('XDG_CACHE_HOME'); + $previousCache = getenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + putenv('XDG_CACHE_HOME=/var/cache/example'); + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + + try { + self::assertSame( + '/var/cache/example/getbible/scripture', + Configuration::fromEnvironment()->cachePath(), + ); + } finally { + $this->restoreEnvironment('XDG_CACHE_HOME', $previousXdg); + $this->restoreEnvironment('GETBIBLE_SCRIPTURE_CACHE_PATH', $previousCache); + } + } + + /** + * Verifies accepted textual boolean forms are strict and symmetric. + * + * @param string $value Candidate value. + * @param bool $expected Parsed value. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('booleanProvider')] + public function testParsesSupportedBooleanStrings(string $value, bool $expected): void + { + self::assertSame($expected, Configuration::fromEnvironment([ + 'cache_path' => '/srv/cache', + 'auto_refresh' => $value, + ])->autoRefresh()); + } + + /** + * Supplies accepted boolean text. + * + * @return iterable + * @since 1.0.0 + */ + public static function booleanProvider(): iterable + { + yield 'true' => ['true', true]; + yield 'yes' => ['YES', true]; + yield 'on' => [' on ', true]; + yield 'false' => ['false', false]; + yield 'no' => ['NO', false]; + yield 'off' => [' off ', false]; + } + + /** + * Verifies malformed boolean values are rejected. + * + * @param bool|int|string|array $value Invalid value. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidBooleanProvider')] + public function testRejectsInvalidBooleanValues(bool|int|string|array $value): void + { + $this->assertInvalidConfigurationValue('auto_refresh', $value); + } + + /** + * Supplies malformed boolean values. + * + * @return iterable}> + * @since 1.0.0 + */ + public static function invalidBooleanProvider(): iterable + { + yield 'integer' => [2]; + yield 'text' => ['occasionally']; + yield 'array' => [['true']]; + } + + /** + * Verifies malformed lock timeouts are rejected. + * + * @param bool|int|string|array $value Invalid value. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidTimeoutProvider')] + public function testRejectsInvalidLockTimeout(bool|int|string|array $value): void + { + $this->assertInvalidConfigurationValue('lock_timeout', $value); + } + + /** + * Supplies malformed positive integers. + * + * @return iterable}> + * @since 1.0.0 + */ + public static function invalidTimeoutProvider(): iterable + { + yield 'zero integer' => [0]; + yield 'negative integer' => [-1]; + yield 'leading zero' => ['01']; + yield 'decimal' => ['1.5']; + yield 'boolean' => [true]; + yield 'array' => [[30]]; + } + + /** + * Verifies malformed module lists are rejected. + * + * @param bool|int|string|array $value Invalid value. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidModulesProvider')] + public function testRejectsInvalidModuleLists(bool|int|string|array $value): void + { + $this->assertInvalidConfigurationValue('modules', $value); + } + + /** + * Passes deliberately out-of-contract input through the runtime boundary. + * + * @param string $key Configuration key. + * @param mixed $value Deliberately invalid value. + * + * @return void + * @since 1.0.0 + */ + private function assertInvalidConfigurationValue(string $key, mixed $value): void + { + $this->expectException(\InvalidArgumentException::class); + + $method = new \ReflectionMethod(Configuration::class, 'fromEnvironment'); + $method->invoke(null, [ + 'cache_path' => '/srv/cache', + $key => $value, + ]); + } + + /** + * Supplies malformed module configurations. + * + * @return iterable}> + * @since 1.0.0 + */ + public static function invalidModulesProvider(): iterable + { + yield 'boolean' => [true]; + yield 'integer' => [1]; + yield 'empty item' => ['KJV,']; + yield 'nul byte' => [["KJV\0"]]; + yield 'non-string item' => [['KJV', 1]]; + } + + /** + * Restores one process environment variable. + * + * @param string $name Variable name. + * @param string|false $value Previous value. + * + * @return void + * @since 1.0.0 + */ + private function restoreEnvironment(string $name, string|false $value): void + { + if (is_string($value)) { + putenv($name . '=' . $value); + + return; + } + + putenv($name); + } +} diff --git a/tests/Unit/Configuration/ConfigurationInvariantTest.php b/tests/Unit/Configuration/ConfigurationInvariantTest.php new file mode 100644 index 0000000..1366943 --- /dev/null +++ b/tests/Unit/Configuration/ConfigurationInvariantTest.php @@ -0,0 +1,190 @@ +corrupt($setting, $value); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage($message); + + $accessor($configuration); + } + + /** + * Supplies corrupt values for every guarded accessor. + * + * @return iterable + * @since 1.0.0 + */ + public static function corruptRegistryProvider(): iterable + { + yield 'cache path' => [ + 'cache_path', + null, + static fn (Configuration $configuration): string => $configuration->cachePath(), + 'cache path', + ]; + yield 'refresh interval' => [ + 'refresh_interval', + '', + static fn (Configuration $configuration): string => $configuration->refreshIntervalSpec(), + 'refresh interval', + ]; + yield 'auto refresh' => [ + 'auto_refresh', + 'yes', + static fn (Configuration $configuration): bool => $configuration->autoRefresh(), + 'automatic refresh', + ]; + yield 'lock timeout' => [ + 'lock_timeout', + 0, + static fn (Configuration $configuration): int => $configuration->lockTimeout(), + 'lock timeout', + ]; + yield 'module container' => [ + 'modules', + 'KJV', + static fn (Configuration $configuration): mixed => $configuration->modules(), + 'not an array', + ]; + yield 'module item' => [ + 'modules', + ['KJV', 1], + static fn (Configuration $configuration): mixed => $configuration->modules(), + 'non-string value', + ]; + yield 'provisioning policy' => [ + 'provisioning_enabled', + 1, + static fn (Configuration $configuration): bool => $configuration->provisioningEnabled(), + 'provisioning policy', + ]; + yield 'install all policy' => [ + 'install_all', + 1, + static fn (Configuration $configuration): bool => $configuration->installAll(), + 'all-module installation policy', + ]; + } + + /** + * Verifies malformed ISO-8601 syntax is translated to configuration error. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedRefreshInterval(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid ISO-8601 refresh interval'); + + Configuration::fromEnvironment([ + 'cache_path' => '/srv/cache', + 'refresh_interval' => 'every month', + ]); + } + + /** + * Verifies the filesystem root remains a valid normalized cache root. + * + * @return void + * @since 1.0.0 + */ + public function testPreservesFilesystemRootCachePath(): void + { + self::assertSame('/', Configuration::fromEnvironment([ + 'cache_path' => '///', + ])->cachePath()); + } + + /** + * Verifies temporary storage is used when user cache locations are absent. + * + * @return void + * @since 1.0.0 + */ + public function testFallsBackToTemporaryCacheRoot(): void + { + $names = ['GETBIBLE_SCRIPTURE_CACHE_PATH', 'XDG_CACHE_HOME', 'HOME']; + $previous = []; + + foreach ($names as $name) { + $previous[$name] = getenv($name); + putenv($name); + } + + try { + self::assertStringStartsWith( + sys_get_temp_dir() . '/getbible-scripture-', + Configuration::fromEnvironment()->cachePath(), + ); + } finally { + foreach ($previous as $name => $value) { + if (is_string($value)) { + putenv($name . '=' . $value); + } else { + putenv($name); + } + } + } + } + + /** + * Creates configuration with one intentionally corrupt registry value. + * + * @param string $setting Registry setting. + * @param mixed $value Corrupt value. + * + * @return Configuration + * @since 1.0.0 + */ + private function corrupt(string $setting, mixed $value): Configuration + { + $configuration = Configuration::fromEnvironment([ + 'cache_path' => '/srv/cache', + ]); + $registry = $configuration->registry(); + $registry->set($setting, $value); + $property = new \ReflectionProperty(Configuration::class, 'registry'); + $property->setValue($configuration, $registry); + + self::assertInstanceOf(Registry::class, $configuration->registry()); + + return $configuration; + } +} diff --git a/tests/Unit/Configuration/ConfigurationPathTest.php b/tests/Unit/Configuration/ConfigurationPathTest.php new file mode 100644 index 0000000..e9e6069 --- /dev/null +++ b/tests/Unit/Configuration/ConfigurationPathTest.php @@ -0,0 +1,131 @@ +create(); + + self::assertSame('/environment/scripture.json', $repository->path()); + } finally { + $this->restoreEnvironment(ConfigurationPath::ENVIRONMENT_VARIABLE, $previous); + } + } + + /** + * Verifies absent optional persistence remains an explicit null repository. + * + * @return void + * @since 1.0.0 + */ + public function testFactorySupportsNonPersistentConfiguration(): void + { + $previous = getenv(ConfigurationPath::ENVIRONMENT_VARIABLE); + putenv(ConfigurationPath::ENVIRONMENT_VARIABLE); + + try { + $repository = (new JsonConfigurationRepositoryFactory())->create(); + + self::assertNull($repository->path()); + self::assertFalse($repository->exists()); + self::assertSame([], $repository->load()); + } finally { + $this->restoreEnvironment(ConfigurationPath::ENVIRONMENT_VARIABLE, $previous); + } + } + + /** + * Verifies unsafe or ambiguous paths are rejected. + * + * @param string $path Invalid path. + * @param string $message Expected diagnostic fragment. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidPathProvider')] + public function testRejectsInvalidPaths(string $path, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + ConfigurationPath::resolve($path); + } + + /** + * Supplies invalid path cases. + * + * @return iterable + * @since 1.0.0 + */ + public static function invalidPathProvider(): iterable + { + yield 'relative' => ['configuration.json', 'must be absolute']; + yield 'root' => ['/', 'must identify a file']; + yield 'nul byte' => ["/srv/configuration\0.json", 'cannot contain NUL']; + } + + /** + * Restores one process environment variable. + * + * @param string $name Variable name. + * @param string|false $value Previous value. + * + * @return void + * @since 1.0.0 + */ + private function restoreEnvironment(string $name, string|false $value): void + { + if (is_string($value)) { + putenv($name . '=' . $value); + + return; + } + + putenv($name); + } +} diff --git a/tests/Unit/Configuration/ConfigurationTest.php b/tests/Unit/Configuration/ConfigurationTest.php index d963776..482efab 100644 --- a/tests/Unit/Configuration/ConfigurationTest.php +++ b/tests/Unit/Configuration/ConfigurationTest.php @@ -75,4 +75,40 @@ public function testRejectsNonAdvancingInterval(): void 'refresh_interval' => 'P0D', ]); } + + /** + * Verifies deployment environment overrides persisted JSON only. + * + * @return void + * @since 1.0.0 + */ + public function testEnvironmentOverridesPersistedButNotExplicitValues(): void + { + $previous = getenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH=/environment/cache'); + + try { + self::assertSame( + '/environment/cache', + Configuration::fromPersisted(['cache_path' => '/persisted/cache'])->cachePath(), + ); + self::assertSame( + '/explicit/cache', + Configuration::fromEnvironment(['cache_path' => '/explicit/cache'])->cachePath(), + ); + self::assertSame( + '/setup/cache', + Configuration::fromLayers( + ['cache_path' => '/persisted/cache'], + ['cache_path' => '/setup/cache'], + )->cachePath(), + ); + } finally { + if (is_string($previous)) { + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH=' . $previous); + } else { + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + } + } + } } diff --git a/tests/Unit/Configuration/JsonConfigurationRepositoryTest.php b/tests/Unit/Configuration/JsonConfigurationRepositoryTest.php new file mode 100644 index 0000000..d22b496 --- /dev/null +++ b/tests/Unit/Configuration/JsonConfigurationRepositoryTest.php @@ -0,0 +1,138 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-configuration-repository-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes isolated configuration files. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $path = $this->directory . '/configuration.json'; + + if (is_file($path) || is_link($path)) { + unlink($path); + } + + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + /** + * Verifies validated settings survive a restrictive atomic round trip. + * + * @return void + * @since 1.0.0 + */ + public function testRoundTripsRestrictiveConfiguration(): void + { + $path = $this->directory . '/configuration.json'; + $repository = new JsonConfigurationRepository($path); + $configuration = Configuration::fromEnvironment([ + 'module_path' => '/srv/sword', + 'cache_path' => '/srv/cache', + 'refresh_interval' => 'P7D', + 'auto_refresh' => false, + 'lock_timeout' => 45, + 'modules' => ['KJV', 'WEB'], + ]); + + self::assertFalse($repository->exists()); + + $repository->save($configuration); + + self::assertTrue($repository->exists()); + self::assertSame(0600, fileperms($path) & 0777); + self::assertSame( + [ + 'module_path' => '/srv/sword', + 'cache_path' => '/srv/cache', + 'refresh_interval' => 'P7D', + 'auto_refresh' => false, + 'lock_timeout' => 45, + 'modules' => ['KJV', 'WEB'], + 'provisioning_enabled' => false, + 'install_all' => false, + ], + $repository->load(), + ); + } + + /** + * Verifies corrupt persisted configuration is rejected. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsCorruptConfiguration(): void + { + self::assertTrue(mkdir($this->directory, 0700, true)); + $path = $this->directory . '/configuration.json'; + self::assertNotFalse(file_put_contents($path, '{"format":"invalid"}')); + $repository = new JsonConfigurationRepository($path); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('document format is invalid'); + + $repository->load(); + } + + /** + * Verifies persistence remains unavailable without an explicit path. + * + * @return void + * @since 1.0.0 + */ + public function testRequiresExplicitPathForPersistence(): void + { + $repository = new JsonConfigurationRepository(null); + + self::assertSame([], $repository->load()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('configuration path must be supplied'); + + $repository->save(Configuration::fromEnvironment([ + 'cache_path' => '/srv/cache', + ])); + } +} diff --git a/tests/Unit/Configuration/JsonConfigurationValidationTest.php b/tests/Unit/Configuration/JsonConfigurationValidationTest.php new file mode 100644 index 0000000..fd2635f --- /dev/null +++ b/tests/Unit/Configuration/JsonConfigurationValidationTest.php @@ -0,0 +1,246 @@ +path = sys_get_temp_dir() + . '/getbible-scripture-untrusted-' + . bin2hex(random_bytes(8)) + . '.json'; + } + + /** + * Removes the temporary configuration file. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + if (is_file($this->path) || is_link($this->path)) { + unlink($this->path); + } + } + + /** + * Verifies malformed JSON and unsupported shapes fail closed. + * + * @param string $payload Untrusted file payload. + * @param string $message Expected diagnostic fragment. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidDocumentProvider')] + public function testRejectsInvalidDocuments(string $payload, string $message): void + { + self::assertNotFalse(file_put_contents($this->path, $payload)); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage($message); + + (new JsonConfigurationRepository($this->path))->load(); + } + + /** + * Supplies untrusted configuration documents. + * + * @return iterable + * @since 1.0.0 + */ + public static function invalidDocumentProvider(): iterable + { + yield 'invalid JSON' => ['{', 'not valid JSON']; + yield 'top-level list' => ['[]', 'document format is invalid']; + yield 'settings list' => [ + '{"format":"getbible.scripture.configuration/v1","settings":[]}', + 'settings map is invalid', + ]; + yield 'unknown setting' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"secret":"value"}}', + 'contains an unknown setting', + ]; + yield 'nested setting' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"modules":[["KJV"]]}}', + 'must contain only strings', + ]; + yield 'associative module map' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"modules":{"one":"KJV"}}}', + 'must be a list of strings', + ]; + yield 'scalar modules' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"modules":"KJV"}}', + 'must be a list of strings', + ]; + yield 'array cache path' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"cache_path":["/srv/cache"]}}', + 'must be a string', + ]; + yield 'textual boolean' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"auto_refresh":"yes"}}', + 'must be a boolean', + ]; + yield 'textual integer' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"lock_timeout":"30"}}', + 'must be an integer', + ]; + yield 'integer module path' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"module_path":1}}', + 'must be a string or null', + ]; + yield 'invalid normalized value' => [ + '{"format":"getbible.scripture.configuration/v1","settings":{"lock_timeout":0}}', + 'must be greater than zero', + ]; + } + + /** + * Verifies symbolic-link configuration sources are never trusted. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsSymbolicLinkConfiguration(): void + { + $target = $this->path . '.target'; + self::assertNotFalse(file_put_contents( + $target, + '{"format":"getbible.scripture.configuration/v1","settings":{}}', + )); + self::assertTrue(symlink($target, $this->path)); + + try { + $repository = new JsonConfigurationRepository($this->path); + + self::assertFalse($repository->exists()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not a readable regular file'); + $repository->load(); + } finally { + if (is_file($target)) { + unlink($target); + } + } + } + + /** + * Verifies persistence never replaces a caller-controlled symbolic link. + * + * @return void + * @since 1.0.0 + */ + public function testRefusesToSaveThroughSymbolicLink(): void + { + $target = $this->path . '.target'; + self::assertNotFalse(file_put_contents($target, 'unchanged')); + self::assertTrue(symlink($target, $this->path)); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Refusing to replace a symbolic-link'); + + (new JsonConfigurationRepository($this->path))->save( + Configuration::fromEnvironment(['cache_path' => '/srv/cache']), + ); + } finally { + self::assertSame('unchanged', file_get_contents($target)); + + if (is_file($target)) { + unlink($target); + } + } + } + + /** + * Verifies directories are never accepted as configuration documents. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsDirectoryConfigurationPath(): void + { + self::assertTrue(mkdir($this->path, 0700)); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not a readable regular file'); + + (new JsonConfigurationRepository($this->path))->load(); + } finally { + rmdir($this->path); + } + } + + /** + * Verifies configuration is never written beneath a symbolic-link parent. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsSymbolicLinkConfigurationDirectory(): void + { + $target = $this->path . '.directory'; + self::assertTrue(mkdir($target, 0700)); + self::assertTrue(symlink($target, $this->path)); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not a writable regular directory'); + + (new JsonConfigurationRepository($this->path . '/configuration.json'))->save( + Configuration::fromEnvironment(['cache_path' => '/srv/cache']), + ); + } finally { + if (is_link($this->path)) { + unlink($this->path); + } + + rmdir($target); + } + } + + /** + * Verifies repository construction requires an already-normalized path. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsUnnormalizedRepositoryPath(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('not normalized'); + + new JsonConfigurationRepository($this->path . '/'); + } +} diff --git a/tests/Unit/Console/CommandApplicationWarmer.php b/tests/Unit/Console/CommandApplicationWarmer.php new file mode 100644 index 0000000..bce67bf --- /dev/null +++ b/tests/Unit/Console/CommandApplicationWarmer.php @@ -0,0 +1,33 @@ + $modules Module targets. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + public function warm(Configuration $configuration, array $modules = []): MaintenanceResult + { + throw new \LogicException('Command test unexpectedly requested warming.'); + } +} diff --git a/tests/Unit/Console/CommandRuntimeInspector.php b/tests/Unit/Console/CommandRuntimeInspector.php new file mode 100644 index 0000000..95ed804 --- /dev/null +++ b/tests/Unit/Console/CommandRuntimeInspector.php @@ -0,0 +1,36 @@ + '/tmp/getbible-scripture-console-factory', + ])); + $application = ConsoleApplicationFactory::create($container); + + self::assertSame('GetBible Scripture', $application->getName()); + self::assertSame('1.0.0', $application->getVersion()); + self::assertTrue($application->hasCommand('scripture:doctor')); + self::assertTrue($application->hasCommand('scripture:setup')); + self::assertFalse($application->hasCommand('scripture:initialize')); + self::assertFalse($application->hasCommand('scripture:refresh')); + self::assertFalse($application->hasCommand('scripture:status')); + } + + /** + * Verifies maintenance commands register when native services can compose. + * + * @return void + * @since 1.0.0 + */ + public function testCreatesCompleteApplicationWithNativeRuntime(): void + { + if (\extension_loaded('getbiblesword')) { + self::markTestSkipped('The deterministic application test requires the extension to be absent.'); + } + + require_once __DIR__ . '/../Setup/NativeSwordFunctionOverrides.php'; + + if (!\class_exists(\GetBible\Sword\Engine::class, false)) { + require_once __DIR__ . '/../../Fixtures/Native/GetBible/Sword/Engine.php'; + } + + NativeRuntimeState::$extensionLoaded = true; + NativeRuntimeState::$engineClassAvailable = true; + NativeRuntimeState::$extensionVersion = '0.1.0'; + NativeRuntimeState::$throwMetadata = false; + + try { + $container = ContainerFactory::create(Configuration::fromEnvironment([ + 'cache_path' => '/tmp/getbible-scripture-complete-console', + ])); + $application = ConsoleApplicationFactory::create($container); + + self::assertTrue($application->hasCommand('scripture:doctor')); + self::assertTrue($application->hasCommand('scripture:setup')); + self::assertTrue($application->hasCommand('scripture:initialize')); + self::assertTrue($application->hasCommand('scripture:refresh')); + self::assertTrue($application->hasCommand('scripture:status')); + } finally { + NativeRuntimeState::reset(); + } + } +} diff --git a/tests/Unit/Console/DoctorCommandDiagnosticsTest.php b/tests/Unit/Console/DoctorCommandDiagnosticsTest.php new file mode 100644 index 0000000..74ddbe3 --- /dev/null +++ b/tests/Unit/Console/DoctorCommandDiagnosticsTest.php @@ -0,0 +1,133 @@ +createMock(SetupServiceInterface::class); + $service->method('inspect')->willReturn($this->readyRuntime()); + $service->expects(self::once()) + ->method('configuration') + ->with(null) + ->willReturn(Configuration::fromEnvironment(['cache_path' => '/srv/cache'])); + $input = new ArrayInput([]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = (new DoctorCommand($service))->execute($input, $output); + $display = $output->fetch(); + + self::assertSame(0, $exitCode); + self::assertStringContainsString('Scripture is ready.', $display); + self::assertStringContainsString('"operation": "doctor"', $display); + } + + /** + * Verifies invalid configuration paths become deterministic diagnostics. + * + * @return void + * @since 1.0.0 + */ + public function testDoctorReportsConfigurationPathFailure(): void + { + $service = $this->createMock(SetupServiceInterface::class); + $service->method('inspect')->willReturn($this->readyRuntime()); + $service->expects(self::never())->method('configuration'); + $input = new ArrayInput([ + '--config' => 'relative.json', + '--json' => true, + ]); + $input->setInteractive(false); + $output = new BufferedOutput(); + + $exitCode = (new DoctorCommand($service))->execute($input, $output); + $payload = StructuredData::object( + json_decode($output->fetch(), true, 32, JSON_THROW_ON_ERROR), + 'Doctor command response', + ); + $errors = StructuredData::list($payload['errors'] ?? null, 'Doctor command errors'); + + self::assertSame(1, $exitCode); + self::assertFalse($payload['succeeded'] ?? true); + self::assertSame('relative.json', $payload['configuration_path'] ?? null); + self::assertNull($payload['configuration'] ?? null); + self::assertIsString($errors[0] ?? null); + self::assertStringContainsString('must be absolute', $errors[0]); + } + + /** + * Verifies incompatible runtime produces a human-readable failure heading. + * + * @return void + * @since 1.0.0 + */ + public function testInteractiveDoctorReportsRuntimeFailure(): void + { + $service = $this->createStub(SetupServiceInterface::class); + $service->method('inspect')->willReturn(new RuntimePrerequisiteReport( + false, + false, + null, + null, + null, + null, + )); + $service->method('configuration')->willReturn( + Configuration::fromEnvironment(['cache_path' => '/srv/cache']), + ); + $input = new ArrayInput([]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = (new DoctorCommand($service))->execute($input, $output); + $display = $output->fetch(); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('Scripture is not ready.', $display); + self::assertStringContainsString('"ready": false', $display); + } + + /** + * Creates a compatible runtime report. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + private function readyRuntime(): RuntimePrerequisiteReport + { + return new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + RuntimePrerequisiteReport::EXPECTED_ABI, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + } +} diff --git a/tests/Unit/Console/InteractiveSetupCommandTest.php b/tests/Unit/Console/InteractiveSetupCommandTest.php new file mode 100644 index 0000000..ebb8948 --- /dev/null +++ b/tests/Unit/Console/InteractiveSetupCommandTest.php @@ -0,0 +1,286 @@ + '/current/cache', + 'modules' => ['OLD'], + ]); + $runtime = $this->readyRuntime(); + $captured = null; + $service = $this->createMock(SetupServiceInterface::class); + $service->expects(self::once()) + ->method('configuration') + ->with($path) + ->willReturn($current); + $service->method('inspect')->willReturn($runtime); + $service->expects(self::once()) + ->method('apply') + ->willReturnCallback( + function (SetupRequest $request) use (&$captured, $path, $runtime): SetupResult { + $captured = $request; + $configuration = Configuration::fromEnvironment($request->values()); + + return new SetupResult( + $path, + $configuration, + $runtime, + $request->warm(), + null, + ); + }, + ); + $answers = [ + '/answers/sword', + '/answers/cache', + 'P2D', + '50', + 'KJV, WEB', + false, + false, + ]; + $helper = $this->createMock(QuestionHelper::class); + $helper->expects(self::exactly(7)) + ->method('ask') + ->willReturnCallback(static function () use (&$answers): mixed { + return array_shift($answers); + }); + $command = new SetupCommand($service); + $command->setHelperSet(new HelperSet(['question' => $helper])); + $input = new ArrayInput(['--config' => $path]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = $command->execute($input, $output); + $display = $output->fetch(); + + self::assertSame(0, $exitCode, $display); + + if (!$captured instanceof SetupRequest) { + self::fail('Interactive setup did not submit a setup request.'); + } + + self::assertFalse($captured->warm()); + self::assertSame([ + 'module_path' => '/answers/sword', + 'cache_path' => '/answers/cache', + 'refresh_interval' => 'P2D', + 'lock_timeout' => '50', + 'modules' => 'KJV, WEB', + 'auto_refresh' => false, + ], $captured->values()); + self::assertStringContainsString('Scripture setup completed.', $display); + } + + /** + * Verifies contradictory automatic-refresh options fail before applying. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsContradictoryAutoRefreshOptions(): void + { + $service = $this->createMock(SetupServiceInterface::class); + $service->method('configuration')->willReturn( + Configuration::fromEnvironment(['cache_path' => '/srv/cache']), + ); + $service->method('inspect')->willReturn($this->readyRuntime()); + $service->expects(self::never())->method('apply'); + $input = new ArrayInput([ + '--config' => '/tmp/getbible-scripture-conflict.json', + '--auto-refresh' => true, + '--no-auto-refresh' => true, + '--json' => true, + ]); + $input->setInteractive(false); + $output = new BufferedOutput(); + + $exitCode = (new SetupCommand($service))->execute($input, $output); + $payload = json_decode($output->fetch(), true, 32, JSON_THROW_ON_ERROR); + + self::assertSame(1, $exitCode); + self::assertIsArray($payload); + self::assertSame( + ['--auto-refresh and --no-auto-refresh cannot be combined.'], + $payload['errors'] ?? null, + ); + } + + /** + * Verifies setup can request its configuration path interactively. + * + * @return void + * @since 1.0.0 + */ + public function testPromptsForMissingConfigurationPath(): void + { + $path = '/tmp/getbible-scripture-prompted.json'; + $runtime = $this->readyRuntime(); + $captured = null; + $service = $this->createMock(SetupServiceInterface::class); + $service->expects(self::once()) + ->method('configuration') + ->with($path) + ->willReturn(Configuration::fromEnvironment(['cache_path' => '/current/cache'])); + $service->expects(self::once()) + ->method('apply') + ->willReturnCallback( + function (SetupRequest $request) use (&$captured, $path, $runtime): SetupResult { + $captured = $request; + + return new SetupResult( + $path, + Configuration::fromEnvironment($request->values()), + $runtime, + $request->warm(), + null, + ); + }, + ); + $answers = [ + $path, + '/answers/sword', + '/answers/cache', + 'P3D', + '20', + null, + ]; + $helper = $this->createMock(QuestionHelper::class); + $helper->expects(self::exactly(6)) + ->method('ask') + ->willReturnCallback(static function () use (&$answers): mixed { + return array_shift($answers); + }); + $command = new SetupCommand($service); + $command->setHelperSet(new HelperSet(['question' => $helper])); + $input = new ArrayInput([ + '--auto-refresh' => true, + '--no-warm' => true, + '--json' => true, + ]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = $command->execute($input, $output); + + self::assertSame(0, $exitCode, $output->fetch()); + + if (!$captured instanceof SetupRequest) { + self::fail('Prompted setup did not submit a setup request.'); + } + + self::assertSame($path, $captured->configurationPath()); + self::assertTrue($captured->values()['auto_refresh'] ?? false); + self::assertSame([], $captured->values()['modules'] ?? null); + self::assertFalse($captured->warm()); + } + + /** + * Verifies a non-text interactive answer becomes a stable command error. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsNonTextInteractiveAnswer(): void + { + $service = $this->createStub(SetupServiceInterface::class); + $service->method('inspect')->willReturn($this->readyRuntime()); + $helper = $this->createMock(QuestionHelper::class); + $helper->expects(self::once())->method('ask')->willReturn(42); + $command = new SetupCommand($service); + $command->setHelperSet(new HelperSet(['question' => $helper])); + $input = new ArrayInput(['--json' => true]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = $command->execute($input, $output); + $payload = json_decode($output->fetch(), true, 32, JSON_THROW_ON_ERROR); + + self::assertSame(1, $exitCode); + self::assertIsArray($payload); + self::assertSame( + ['The "Absolute Scripture configuration file path" answer must be text.'], + $payload['errors'] ?? null, + ); + } + + /** + * Verifies an incorrectly bound question helper fails with a clear error. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsUnavailableQuestionHelper(): void + { + $service = $this->createStub(SetupServiceInterface::class); + $service->method('inspect')->willReturn($this->readyRuntime()); + $helpers = new HelperSet(); + $helpers->set(new FormatterHelper(), 'question'); + $command = new SetupCommand($service); + $command->setHelperSet($helpers); + $input = new ArrayInput(['--json' => true]); + $input->setInteractive(true); + $output = new BufferedOutput(); + + $exitCode = $command->execute($input, $output); + $payload = json_decode($output->fetch(), true, 32, JSON_THROW_ON_ERROR); + + self::assertSame(1, $exitCode); + self::assertIsArray($payload); + self::assertSame( + ['The Joomla Console question helper is unavailable.'], + $payload['errors'] ?? null, + ); + } + + /** + * Creates a compatible runtime report. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + private function readyRuntime(): RuntimePrerequisiteReport + { + return new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + RuntimePrerequisiteReport::EXPECTED_ABI, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + } +} diff --git a/tests/Unit/Console/MaintenanceCommandsTest.php b/tests/Unit/Console/MaintenanceCommandsTest.php new file mode 100644 index 0000000..1283e1a --- /dev/null +++ b/tests/Unit/Console/MaintenanceCommandsTest.php @@ -0,0 +1,199 @@ +createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::once()) + ->method('initialize') + ->with(['KJV', 'WEB'], true) + ->willReturn($this->maintenanceResult('initialize', MaintenanceModuleResult::STATUS_READY)); + + [$exitCode, $payload] = $this->execute(new InitializeCommand($maintenance), [ + '--module' => ['KJV', 'WEB'], + '--all' => true, + ]); + + self::assertSame(0, $exitCode); + self::assertSame('initialize', $payload['operation'] ?? null); + self::assertTrue($payload['succeeded'] ?? false); + } + + /** + * Verifies initialize returns a failing process status for module failure. + * + * @return void + * @since 1.0.0 + */ + public function testInitializeReturnsFailureExitCode(): void + { + $maintenance = $this->createStub(MaintenanceServiceInterface::class); + $maintenance->method('initialize')->willReturn( + $this->maintenanceResult('initialize', MaintenanceModuleResult::STATUS_FAILED), + ); + + [$exitCode, $payload] = $this->execute(new InitializeCommand($maintenance), []); + + self::assertSame(1, $exitCode); + self::assertFalse($payload['succeeded'] ?? true); + } + + /** + * Verifies a forced refresh uses the unconditional service operation. + * + * @return void + * @since 1.0.0 + */ + public function testRefreshDispatchesForcedOperation(): void + { + $maintenance = $this->createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::once()) + ->method('refresh') + ->with(['KJV']) + ->willReturn($this->maintenanceResult('refresh', MaintenanceModuleResult::STATUS_REFRESHED)); + $maintenance->expects(self::never())->method('refreshIfDue'); + + [$exitCode, $payload] = $this->execute(new RefreshCommand($maintenance), [ + '--module' => ['KJV'], + ]); + + self::assertSame(0, $exitCode); + self::assertSame('refresh', $payload['operation'] ?? null); + } + + /** + * Verifies interval-gated refresh uses the due-aware service operation. + * + * @return void + * @since 1.0.0 + */ + public function testRefreshDispatchesIfDueOperation(): void + { + $maintenance = $this->createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::never())->method('refresh'); + $maintenance->expects(self::once()) + ->method('refreshIfDue') + ->with(['WEB']) + ->willReturn($this->maintenanceResult( + 'refresh-if-due', + MaintenanceModuleResult::STATUS_SKIPPED, + )); + + [$exitCode, $payload] = $this->execute(new RefreshCommand($maintenance), [ + '--module' => ['WEB'], + '--if-due' => true, + ]); + + self::assertSame(0, $exitCode); + self::assertSame('refresh-if-due', $payload['operation'] ?? null); + } + + /** + * Verifies status emits the service's complete health report. + * + * @return void + * @since 1.0.0 + */ + public function testStatusEmitsOperationalState(): void + { + $status = new MaintenanceStatus( + new \DateTimeImmutable('2026-07-26T12:00:00+00:00'), + false, + new \DateTimeImmutable('2026-08-26T12:00:00+00:00'), + MaintenanceState::empty(), + ProvisioningCapabilities::abiV1(), + false, + ['KJV'], + ['KJV', 'WEB'], + ); + $maintenance = $this->createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::once())->method('status')->willReturn($status); + + [$exitCode, $payload] = $this->execute(new StatusCommand($maintenance), []); + + self::assertSame(0, $exitCode); + self::assertFalse($payload['due'] ?? true); + self::assertSame(['KJV'], $payload['configured_modules'] ?? null); + self::assertSame(['KJV', 'WEB'], $payload['installed_modules'] ?? null); + } + + /** + * Executes one Joomla command and decodes its JSON output. + * + * @param AbstractCommand $command Command under test. + * @param array> $parameters Input parameters. + * + * @return array{int, array} + * @since 1.0.0 + */ + private function execute(AbstractCommand $command, array $parameters): array + { + $input = new ArrayInput($parameters); + $input->setInteractive(false); + $output = new BufferedOutput(); + $exitCode = $command->execute($input, $output); + $payload = StructuredData::object( + json_decode($output->fetch(), true, 32, JSON_THROW_ON_ERROR), + 'Maintenance command response', + ); + + return [$exitCode, $payload]; + } + + /** + * Creates a deterministic maintenance outcome. + * + * @param string $operation Operation name. + * @param string $status Module status. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + private function maintenanceResult(string $operation, string $status): MaintenanceResult + { + $time = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + return new MaintenanceResult( + $operation, + $time, + $time, + true, + [new MaintenanceModuleResult('KJV', $status)], + null, + [], + ); + } +} diff --git a/tests/Unit/Console/ModuleOptionTest.php b/tests/Unit/Console/ModuleOptionTest.php new file mode 100644 index 0000000..d36299d --- /dev/null +++ b/tests/Unit/Console/ModuleOptionTest.php @@ -0,0 +1,60 @@ +expectException(\UnexpectedValueException::class); + + ModuleOption::normalize($value); + } + + /** + * Supplies invalid option shapes. + * + * @return iterable + * @since 1.0.0 + */ + public static function invalidValueProvider(): iterable + { + yield 'null' => [null]; + yield 'scalar' => ['KJV']; + yield 'non-string item' => [['KJV', 1]]; + } +} diff --git a/tests/Unit/Console/SetupCommandsTest.php b/tests/Unit/Console/SetupCommandsTest.php new file mode 100644 index 0000000..e8e1a4b --- /dev/null +++ b/tests/Unit/Console/SetupCommandsTest.php @@ -0,0 +1,222 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-setup-command-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes isolated command files. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $path = $this->directory . '/configuration.json'; + + if (is_file($path)) { + unlink($path); + } + + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + /** + * Verifies non-interactive setup writes JSON configuration without warming. + * + * @return void + * @since 1.0.0 + */ + public function testNonInteractiveSetupIsDeterministic(): void + { + $path = $this->directory . '/configuration.json'; + [$exitCode, $display] = $this->execute( + new SetupCommand($this->service()), + [ + '--config' => $path, + '--cache-path' => '/srv/scripture-cache', + '--module' => ['KJV', 'WEB'], + '--no-auto-refresh' => true, + '--no-warm' => true, + '--json' => true, + ], + ); + $payload = StructuredData::object( + json_decode($display, true, 32, JSON_THROW_ON_ERROR), + 'Setup command response', + ); + $configuration = StructuredData::object( + $payload['configuration'] ?? null, + 'Setup command configuration', + ); + + self::assertSame(0, $exitCode); + self::assertSame('setup', $payload['operation'] ?? null); + self::assertTrue($payload['succeeded'] ?? false); + self::assertSame(['KJV', 'WEB'], $configuration['modules'] ?? null); + self::assertFalse($configuration['auto_refresh'] ?? true); + self::assertFalse($payload['warm_requested'] ?? true); + self::assertFileExists($path); + } + + /** + * Verifies doctor emits deterministic JSON and remains read-only. + * + * @return void + * @since 1.0.0 + */ + public function testDoctorReportsRuntimeAndConfiguration(): void + { + $path = $this->directory . '/configuration.json'; + $service = $this->service(); + $this->execute( + new SetupCommand($service), + [ + '--config' => $path, + '--cache-path' => '/srv/scripture-cache', + '--no-warm' => true, + '--json' => true, + ], + ); + $before = file_get_contents($path); + [$exitCode, $display] = $this->execute( + new DoctorCommand($service), + [ + '--config' => $path, + '--json' => true, + ], + ); + $payload = StructuredData::object( + json_decode($display, true, 32, JSON_THROW_ON_ERROR), + 'Doctor command response', + ); + $runtime = StructuredData::object( + $payload['runtime'] ?? null, + 'Doctor command runtime', + ); + $configuration = StructuredData::object( + $payload['configuration'] ?? null, + 'Doctor command configuration', + ); + + self::assertSame(0, $exitCode); + self::assertSame('doctor', $payload['operation'] ?? null); + self::assertTrue($runtime['ready'] ?? false); + self::assertSame('/srv/scripture-cache', $configuration['cache_path'] ?? null); + self::assertSame($before, file_get_contents($path)); + } + + /** + * Verifies non-interactive setup refuses to invent a configuration path. + * + * @return void + * @since 1.0.0 + */ + public function testNonInteractiveSetupRequiresConfigurationPath(): void + { + $previous = getenv('GETBIBLE_SCRIPTURE_CONFIG_PATH'); + putenv('GETBIBLE_SCRIPTURE_CONFIG_PATH'); + + try { + [$exitCode, $display] = $this->execute( + new SetupCommand($this->service()), + [ + '--cache-path' => '/srv/scripture-cache', + '--no-warm' => true, + '--json' => true, + ], + ); + $payload = json_decode($display, true, 32, JSON_THROW_ON_ERROR); + + self::assertSame(1, $exitCode); + self::assertIsArray($payload); + self::assertFalse($payload['succeeded'] ?? true); + self::assertSame( + ['Supply --config or set GETBIBLE_SCRIPTURE_CONFIG_PATH.'], + $payload['errors'] ?? null, + ); + } finally { + if (is_string($previous)) { + putenv('GETBIBLE_SCRIPTURE_CONFIG_PATH=' . $previous); + } else { + putenv('GETBIBLE_SCRIPTURE_CONFIG_PATH'); + } + } + } + + /** + * Executes a Joomla command through its public input/output boundary. + * + * @param AbstractCommand $command Command under test. + * @param array> $parameters Input parameters. + * + * @return array{int, string} + * @since 1.0.0 + */ + private function execute(AbstractCommand $command, array $parameters): array + { + $input = new ArrayInput($parameters); + $input->setInteractive(false); + $output = new BufferedOutput(); + $exitCode = $command->execute($input, $output); + + return [$exitCode, $output->fetch()]; + } + + /** + * Creates a deterministic setup service. + * + * @return SetupService + * @since 1.0.0 + */ + private function service(): SetupService + { + return new SetupService( + new JsonConfigurationRepositoryFactory(), + new CommandRuntimeInspector(), + new CommandApplicationWarmer(), + ); + } +} diff --git a/tests/Unit/Contract/ByteValueTest.php b/tests/Unit/Contract/ByteValueTest.php index 37bcca0..9b46dd7 100644 --- a/tests/Unit/Contract/ByteValueTest.php +++ b/tests/Unit/Contract/ByteValueTest.php @@ -8,6 +8,7 @@ use GetBible\Scripture\Contract\ByteValue; use GetBible\Scripture\Exception\ContractException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** @@ -34,8 +35,14 @@ public function testVerifiedByteValueExposesAuthoritativeBytes(): void ]); self::assertSame('KJV', $value->bytes()); + self::assertSame('KJV', $value->utf8()); self::assertSame('KJV', $value->requireUtf8()); self::assertSame(3, $value->size()); + self::assertSame( + 'f98326ec7971053443d80268b911680a0eec8d4ead3b1d67445b7d534f1b5b2f', + $value->sha256(), + ); + self::assertSame('S0pW', $value->toArray()['base64']); } /** @@ -56,4 +63,67 @@ public function testDigestMismatchIsRejected(): void 'utf8' => 'KJV', ]); } + + /** + * Verifies opaque bytes remain accessible without claiming a UTF-8 view. + * + * @return void + * @since 1.0.0 + */ + public function testMissingUtf8ProjectionIsExplicit(): void + { + $value = ByteValue::fromArray([ + 'base64' => '/w==', + 'encoding' => 'base64', + 'sha256' => hash('sha256', "\xff"), + 'size' => 1, + ]); + + self::assertNull($value->utf8()); + self::assertSame("\xff", $value->bytes()); + + $this->expectException(\UnexpectedValueException::class); + $value->requireUtf8(); + } + + /** + * Verifies malformed and internally inconsistent envelopes are rejected. + * + * @param array $envelope Invalid envelope. + * + * @return void + * @since 1.0.0 + */ + #[DataProvider('invalidEnvelopes')] + public function testInvalidEnvelopeIsRejected(array $envelope): void + { + $this->expectException(ContractException::class); + ByteValue::fromArray($envelope, 'fixture'); + } + + /** + * Returns independently invalid byte envelopes. + * + * @return iterable}> + * @since 1.0.0 + */ + public static function invalidEnvelopes(): iterable + { + $valid = [ + 'base64' => 'S0pW', + 'encoding' => 'base64', + 'sha256' => hash('sha256', 'KJV'), + 'size' => 3, + 'utf8' => 'KJV', + ]; + + yield 'unknown member' => [$valid + ['extra' => true]]; + yield 'invalid encoding' => [array_replace($valid, ['encoding' => 'hex'])]; + yield 'invalid digest shape' => [array_replace($valid, ['sha256' => 'invalid'])]; + yield 'negative size' => [array_replace($valid, ['size' => -1])]; + yield 'non-string UTF-8' => [array_replace($valid, ['utf8' => 1])]; + yield 'non-canonical Base64' => [array_replace($valid, ['base64' => 'S0pW='])]; + yield 'wrong size' => [array_replace($valid, ['size' => 4])]; + yield 'wrong UTF-8 projection' => [array_replace($valid, ['utf8' => 'kjv'])]; + } } diff --git a/tests/Unit/Contract/ContractV1ValidatorTest.php b/tests/Unit/Contract/ContractV1ValidatorTest.php index e968842..0e683a4 100644 --- a/tests/Unit/Contract/ContractV1ValidatorTest.php +++ b/tests/Unit/Contract/ContractV1ValidatorTest.php @@ -7,6 +7,7 @@ namespace GetBible\Scripture\Tests\Unit\Contract; use GetBible\Scripture\Contract\ContractV1Validator; +use GetBible\Scripture\Contract\RecordObserverInterface; use GetBible\Scripture\Contract\StructuredData; use GetBible\Scripture\Exception\ContractException; use PHPUnit\Framework\TestCase; @@ -43,6 +44,88 @@ public function testCompleteFixtureIsAccepted(): void ); } + /** + * Verifies validated records are reported with exact stream locations. + * + * @return void + * @since 1.0.0 + */ + public function testObserverReceivesEveryValidatedRecord(): void + { + $observer = new class () implements RecordObserverInterface { + /** + * Observed record types. + * + * @var list + */ + public array $types = []; + + /** + * Observed byte offsets. + * + * @var list + */ + public array $offsets = []; + + /** + * Observed serialized lengths. + * + * @var list + */ + public array $lengths = []; + + /** + * Captures one validated record. + * + * @param array $record Validated record. + * @param int $offset Record offset. + * @param int $length Serialized length. + * + * @return void + */ + public function onRecord(array $record, int $offset, int $length): void + { + $type = $record['type'] ?? null; + + if (!is_string($type)) { + throw new \LogicException('A validated record must have a type.'); + } + + $this->types[] = $type; + $this->offsets[] = $offset; + $this->lengths[] = $length; + } + }; + $stream = fopen(__DIR__ . '/../../Fixtures/test-bible.ndjson', 'rb'); + self::assertIsResource($stream); + + try { + (new ContractV1Validator())->validate($stream, $observer); + } finally { + fclose($stream); + } + + self::assertSame( + ['header', 'module', 'config_entry', 'entry', 'entry', 'footer'], + $observer->types, + ); + self::assertSame(0, $observer->offsets[0]); + self::assertSame(filesize(__DIR__ . '/../../Fixtures/test-bible.ndjson'), array_sum($observer->lengths)); + } + + /** + * Verifies validation accepts only readable stream resources. + * + * @return void + * @since 1.0.0 + */ + public function testNonStreamInputIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + (new \ReflectionMethod(ContractV1Validator::class, 'validate')) + ->invoke(new ContractV1Validator(), null); + } + /** * Verifies that changing one authoritative field invalidates the footer. * @@ -177,9 +260,31 @@ public function testChunkAfterShortChunkIsRejected(): void * @since 0.1.0 */ public function testUnsafeArtifactPathIsRejected(): void + { + foreach (['../test.dat', '/absolute.dat', 'directory//test.dat', "test\0.dat"] as $path) { + $records = $this->fixtureRecords(); + array_push($records, ...$this->regularArtifactRecords($path, 'content')); + $stream = $this->stream($records); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + } + + /** + * Verifies that list operations cannot contain extract-only records. + * + * @return void + * @since 0.1.0 + */ + public function testListStreamRejectsExtractRecords(): void { $records = $this->fixtureRecords(); - array_push($records, ...$this->regularArtifactRecords('../test.dat', 'content')); + $records[0]['command'] = 'list'; + unset($records[0]['artifact_chunk_size']); $stream = $this->stream($records); $this->expectException(ContractException::class); @@ -192,22 +297,469 @@ public function testUnsafeArtifactPathIsRejected(): void } /** - * Verifies that list operations cannot contain extract-only records. + * Verifies raw configuration sources and diagnostics are validated. * * @return void - * @since 0.1.0 + * @since 1.0.0 */ - public function testListStreamRejectsExtractRecords(): void + public function testAcceptsConfigurationSourceAndInformationDiagnostic(): void { $records = $this->fixtureRecords(); - $records[0]['command'] = 'list'; - unset($records[0]['artifact_chunk_size']); + array_splice($records, 2, 0, [[ + 'ordinal' => 0, + 'path' => $this->byteValue('mods.d/test.conf'), + 'raw' => $this->byteValue('[TestBible]'), + 'type' => 'config_source', + ]]); + $records[] = [ + 'code' => 'test.information', + 'message' => $this->byteValue('Validated information'), + 'severity' => 'info', + 'type' => 'diagnostic', + ]; $stream = $this->stream($records); - $this->expectException(ContractException::class); + try { + $result = (new ContractV1Validator())->validate($stream); + } finally { + fclose($stream); + } + + $footer = $result->footer(); + $diagnostics = StructuredData::object($footer['diagnostics'] ?? null, 'Footer diagnostics'); + $counts = StructuredData::object($footer['counts'] ?? null, 'Footer counts'); + self::assertSame(1, $diagnostics['info']); + self::assertSame(1, $counts['config_source']); + } + + /** + * Verifies generic SWORD keys remain valid for non-Bible entry consumers. + * + * @return void + * @since 1.0.0 + */ + public function testAcceptsGenericSwordKeyEntryScope(): void + { + $records = $this->fixtureRecords(); + $records[3]['scope'] = ['index' => 1, 'type' => 'sword_key']; + $stream = $this->stream($records); try { - (new ContractV1Validator())->validate($stream); + $result = (new ContractV1Validator())->validate($stream); + } finally { + fclose($stream); + } + + self::assertSame(2, $result->footer()['entries']); + } + + /** + * Verifies directory and symlink artifact semantics. + * + * @return void + * @since 1.0.0 + */ + public function testAcceptsDirectoryAndSymlinkArtifacts(): void + { + $records = $this->fixtureRecords(); + array_push($records, ...$this->directoryArtifactRecords(0, 'modules/')); + array_push($records, ...$this->symlinkArtifactRecords(1, 'modules/current', 'TestBible')); + $stream = $this->stream($records); + + try { + $result = (new ContractV1Validator())->validate($stream); + } finally { + fclose($stream); + } + + self::assertSame(2, $result->footer()['artifacts']); + self::assertSame(strlen('TestBible'), $result->footer()['artifact_bytes']); + } + + /** + * Verifies unreadable, truncated, and malformed stream input is rejected. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsUnreadableTruncatedAndMalformedStreams(): void + { + $path = tempnam(sys_get_temp_dir(), 'getbible-contract-'); + self::assertIsString($path); + $unreadable = fopen($path, 'wb'); + self::assertIsResource($unreadable); + + try { + $this->assertRejected($unreadable, \InvalidArgumentException::class); + } finally { + fclose($unreadable); + unlink($path); + } + + foreach (['{"type":"header"}', "{invalid json}\n"] as $contents) { + $stream = $this->rawStream($contents); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + + $badSequence = $this->rawStream("{\"sequence\":1,\"type\":\"header\"}\n"); + + try { + $this->assertRejected($badSequence); + } finally { + fclose($badSequence); + } + } + + /** + * Verifies complete framing requires one terminal footer. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMissingFooterAndRecordAfterFooter(): void + { + $records = $this->fixtureRecords(); + $header = $records[0]; + $header['sequence'] = 0; + $missingFooter = $this->rawStream( + json_encode( + $header, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n", + ); + + try { + $this->assertRejected($missingFooter); + } finally { + fclose($missingFooter); + } + + $complete = $this->stream($records); + $contents = stream_get_contents($complete); + fclose($complete); + self::assertIsString($contents); + $afterFooter = [ + 'code' => 'late.record', + 'message' => $this->byteValue('Too late'), + 'sequence' => count($records) + 1, + 'severity' => 'info', + 'type' => 'diagnostic', + ]; + $lateStream = $this->rawStream( + $contents . json_encode( + $afterFooter, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n", + ); + + try { + $this->assertRejected($lateStream); + } finally { + fclose($lateStream); + } + } + + /** + * Verifies record ordering, type, and extract cardinality invariants. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsInvalidRecordOrderingAndCardinality(): void + { + $fixture = $this->fixtureRecords(); + $duplicateModule = $fixture; + array_splice($duplicateModule, 2, 0, [$fixture[1]]); + $missingModule = [$fixture[0]]; + $beforeModule = [$fixture[0], $fixture[2]]; + $unsupported = $fixture; + $unsupported[2]['type'] = 'future_record'; + $notHeader = $fixture; + array_shift($notHeader); + $badHeader = $fixture; + $badHeader[0]['command'] = 'unknown'; + $incompatibleHeader = $fixture; + $incompatibleHeader[0]['contract'] = 'getbiblesword.ndjson/v2'; + $invalidChunkSize = $fixture; + $invalidChunkSize[0]['artifact_chunk_size'] = 100; + $secondHeader = [$fixture[0], $fixture[0]]; + $outOfPhase = $fixture; + [$configEntry] = array_splice($outOfPhase, 2, 1); + array_splice($outOfPhase, 4, 0, [$configEntry]); + + foreach ( + [ + $duplicateModule, + $missingModule, + $beforeModule, + $unsupported, + $notHeader, + $badHeader, + $incompatibleHeader, + $invalidChunkSize, + $secondHeader, + $outOfPhase, + ] as $records + ) { + $stream = $this->stream($records); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + } + + /** + * Verifies config, entry, and diagnostic record members are strict. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedExtractRecords(): void + { + $fixture = $this->fixtureRecords(); + $badConfigSource = $fixture; + array_splice($badConfigSource, 2, 0, [[ + 'ordinal' => 1, + 'path' => $this->byteValue('mods.d/test.conf'), + 'raw' => $this->byteValue('config'), + 'type' => 'config_source', + ]]); + $badConfigEntry = $fixture; + $badConfigEntry[2]['ordinal'] = 1; + $badEntry = $fixture; + $badEntry[3]['ordinal'] = -1; + $badGenericScope = $fixture; + $badGenericScope[3]['scope'] = ['index' => -1, 'type' => 'sword_key']; + $unknownScope = $fixture; + $unknownScope[3]['scope'] = ['index' => 0, 'type' => 'unknown']; + $badSegments = $fixture; + $badSegments[3]['annotation_segments'] = [null]; + $mismatchedSegments = $fixture; + $mismatchedSegments[3]['annotation_segments'] = []; + $badProjection = $fixture; + $badProjection[3]['stripped'] = null; + $unexpectedProjection = $fixture; + $unexpectedProjection[3]['projections_available'] = false; + $badDiagnostic = $fixture; + $badDiagnostic[] = [ + 'code' => null, + 'message' => $this->byteValue('Bad diagnostic'), + 'severity' => 'info', + 'type' => 'diagnostic', + ]; + + foreach ( + [ + $badConfigSource, + $badConfigEntry, + $badEntry, + $badGenericScope, + $unknownScope, + $badSegments, + $mismatchedSegments, + $badProjection, + $unexpectedProjection, + $badDiagnostic, + ] as $records + ) { + $stream = $this->stream($records); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + } + + /** + * Verifies incomplete, duplicated, and invalid artifact groups fail. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsInvalidArtifactGroups(): void + { + $fixture = $this->fixtureRecords(); + $chunkWithoutBegin = $fixture; + $chunkWithoutBegin[] = $this->regularArtifactRecords('test.dat', 'x')[1]; + $endWithoutBegin = $fixture; + $endWithoutBegin[] = $this->regularArtifactRecords('test.dat', 'x')[2]; + $nestedBegin = $fixture; + $nestedBegin[] = $this->regularArtifactRecords('first.dat', 'x')[0]; + $nestedBegin[] = $this->regularArtifactRecords('second.dat', 'y')[0]; + $unfinished = $fixture; + $unfinished[] = $this->regularArtifactRecords('test.dat', 'x')[0]; + $duplicatePath = $fixture; + array_push($duplicatePath, ...$this->directoryArtifactRecords(0, 'same/')); + array_push($duplicatePath, ...$this->directoryArtifactRecords(1, 'same/')); + $invalidBegin = $fixture; + $invalidBegin[] = [ + 'artifact_id' => 0, + 'file_type' => 'device', + 'mode' => 420, + 'path' => $this->byteValue('device'), + 'role' => 'module_data', + 'type' => 'artifact_begin', + ]; + $invalidRegularSize = $fixture; + $begin = $this->regularArtifactRecords('invalid-size.dat', 'x')[0]; + $begin['size_expected'] = -1; + $invalidRegularSize[] = $begin; + $invalidSymlink = $fixture; + array_push($invalidSymlink, ...$this->symlinkArtifactRecords(0, 'link', "\0")); + $emptyChunk = $fixture; + $emptyArtifact = $this->regularArtifactRecords('empty.dat', ''); + $emptyArtifact[1]['data'] = $this->byteValue(''); + array_push($emptyChunk, ...$emptyArtifact); + $invalidStable = $fixture; + $artifact = $this->regularArtifactRecords('unstable.dat', 'content'); + $artifact[2]['stable'] = 'yes'; + array_push($invalidStable, ...$artifact); + $invalidSize = $fixture; + $artifact = $this->regularArtifactRecords('wrong-size.dat', 'content'); + $artifact[2]['size'] = 6; + array_push($invalidSize, ...$artifact); + $invalidEndShape = $fixture; + $artifact = $this->regularArtifactRecords('invalid-end.dat', 'content'); + $artifact[2]['sha256'] = 'invalid'; + array_push($invalidEndShape, ...$artifact); + $oversizedChunk = $fixture; + $oversized = str_repeat('x', 1048577); + array_push($oversizedChunk, ...$this->regularArtifactRecords('oversized.dat', $oversized)); + $badEnd = $fixture; + $artifact = $this->regularArtifactRecords('bad.dat', 'content'); + $artifact[2]['sha256'] = str_repeat('0', 64); + array_push($badEnd, ...$artifact); + + foreach ( + [ + $chunkWithoutBegin, + $endWithoutBegin, + $nestedBegin, + $unfinished, + $duplicatePath, + $invalidBegin, + $invalidRegularSize, + $invalidSymlink, + $emptyChunk, + $invalidStable, + $invalidSize, + $invalidEndShape, + $oversizedChunk, + $badEnd, + ] as $records + ) { + $stream = $this->stream($records); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + } + + /** + * Verifies footer member shapes, names, totals, and success semantics. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedFooterVariants(): void + { + $records = $this->fixtureRecords(); + /** @var list): array> $mutations */ + $mutations = [ + static function (array $footer): array { + $footer['entries'] = -1; + + return $footer; + }, + static function (array $footer): array { + $counts = StructuredData::object($footer['counts'] ?? null, 'Footer counts'); + $counts['entry'] = 'two'; + $footer['counts'] = $counts; + + return $footer; + }, + static function (array $footer): array { + $counts = StructuredData::object($footer['counts'] ?? null, 'Footer counts'); + $counts['footer'] = 1; + $footer['counts'] = $counts; + + return $footer; + }, + static function (array $footer): array { + $diagnostics = StructuredData::object( + $footer['diagnostics'] ?? null, + 'Footer diagnostics', + ); + $diagnostics['notice'] = 0; + $footer['diagnostics'] = $diagnostics; + + return $footer; + }, + static function (array $footer): array { + $diagnostics = StructuredData::object( + $footer['diagnostics'] ?? null, + 'Footer diagnostics', + ); + $diagnostics['warning'] = -1; + $footer['diagnostics'] = $diagnostics; + + return $footer; + }, + static function (array $footer): array { + $footer['artifact_bytes'] = 1; + + return $footer; + }, + static function (array $footer): array { + $footer['success'] = false; + + return $footer; + }, + ]; + + foreach ($mutations as $mutation) { + $stream = $this->mutateFooter($this->stream($records), $mutation); + + try { + $this->assertRejected($stream); + } finally { + fclose($stream); + } + } + } + + /** + * Verifies an error diagnostic produces a failed native operation. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsNativeOperationWithErrorDiagnostic(): void + { + $records = $this->fixtureRecords(); + $records[] = [ + 'code' => 'native.error', + 'message' => $this->byteValue('Native failure'), + 'severity' => 'error', + 'type' => 'diagnostic', + ]; + $stream = $this->stream($records); + + try { + $this->assertRejected($stream); } finally { fclose($stream); } @@ -274,6 +826,66 @@ private function regularArtifactRecords(string $path, string $bytes): array ]; } + /** + * Creates a complete directory-artifact record group. + * + * @param int $id Artifact identifier. + * @param string $path Directory path. + * + * @return list> + * @since 1.0.0 + */ + private function directoryArtifactRecords(int $id, string $path): array + { + return [ + [ + 'artifact_id' => $id, + 'file_type' => 'directory', + 'mode' => 493, + 'path' => $this->byteValue($path), + 'role' => 'module_data', + 'type' => 'artifact_begin', + ], + [ + 'artifact_id' => $id, + 'sha256' => hash('sha256', ''), + 'size' => 0, + 'type' => 'artifact_end', + ], + ]; + } + + /** + * Creates a complete symbolic-link artifact record group. + * + * @param int $id Artifact identifier. + * @param string $path Link path. + * @param string $target Exact link target. + * + * @return list> + * @since 1.0.0 + */ + private function symlinkArtifactRecords(int $id, string $path, string $target): array + { + return [ + [ + 'artifact_id' => $id, + 'file_type' => 'symlink', + 'mode' => 511, + 'path' => $this->byteValue($path), + 'role' => 'module_data', + 'target' => $this->byteValue($target), + 'type' => 'artifact_begin', + ], + [ + 'artifact_id' => $id, + 'sha256' => hash('sha256', $target), + 'size' => strlen($target), + 'type' => 'artifact_end', + ], + ]; + } + /** * Creates an exact byte envelope for generated contract records. * @@ -293,6 +905,73 @@ private function byteValue(string $bytes): array ]; } + /** + * Asserts that validation rejects a stream. + * + * @param resource $stream Candidate stream. + * @param class-string<\Throwable> $exception Expected exception. + * + * @return void + * @since 1.0.0 + */ + private function assertRejected( + mixed $stream, + string $exception = ContractException::class, + ): void { + try { + (new ContractV1Validator())->validate($stream); + self::fail('The malformed contract stream was accepted.'); + } catch (\Throwable $caught) { + self::assertInstanceOf($exception, $caught); + } + } + + /** + * Opens exact raw stream contents. + * + * @param string $contents Exact contents. + * + * @return resource + * @since 1.0.0 + */ + private function rawStream(string $contents): mixed + { + $stream = fopen('php://temp', 'w+b'); + self::assertIsResource($stream); + self::assertSame(strlen($contents), fwrite($stream, $contents)); + rewind($stream); + + return $stream; + } + + /** + * Rewrites only the generated footer for one negative test. + * + * @param resource $stream Generated contract stream. + * @param callable(array): array $mutation Footer mutation. + * + * @return resource + * @since 1.0.0 + */ + private function mutateFooter(mixed $stream, callable $mutation): mixed + { + $contents = stream_get_contents($stream); + fclose($stream); + self::assertIsString($contents); + $lines = explode("\n", rtrim($contents, "\n")); + $last = array_key_last($lines); + $footer = StructuredData::object( + json_decode($lines[$last], true, 512, JSON_THROW_ON_ERROR), + 'Generated footer', + ); + $lines[$last] = json_encode( + $mutation($footer), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + + return $this->rawStream(implode("\n", $lines) . "\n"); + } + /** * Serializes records with an internally consistent footer. * @@ -314,7 +993,9 @@ private function stream(array $records): mixed $record['sequence'] = $sequence; $type = $record['type']; self::assertIsString($type); - $counts[$type] = ($counts[$type] ?? 0) + 1; + if ($type !== 'diagnostic') { + $counts[$type] = ($counts[$type] ?? 0) + 1; + } if ($type === 'entry') { ++$entries; diff --git a/tests/Unit/Contract/ContractValueObjectsTest.php b/tests/Unit/Contract/ContractValueObjectsTest.php new file mode 100644 index 0000000..5aeb44f --- /dev/null +++ b/tests/Unit/Contract/ContractValueObjectsTest.php @@ -0,0 +1,262 @@ + 'KJV'], StructuredData::object(['name' => 'KJV'], 'object')); + self::assertSame(['KJV', 'WEB'], StructuredData::list(['KJV', 'WEB'], 'list')); + self::assertSame([1 => 'one'], StructuredData::map([1 => 'one'], 'map')); + } + + /** + * Verifies each decoded JSON shape guard rejects another shape. + * + * @return void + * @since 1.0.0 + */ + public function testStructuredDataGuardsRejectWrongShapes(): void + { + foreach ( + [ + static fn (): array => StructuredData::object([], 'object'), + static fn (): array => StructuredData::list(['name' => 'KJV'], 'list'), + static fn (): array => StructuredData::map('KJV', 'map'), + ] as $guard + ) { + try { + $guard(); + self::fail('The invalid decoded JSON shape was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('JSON', $exception->getMessage()); + } + } + } + + /** + * Verifies associative objects cannot conceal integer keys. + * + * @return void + * @since 1.0.0 + */ + public function testStructuredObjectRejectsMixedKeyTypes(): void + { + $this->expectException(ContractException::class); + $this->expectExceptionMessage('non-string object key'); + StructuredData::object([1 => 'one', 'name' => 'KJV'], 'object'); + } + + /** + * Verifies SWORD enumerations retain the native code and producer name. + * + * @return void + * @since 1.0.0 + */ + public function testEnumValueExposesCodeAndName(): void + { + $value = EnumValue::fromArray(['code' => 7, 'name' => 'osis'], 'markup'); + + self::assertSame(7, $value->code()); + self::assertSame('osis', $value->name()); + } + + /** + * Verifies unsigned-byte limits and required enum names. + * + * @return void + * @since 1.0.0 + */ + public function testEnumValueRejectsInvalidMembers(): void + { + foreach ( + [ + ['code' => -1, 'name' => 'invalid'], + ['code' => 256, 'name' => 'invalid'], + ['code' => 1, 'name' => null], + ] as $value + ) { + try { + EnumValue::fromArray($value, 'enum'); + self::fail('The invalid SWORD enumeration was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('enumeration', $exception->getMessage()); + } + } + } + + /** + * Verifies lexical segments expose exact validated bytes. + * + * @return void + * @since 1.0.0 + */ + public function testAnnotationSegmentExposesValidatedValues(): void + { + $segment = AnnotationSegment::fromArray([ + 'kind' => 'entity', + 'interpretation' => 'uninterpreted', + 'raw' => $this->bytes('&'), + ], 2); + + self::assertSame('entity', $segment->kind()); + self::assertSame('uninterpreted', $segment->interpretation()); + self::assertSame('&', $segment->raw()->requireUtf8()); + } + + /** + * Verifies segment kind, interpretation, and byte value are all required. + * + * @return void + * @since 1.0.0 + */ + public function testAnnotationSegmentRejectsInvalidMembers(): void + { + foreach ( + [ + ['kind' => 'unknown', 'interpretation' => 'uninterpreted', 'raw' => $this->bytes('x')], + ['kind' => 'text', 'interpretation' => 'uninterpreted', 'raw' => $this->bytes('x')], + ['kind' => 'markup', 'interpretation' => 'uninterpreted', 'raw' => null], + ] as $segment + ) { + try { + AnnotationSegment::fromArray($segment, 0); + self::fail('The invalid annotation segment was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('Annotation segment', $exception->getMessage()); + } + } + } + + /** + * Verifies all three ordered official-attribute levels remain addressable. + * + * @return void + * @since 1.0.0 + */ + public function testOfficialAttributesPreserveOrderedLevels(): void + { + $attributes = OfficialAttributes::fromArray([[ + 'name' => $this->bytes('Footnote'), + 'lists' => [[ + 'name' => $this->bytes('Word'), + 'values' => [[ + 'name' => $this->bytes('Lemma'), + 'value' => $this->bytes('strong:G3056'), + ]], + ]], + ]]); + $type = $attributes->types()[0]; + $list = $type->lists()[0]; + $value = $list->values()[0]; + + self::assertSame('Footnote', $type->name()->requireUtf8()); + self::assertSame('Word', $list->name()->requireUtf8()); + self::assertSame('Lemma', $value->name()->requireUtf8()); + self::assertSame('strong:G3056', $value->value()->requireUtf8()); + } + + /** + * Verifies the attribute map requires ordered, complete nested objects. + * + * @return void + * @since 1.0.0 + */ + public function testOfficialAttributesRejectMalformedLevels(): void + { + foreach ( + [ + ['named' => []], + [['name' => null, 'lists' => []]], + [['name' => $this->bytes('Type'), 'lists' => 'invalid']], + [['name' => $this->bytes('Type'), 'lists' => [['name' => null, 'values' => []]]]], + [[ + 'name' => $this->bytes('Type'), + 'lists' => [['name' => $this->bytes('List'), 'values' => [['name' => null]]]], + ]], + ] as $attributes + ) { + try { + OfficialAttributes::fromArray($attributes); + self::fail('The malformed official attribute map was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies validation summaries expose every immutable field. + * + * @return void + * @since 1.0.0 + */ + public function testValidationResultExposesFooterDigest(): void + { + $footer = ['stream_sha256' => str_repeat('a', 64), 'success' => true]; + $result = new ValidationResult('list', [], $footer); + + self::assertSame('list', $result->command()); + self::assertSame([], $result->modules()); + self::assertSame($footer, $result->footer()); + self::assertSame(str_repeat('a', 64), $result->streamSha256()); + } + + /** + * Verifies an impossible corrupt validated footer fails explicitly. + * + * @return void + * @since 1.0.0 + */ + public function testValidationResultRejectsMissingDigestOnAccess(): void + { + $result = new ValidationResult('list', [], []); + + $this->expectException(\LogicException::class); + $result->streamSha256(); + } + + /** + * Creates an exact UTF-8 byte envelope. + * + * @param string $value Exact value. + * + * @return array{base64: string, encoding: string, sha256: string, size: int, utf8: string} + * @since 1.0.0 + */ + private function bytes(string $value): array + { + return [ + 'base64' => base64_encode($value), + 'encoding' => 'base64', + 'sha256' => hash('sha256', $value), + 'size' => strlen($value), + 'utf8' => $value, + ]; + } +} diff --git a/tests/Unit/Contract/VerseScopeTest.php b/tests/Unit/Contract/VerseScopeTest.php new file mode 100644 index 0000000..40925e8 --- /dev/null +++ b/tests/Unit/Contract/VerseScopeTest.php @@ -0,0 +1,185 @@ +scope(3)); + + self::assertSame(2, $scope->testament()); + self::assertSame(4, $scope->book()); + self::assertSame(1, $scope->chapter()); + self::assertSame(0, $scope->verse()); + self::assertSame(0, $scope->suffix()); + self::assertSame(0, $scope->index()); + self::assertSame('chapter', $scope->introductionScope()); + self::assertFalse($scope->isVerse()); + self::assertSame('John', $scope->bookAbbreviation()?->requireUtf8()); + self::assertSame('John', $scope->bookName()?->requireUtf8()); + self::assertSame('John.1.0', $scope->osisReference()->requireUtf8()); + self::assertSame('KJV', $scope->versification()->requireUtf8()); + } + + /** + * Verifies a verse scope cannot describe verse zero. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsVerseScopeAtIntroductionCoordinate(): void + { + $scope = $this->scope(3); + $scope['intro_scope'] = 'verse'; + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('do not agree'); + + VerseScope::fromArray($scope); + } + + /** + * Verifies an ordinary verse scope is positively identified. + * + * @return void + * @since 1.0.0 + */ + public function testAcceptsOrdinaryVerseScope(): void + { + $scope = VerseScope::fromArray($this->scope(4)); + + self::assertTrue($scope->isVerse()); + self::assertSame(1, $scope->verse()); + self::assertSame(1, $scope->index()); + } + + /** + * Verifies a book scope cannot omit its identifying byte values. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMissingBookIdentity(): void + { + $scope = $this->scope(4); + $scope['book_name'] = null; + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('requires book name'); + VerseScope::fromArray($scope); + } + + /** + * Verifies the unsigned suffix byte limit is enforced. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsOversizedSuffix(): void + { + $scope = $this->scope(4); + $scope['suffix'] = 256; + + $this->expectException(ContractException::class); + VerseScope::fromArray($scope); + } + + /** + * Verifies each introduction coordinate family is supported. + * + * @return void + * @since 1.0.0 + */ + public function testAcceptsAllIntroductionCoordinateFamilies(): void + { + $base = $this->scope(3); + $base['book_abbreviation'] = null; + $base['book_name'] = null; + $coordinates = [ + 'module' => [0, 0, 0, 0], + 'testament' => [1, 0, 0, 0], + 'book' => [1, 1, 0, 0], + ]; + + foreach ($coordinates as $introduction => [$testament, $book, $chapter, $verse]) { + $scope = $base; + $scope['intro_scope'] = $introduction; + $scope['testament'] = $testament; + $scope['book'] = $book; + $scope['chapter'] = $chapter; + $scope['verse'] = $verse; + + if ($book > 0) { + $scope['book_abbreviation'] = $this->scope(3)['book_abbreviation']; + $scope['book_name'] = $this->scope(3)['book_name']; + } + + self::assertSame($introduction, VerseScope::fromArray($scope)->introductionScope()); + } + } + + /** + * Verifies invalid scope shape, coordinates, classifications, and bytes fail. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedScopeMembers(): void + { + $base = $this->scope(4); + $invalid = [ + array_replace($base, ['type' => 'sword_key']), + array_replace($base, ['testament' => -1]), + array_replace($base, ['intro_scope' => 'unknown']), + array_replace($base, ['osis_reference' => null]), + ]; + + foreach ($invalid as $scope) { + try { + VerseScope::fromArray($scope); + self::fail('A malformed verse scope was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Loads one scope from the deterministic fixture line. + * + * @param int $line Zero-based fixture line. + * + * @return array + * @since 1.0.0 + */ + private function scope(int $line): array + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $record = json_decode($lines[$line], true, 512, JSON_THROW_ON_ERROR); + $record = StructuredData::object($record, 'Fixture entry'); + + return StructuredData::object($record['scope'] ?? null, 'Fixture scope'); + } +} diff --git a/tests/Unit/DependencyInjection/ContainerFactoryTest.php b/tests/Unit/DependencyInjection/ContainerFactoryTest.php new file mode 100644 index 0000000..b9c5e74 --- /dev/null +++ b/tests/Unit/DependencyInjection/ContainerFactoryTest.php @@ -0,0 +1,101 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-container-factory-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes persisted configuration. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $path = $this->directory . '/configuration.json'; + + if (is_file($path)) { + unlink($path); + } + + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + /** + * Verifies the factory loads durable configuration when none is injected. + * + * @return void + * @since 1.0.0 + */ + public function testCreatesContainerFromPersistedConfiguration(): void + { + $path = $this->directory . '/configuration.json'; + (new JsonConfigurationRepository($path))->save(Configuration::fromEnvironment([ + 'cache_path' => '/persisted/cache', + 'modules' => ['KJV'], + ])); + + $container = ContainerFactory::create(null, $path); + $configuration = ContainerService::get($container, Configuration::class); + + self::assertSame('/persisted/cache', $configuration->cachePath()); + self::assertSame(['KJV'], $configuration->modules()); + } + + /** + * Verifies the service provider supplies configuration to a bare container. + * + * @return void + * @since 1.0.0 + */ + public function testProviderSuppliesMissingConfiguration(): void + { + $container = new Container(); + $container->registerServiceProvider(new ScriptureServiceProvider()); + + self::assertInstanceOf( + Configuration::class, + ContainerService::get($container, Configuration::class), + ); + } +} diff --git a/tests/Unit/DependencyInjection/RuntimeUtilitiesTest.php b/tests/Unit/DependencyInjection/RuntimeUtilitiesTest.php new file mode 100644 index 0000000..587f3af --- /dev/null +++ b/tests/Unit/DependencyInjection/RuntimeUtilitiesTest.php @@ -0,0 +1,71 @@ +now(); + $after = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + + self::assertGreaterThanOrEqual($before, $now); + self::assertLessThanOrEqual($after, $now); + self::assertSame('UTC', $now->getTimezone()->getName()); + } + + /** + * Verifies a correctly typed container service is returned unchanged. + * + * @return void + * @since 1.0.0 + */ + public function testContainerServiceReturnsVerifiedType(): void + { + $container = new Container(); + $clock = new SystemClock(); + $container->share(SystemClock::class, $clock, true); + + self::assertSame( + $clock, + ContainerService::get($container, SystemClock::class), + ); + } + + /** + * Verifies an incorrectly bound Joomla service fails at the boundary. + * + * @return void + * @since 1.0.0 + */ + public function testContainerServiceRejectsWrongRuntimeType(): void + { + $container = new Container(); + $container->share(\DateTimeInterface::class, new \stdClass(), true); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(\DateTimeInterface::class); + ContainerService::get($container, \DateTimeInterface::class); + } +} diff --git a/tests/Unit/Domain/DomainHydrationTest.php b/tests/Unit/Domain/DomainHydrationTest.php new file mode 100644 index 0000000..effae73 --- /dev/null +++ b/tests/Unit/Domain/DomainHydrationTest.php @@ -0,0 +1,292 @@ +record(1); + $metadata = TranslationMetadata::fromRecord($record); + + self::assertSame('bible', $metadata->classification()); + self::assertTrue($metadata->isBible()); + self::assertSame('TestBible', $metadata->name()->requireUtf8()); + self::assertSame('Test Bible', $metadata->description()->requireUtf8()); + self::assertSame('RawText', $metadata->driver()->requireUtf8()); + self::assertSame('en', $metadata->language()->requireUtf8()); + self::assertSame('Biblical Texts', $metadata->swordType()->requireUtf8()); + self::assertSame('ltr', $metadata->direction()->name()); + self::assertSame('utf8', $metadata->encoding()->name()); + self::assertSame('osis', $metadata->markup()->name()); + self::assertSame($record, $metadata->contractRecord()); + } + + /** + * Verifies supported non-Bible metadata is represented without coercion. + * + * @return void + * @since 1.0.0 + */ + public function testTranslationMetadataPreservesNonBibleClassification(): void + { + $record = $this->record(1); + $record['classification'] = 'commentary'; + $metadata = TranslationMetadata::fromRecord($record); + + self::assertSame('commentary', $metadata->classification()); + self::assertFalse($metadata->isBible()); + } + + /** + * Verifies invalid metadata type, classification, and required values fail. + * + * @return void + * @since 1.0.0 + */ + public function testTranslationMetadataRejectsMalformedRecords(): void + { + $record = $this->record(1); + + foreach ( + [ + array_replace($record, ['type' => 'entry']), + array_replace($record, ['classification' => 'unsupported']), + array_replace($record, ['name' => null]), + array_replace($record, ['direction' => null]), + ] as $invalid + ) { + try { + TranslationMetadata::fromRecord($invalid); + self::fail('Malformed translation metadata was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies ordered configuration values preserve exact bytes. + * + * @return void + * @since 1.0.0 + */ + public function testConfigEntryExposesOrderNameAndValue(): void + { + $entry = ConfigEntry::fromRecord($this->record(2)); + + self::assertSame(0, $entry->ordinal()); + self::assertSame('DistributionLicense', $entry->name()->requireUtf8()); + self::assertSame('Public Domain', $entry->value()->requireUtf8()); + } + + /** + * Verifies configuration entries reject negative and incomplete records. + * + * @return void + * @since 1.0.0 + */ + public function testConfigEntryRejectsMalformedRecords(): void + { + $record = $this->record(2); + + foreach ( + [ + array_replace($record, ['type' => 'entry']), + array_replace($record, ['ordinal' => -1]), + array_replace($record, ['name' => null]), + array_replace($record, ['value' => null]), + ] as $invalid + ) { + try { + ConfigEntry::fromRecord($invalid); + self::fail('Malformed configuration entry was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies every ordinary verse layer remains available. + * + * @return void + * @since 1.0.0 + */ + public function testVerseExposesEveryContractLayer(): void + { + $record = $this->record(4); + $verse = Verse::fromRecord($record); + + self::assertSame(1, $verse->ordinal()); + self::assertSame('John 1:1', $verse->key()->requireUtf8()); + self::assertSame('John.1.1', $verse->scope()->osisReference()->requireUtf8()); + self::assertSame('Word', $verse->raw()->requireUtf8()); + self::assertSame('Word', $verse->rendered()?->requireUtf8()); + self::assertSame('Word', $verse->stripped()?->requireUtf8()); + self::assertCount(3, $verse->annotationSegments()); + self::assertSame( + 'strong:G3056', + $verse->officialAttributes()->types()[0]->lists()[0]->values()[0]->value()->requireUtf8(), + ); + self::assertSame($record, $verse->contractRecord()); + } + + /** + * Verifies unavailable projections are represented by null. + * + * @return void + * @since 1.0.0 + */ + public function testVerseSupportsUnavailableProjections(): void + { + $record = $this->record(4); + $record['projections_available'] = false; + $record['rendered_default'] = null; + $record['stripped'] = null; + $verse = Verse::fromRecord($record); + + self::assertNull($verse->rendered()); + self::assertNull($verse->stripped()); + } + + /** + * Verifies a verse cannot accept an introduction or inconsistent layers. + * + * @return void + * @since 1.0.0 + */ + public function testVerseRejectsIntroductionAndInconsistentLayers(): void + { + $verseRecord = $this->record(4); + + foreach ( + [ + $this->record(3), + array_replace($verseRecord, ['type' => 'module']), + array_replace($verseRecord, ['ordinal' => -1]), + array_replace($verseRecord, ['scope' => null]), + array_replace($verseRecord, ['annotation_segments' => [null]]), + array_replace($verseRecord, ['projections_available' => 'yes']), + array_replace($verseRecord, ['annotation_segments' => []]), + array_replace($verseRecord, ['stripped' => null]), + array_replace($verseRecord, ['projections_available' => false]), + ] as $invalid + ) { + try { + Verse::fromRecord($invalid); + self::fail('An invalid ordinary verse was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies every chapter-introduction layer remains available. + * + * @return void + * @since 1.0.0 + */ + public function testIntroductionExposesEveryContractLayer(): void + { + $record = $this->record(3); + $introduction = Introduction::fromRecord($record); + + self::assertSame('chapter', $introduction->scope()->introductionScope()); + self::assertSame('John 1', $introduction->key()->requireUtf8()); + self::assertSame('John', $introduction->raw()->requireUtf8()); + self::assertSame('John', $introduction->rendered()?->requireUtf8()); + self::assertSame('John', $introduction->stripped()?->requireUtf8()); + self::assertCount(3, $introduction->annotationSegments()); + self::assertSame([], $introduction->officialAttributes()->types()); + self::assertSame($record, $introduction->contractRecord()); + } + + /** + * Verifies an introduction supports unavailable projections. + * + * @return void + * @since 1.0.0 + */ + public function testIntroductionSupportsUnavailableProjections(): void + { + $record = $this->record(3); + $record['projections_available'] = false; + $record['rendered_default'] = null; + $record['stripped'] = null; + $introduction = Introduction::fromRecord($record); + + self::assertNull($introduction->rendered()); + self::assertNull($introduction->stripped()); + } + + /** + * Verifies an introduction cannot accept a verse or inconsistent layers. + * + * @return void + * @since 1.0.0 + */ + public function testIntroductionRejectsVerseAndInconsistentLayers(): void + { + $introductionRecord = $this->record(3); + + foreach ( + [ + $this->record(4), + array_replace($introductionRecord, ['type' => 'module']), + array_replace($introductionRecord, ['annotation_segments' => [null]]), + array_replace($introductionRecord, ['annotation_segments' => []]), + array_replace($introductionRecord, ['stripped' => null]), + array_replace($introductionRecord, ['projections_available' => false]), + ] as $invalid + ) { + try { + Introduction::fromRecord($invalid); + self::fail('An invalid introduction was accepted.'); + } catch (ContractException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Loads one deterministic decoded fixture record. + * + * @param int $line Zero-based fixture line. + * + * @return array + * @since 1.0.0 + */ + private function record(int $line): array + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $record = json_decode($lines[$line], true, 512, JSON_THROW_ON_ERROR); + + return StructuredData::object($record, 'Fixture record'); + } +} diff --git a/tests/Unit/Domain/DomainObjectGraphTest.php b/tests/Unit/Domain/DomainObjectGraphTest.php new file mode 100644 index 0000000..bd94e04 --- /dev/null +++ b/tests/Unit/Domain/DomainObjectGraphTest.php @@ -0,0 +1,285 @@ +cachePath = sys_get_temp_dir() . '/getbible-domain-test-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($this->cachePath, 0700, true)); + } + + /** + * Removes the isolated cache root. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $this->deleteDirectory($this->cachePath); + } + + /** + * Verifies complete navigation and snapshot metadata exposure. + * + * @return void + * @since 1.0.0 + */ + public function testTranslationGraphExposesEveryNavigationMethod(): void + { + [$translation] = $this->translation(); + + self::assertSame('TestBible', $translation->moduleName()); + self::assertSame('TestBible', $translation->metadata()->name()->requireUtf8()); + self::assertCount(1, $translation->books()); + self::assertSame('John', $translation->book('john')->name()->requireUtf8()); + self::assertSame('John', $translation->bookByPosition(2, 4)->abbreviation()->requireUtf8()); + self::assertSame('Word', $translation->verses('John', 1, 1, 1)[0]->stripped()?->requireUtf8()); + self::assertCount(1, $translation->configEntries()); + self::assertSame( + 'Public Domain', + $translation->configEntriesNamed('DistributionLicense')[0]->value()->requireUtf8(), + ); + self::assertSame([], $translation->configEntriesNamed('Unknown')); + self::assertSame([], $translation->introductions()); + self::assertFileExists($translation->rawExportPath()); + self::assertSame('2026-07-26T12:00:00+00:00', $translation->activatedAt()->format(DATE_ATOM)); + self::assertSame('2026-08-26T12:00:00+00:00', $translation->expiresAt()->format(DATE_ATOM)); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/D', $translation->generationId()); + + $book = $translation->books()[0]; + self::assertSame($translation, $book->translation()); + self::assertSame(2, $book->testament()); + self::assertSame(4, $book->position()); + self::assertSame('John', $book->name()->requireUtf8()); + self::assertSame('John', $book->abbreviation()->requireUtf8()); + self::assertSame('KJV', $book->versification()->requireUtf8()); + self::assertCount(1, $book->chapters()); + self::assertSame([], $book->introductions()); + + $chapter = $book->chapter(1); + self::assertSame($book, $chapter->book()); + self::assertSame(1, $chapter->number()); + self::assertSame('John 1:1', $chapter->verse(1)->key()->requireUtf8()); + self::assertCount(1, $chapter->verses()); + self::assertCount(1, $chapter->verses(1, 1)); + self::assertCount(1, $chapter->introductions()); + } + + /** + * Verifies missing books, chapters, verses, and invalid ranges fail clearly. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidReferencesFailAtTheirOwningLevel(): void + { + [$translation, $snapshot] = $this->translation(); + $book = $translation->book('John'); + $chapter = $book->chapter(1); + + $operations = [ + static fn () => $translation->book('Genesis'), + static fn () => $translation->bookByPosition(1, 1), + static fn () => $book->chapter(0), + static fn () => $book->chapter(2), + static fn () => $chapter->verse(2), + static fn () => $chapter->verse(1, 256), + static fn () => $chapter->verses(2, 1), + static fn () => new Chapter($book, $snapshot, '2:4', 0), + ]; + + foreach ($operations as $operation) { + try { + $operation(); + self::fail('An invalid Scripture reference was accepted.'); + } catch (InvalidReferenceException | ReferenceNotFoundException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies parent/snapshot ownership invariants reject mismatches. + * + * @return void + * @since 1.0.0 + */ + public function testTranslationRejectsSnapshotFromAnotherModule(): void + { + [, $snapshot] = $this->translation(); + + $this->expectException(\InvalidArgumentException::class); + new Translation('DifferentBible', $snapshot); + } + + /** + * Verifies Book and Chapter reject parent objects from another snapshot. + * + * @return void + * @since 1.0.0 + */ + public function testBookAndChapterRejectParentOwnershipMismatch(): void + { + [$translation, $snapshot] = $this->translation(); + $translationProperty = new \ReflectionProperty($translation, 'moduleName'); + $translationProperty->setValue($translation, 'DifferentBible'); + + try { + new Book($translation, $snapshot, '2:4'); + self::fail('A Book accepted a Translation from another snapshot.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('parent Translation', $exception->getMessage()); + } + + $translationProperty->setValue($translation, 'TestBible'); + $book = new Book($translation, $snapshot, '2:4'); + $metadataProperty = new \ReflectionProperty($book, 'metadata'); + $metadata = $metadataProperty->getValue($book); + self::assertIsArray($metadata); + $metadata['testament'] = 1; + $metadataProperty->setValue($book, $metadata); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('parent Book'); + new Chapter($book, $snapshot, '2:4', 1); + } + + /** + * Builds a deterministic translation over one real immutable snapshot. + * + * @return array{Translation, SnapshotIndex} + * @since 1.0.0 + */ + private function translation(): array + { + $fixture = __DIR__ . '/../../Fixtures/test-bible.ndjson'; + $lines = file($fixture); + self::assertIsArray($lines); + $record = json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR); + $metadata = TranslationMetadata::fromRecord( + StructuredData::object($record, 'Fixture module'), + ); + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturn([$metadata]); + $catalog->method('translation')->willReturn($metadata); + $extractor = $this->createStub(ModuleExtractorInterface::class); + $extractor->method('modulePath')->willReturn('/test/modules'); + $extractor->method('streamModule')->willReturnCallback( + static function ( + string $module, + mixed $destination, + int $artifactChunkSize = 1048576, + ) use ($fixture): int { + $contents = file_get_contents($fixture); + + if ( + !is_string($contents) + || !is_resource($destination) + || fwrite($destination, $contents) !== strlen($contents) + ) { + throw new \RuntimeException('Unable to copy the deterministic fixture.'); + } + + return strlen($contents); + }, + ); + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn(new \DateTimeImmutable('2026-07-26T12:00:00+00:00')); + $lock = $this->createStub(ModuleRootLockInterface::class); + $lock->method('read')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $lock->method('write')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $configuration = Configuration::fromEnvironment([ + 'module_path' => '/test/modules', + 'cache_path' => $this->cachePath, + 'refresh_interval' => 'P1M', + 'auto_refresh' => true, + ]); + $manager = new TranslationSnapshotManager( + $configuration, + $clock, + $catalog, + $extractor, + new ContractV1Validator(), + new Dispatcher(), + $lock, + ); + $snapshot = $manager->get('TestBible'); + + return [new Translation('TestBible', $snapshot), $snapshot]; + } + + /** + * Recursively removes a controlled test directory. + * + * @param string $path Controlled temporary path. + * + * @return void + * @since 1.0.0 + */ + private function deleteDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/Event/LifecycleEventDispatcherTest.php b/tests/Unit/Event/LifecycleEventDispatcherTest.php new file mode 100644 index 0000000..26ffd35 --- /dev/null +++ b/tests/Unit/Event/LifecycleEventDispatcherTest.php @@ -0,0 +1,105 @@ +addListener( + 'onTestLifecycle', + static function (Event $event) use (&$received): void { + $received = $event->getArgument('module'); + }, + ); + + $failure = LifecycleEventDispatcher::dispatch( + $dispatcher, + 'onTestLifecycle', + ['module' => 'KJV'], + ); + + self::assertNull($failure); + self::assertSame('KJV', $received); + } + + /** + * Verifies listener failures are returned instead of thrown. + * + * @return void + * @since 1.0.0 + */ + public function testListenerFailureIsContained(): void + { + $dispatcher = new Dispatcher(); + $expected = new \RuntimeException('Observer failed.'); + $dispatcher->addListener( + 'onTestLifecycle', + static function () use ($expected): void { + throw $expected; + }, + ); + + self::assertSame( + $expected, + LifecycleEventDispatcher::dispatch( + $dispatcher, + 'onTestLifecycle', + ['module' => 'KJV'], + ), + ); + } + + /** + * Verifies every documented lifecycle event has a unique stable name. + * + * @return void + * @since 1.0.0 + */ + public function testEventNamesAreStableAndUnique(): void + { + $names = [ + EventName::WARM_STARTED, + EventName::WARM_COMPLETED, + EventName::WARM_FAILED, + EventName::REFRESH_STARTED, + EventName::REFRESH_COMPLETED, + EventName::REFRESH_FAILED, + EventName::PROVISIONING_STARTED, + EventName::PROVISIONING_COMPLETED, + EventName::PROVISIONING_FAILED, + EventName::MAINTENANCE_STARTED, + EventName::MAINTENANCE_COMPLETED, + EventName::MAINTENANCE_FAILED, + ]; + + self::assertCount(12, array_unique($names)); + + foreach ($names as $name) { + self::assertStringStartsWith('onGetBibleScripture', $name); + } + } +} diff --git a/tests/Unit/Infrastructure/GenerationLeaseTest.php b/tests/Unit/Infrastructure/GenerationLeaseTest.php new file mode 100644 index 0000000..918435a --- /dev/null +++ b/tests/Unit/Infrastructure/GenerationLeaseTest.php @@ -0,0 +1,141 @@ +moduleRoot = sys_get_temp_dir() . '/getbible-generation-lease-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($this->moduleRoot, 0700, true)); + } + + /** + * Removes the controlled test cache root. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->moduleRoot, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($this->moduleRoot); + } + + /** + * Verifies cleanup is nonblocking and succeeds after release. + * + * @return void + * @since 1.0.0 + */ + public function testCleanupWaitsForReaderRelease(): void + { + $generation = str_repeat('a', 64); + $lease = new GenerationLease($this->moduleRoot, $generation); + $cleaned = false; + + self::assertFalse(GenerationLease::cleanup( + $this->moduleRoot, + $generation, + static function () use (&$cleaned): void { + $cleaned = true; + }, + )); + self::assertFalse($cleaned); + + unset($lease); + + self::assertTrue(GenerationLease::cleanup( + $this->moduleRoot, + $generation, + static function () use (&$cleaned): void { + $cleaned = true; + }, + )); + self::assertTrue($cleaned); + } + + /** + * Verifies explicit release is idempotent and permits cleanup. + * + * @return void + * @since 1.0.0 + */ + public function testExplicitReleaseIsIdempotent(): void + { + $generation = str_repeat('b', 64); + $lease = new GenerationLease($this->moduleRoot, $generation); + $lease->release(); + $lease->release(); + + self::assertTrue(GenerationLease::cleanup( + $this->moduleRoot, + $generation, + static function (): void { + }, + )); + } + + /** + * Verifies lease paths accept only content-addressed identifiers. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsInvalidGenerationIdentifier(): void + { + try { + new GenerationLease($this->moduleRoot, 'not-a-generation'); + self::fail('An invalid generation lease identifier was accepted.'); + } catch (\InvalidArgumentException) { + self::addToAssertionCount(1); + } + + $this->expectException(\InvalidArgumentException::class); + GenerationLease::cleanup( + $this->moduleRoot, + str_repeat('g', 64), + static function (): void { + }, + ); + } +} diff --git a/tests/Unit/Infrastructure/LockClassesTest.php b/tests/Unit/Infrastructure/LockClassesTest.php new file mode 100644 index 0000000..6c3bc42 --- /dev/null +++ b/tests/Unit/Infrastructure/LockClassesTest.php @@ -0,0 +1,204 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-locks-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes lock files created during the test. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + if (!is_dir($this->directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $this->directory, + \FilesystemIterator::SKIP_DOTS, + ), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($this->directory); + } + + /** + * Verifies shared and exclusive callbacks return their exact values. + * + * @return void + * @since 1.0.0 + */ + public function testBoundedLockExecutesAndReleasesCallbacks(): void + { + $lock = new BoundedFileLock($this->directory . '/direct/lifecycle.lock', 1); + + self::assertSame( + 'shared', + $lock->synchronized(LOCK_SH, static fn (): string => 'shared'), + ); + self::assertSame( + ['exclusive'], + $lock->synchronized(LOCK_EX, static fn (): array => ['exclusive']), + ); + self::assertFileExists($this->directory . '/direct/lifecycle.lock'); + } + + /** + * Verifies configuration-backed lifecycle locks use their correct modes. + * + * @return void + * @since 1.0.0 + */ + public function testConfigurationBackedLocksExecuteCallbacks(): void + { + $configuration = Configuration::fromEnvironment([ + 'cache_path' => $this->directory, + 'lock_timeout' => 1, + ]); + $maintenance = new FileMaintenanceLock($configuration); + $moduleRoot = new FileModuleRootLock($configuration); + + self::assertSame(11, $maintenance->run(static fn (): int => 11)); + self::assertSame(12, $moduleRoot->read(static fn (): int => 12)); + self::assertSame(13, $moduleRoot->write(static fn (): int => 13)); + self::assertFileExists($this->directory . '/locks/maintenance.lock'); + self::assertFileExists($this->directory . '/locks/module-root.lock'); + } + + /** + * Verifies invalid lock construction and modes are rejected. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidLockArgumentsAreRejected(): void + { + try { + new BoundedFileLock('', 1); + self::fail('An empty lock path was accepted.'); + } catch (\InvalidArgumentException) { + } + + try { + new BoundedFileLock($this->directory . '/invalid.lock', 0); + self::fail('A zero lock timeout was accepted.'); + } catch (\InvalidArgumentException) { + } + + $this->expectException(\InvalidArgumentException::class); + (new BoundedFileLock($this->directory . '/invalid-mode.lock', 1)) + ->synchronized(0, static fn (): null => null); + } + + /** + * Verifies callback failures still release the advisory lock. + * + * @return void + * @since 1.0.0 + */ + public function testBoundedLockReleasesAfterCallbackFailure(): void + { + $lock = new BoundedFileLock($this->directory . '/failure/lifecycle.lock', 1); + + try { + $lock->synchronized( + LOCK_EX, + self::throwExpectedCallbackFailure(...), + ); + self::fail('The callback failure was not propagated.'); + } catch (\RuntimeException $exception) { + self::assertSame('Expected callback failure.', $exception->getMessage()); + } + + self::assertSame( + 'reacquired', + $lock->synchronized(LOCK_EX, static fn (): string => 'reacquired'), + ); + } + + /** + * Verifies a contended advisory lock fails within its configured bound. + * + * @return void + * @since 1.0.0 + */ + public function testBoundedLockTimesOutWhenContended(): void + { + self::assertTrue(mkdir($this->directory, 0700, true)); + $path = $this->directory . '/contended.lock'; + $holder = fopen($path, 'c+b'); + self::assertIsResource($holder); + self::assertTrue(flock($holder, LOCK_EX | LOCK_NB)); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Timed out after 1 seconds'); + (new BoundedFileLock($path, 1)) + ->synchronized(LOCK_EX, static fn (): null => null); + } finally { + flock($holder, LOCK_UN); + fclose($holder); + } + } + + /** + * Throws the deterministic callback failure used by the lock-release test. + * + * @return void + * @since 1.0.0 + */ + private static function throwExpectedCallbackFailure(): void + { + throw new \RuntimeException('Expected callback failure.'); + } +} diff --git a/tests/Unit/Infrastructure/SwordEngineAdapterTest.php b/tests/Unit/Infrastructure/SwordEngineAdapterTest.php new file mode 100644 index 0000000..04c0032 --- /dev/null +++ b/tests/Unit/Infrastructure/SwordEngineAdapterTest.php @@ -0,0 +1,106 @@ +modulePath()); + + $modules = $this->temporaryStream(); + $module = $this->temporaryStream(); + + try { + $moduleBytes = $adapter->streamModules($modules); + rewind($modules); + $moduleList = stream_get_contents($modules); + self::assertIsString($moduleList); + self::assertSame($moduleBytes, strlen($moduleList)); + self::assertStringContainsString('"command":"list"', $moduleList); + + $extractBytes = $adapter->streamModule('TestBible', $module, 4096); + rewind($module); + $extract = stream_get_contents($module); + $fixture = file_get_contents(NativeRuntimeState::$fixturePath); + self::assertIsString($extract); + self::assertIsString($fixture); + self::assertSame($extractBytes, strlen($extract)); + self::assertSame($fixture, $extract); + } finally { + fclose($modules); + fclose($module); + } + } + + /** + * Creates a writable in-memory stream. + * + * @return resource + * @since 1.0.0 + */ + private function temporaryStream(): mixed + { + $stream = fopen('php://temp', 'w+b'); + + if (!is_resource($stream)) { + throw new \RuntimeException('Unable to create an adapter test stream.'); + } + + return $stream; + } +} diff --git a/tests/Unit/Integration/Joomla/ScheduledRefreshHandlerTest.php b/tests/Unit/Integration/Joomla/ScheduledRefreshHandlerTest.php new file mode 100644 index 0000000..7e7b4e6 --- /dev/null +++ b/tests/Unit/Integration/Joomla/ScheduledRefreshHandlerTest.php @@ -0,0 +1,61 @@ +maintenanceResult(); + $maintenance = $this->createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::exactly(2)) + ->method('refreshIfDue') + ->willReturn($result); + $handler = new ScheduledRefreshHandler($maintenance); + + self::assertSame($result, $handler->run()); + self::assertSame($result, $handler()); + } + + /** + * Creates a deterministic skipped maintenance result. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + private function maintenanceResult(): MaintenanceResult + { + $time = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + return new MaintenanceResult( + 'refresh-if-due', + $time, + $time, + false, + [], + null, + [], + 'The configured refresh interval has not elapsed.', + ); + } +} diff --git a/tests/Unit/Maintenance/JsonMaintenanceStateStoreTest.php b/tests/Unit/Maintenance/JsonMaintenanceStateStoreTest.php index bccaf68..5b830a5 100644 --- a/tests/Unit/Maintenance/JsonMaintenanceStateStoreTest.php +++ b/tests/Unit/Maintenance/JsonMaintenanceStateStoreTest.php @@ -102,6 +102,28 @@ public function testRejectsCorruptState(): void $store->load(); } + /** + * Verifies malformed JSON preserves the decoder failure as context. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedJsonState(): void + { + $store = $this->store(); + $store->save(MaintenanceState::empty()); + $statePath = $this->cachePath . '/maintenance/state.json'; + self::assertNotFalse(file_put_contents($statePath, "{invalid\n")); + + try { + $store->load(); + self::fail('Malformed durable state was accepted.'); + } catch (\RuntimeException $exception) { + self::assertSame('Unable to decode durable maintenance state.', $exception->getMessage()); + self::assertInstanceOf(\JsonException::class, $exception->getPrevious()); + } + } + /** * Creates the state store for the isolated path. * diff --git a/tests/Unit/Maintenance/MaintenanceServiceBehaviorTest.php b/tests/Unit/Maintenance/MaintenanceServiceBehaviorTest.php new file mode 100644 index 0000000..3c96992 --- /dev/null +++ b/tests/Unit/Maintenance/MaintenanceServiceBehaviorTest.php @@ -0,0 +1,423 @@ +metadata(); + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturn([$metadata]); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::once()) + ->method('refresh') + ->with('TestBible') + ->willReturn($this->snapshot()); + $provisioning = $this->createStub(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn(ProvisioningCapabilities::abiV1()); + $store = $this->stateStore(); + $service = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-refresh', + 'modules' => ['TestBible'], + 'provisioning_enabled' => false, + ]), + $catalog, + $snapshots, + $provisioning, + $store, + ); + + $result = $service->refreshIfDue([' TestBible ', 'TestBible']); + + self::assertTrue($result->succeeded()); + self::assertSame('refresh-if-due', $result->operation()); + self::assertSame('refreshed', $result->modules()[0]->status()); + self::assertNotNull($store->load()->lastSuccessAt()); + + $status = $service->status()->toArray(); + self::assertFalse($status['due']); + self::assertSame(['TestBible'], $status['configured_modules']); + self::assertSame(['TestBible'], $status['installed_modules']); + self::assertFalse($status['provisioning_enabled']); + } + + /** + * Verifies failed native provisioning and snapshot rebuilds are both isolated and persisted. + * + * @return void + * @since 1.0.0 + */ + public function testRefreshCapturesProvisioningAndSnapshotFailures(): void + { + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturn([$this->metadata()]); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::once()) + ->method('refresh') + ->with('TestBible') + ->willThrowException(new \RuntimeException('Snapshot rebuild failed.')); + $provisioning = $this->createMock(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn( + new ProvisioningCapabilities('test', 'test/v1', true, true, true, true), + ); + $provisioning->expects(self::once()) + ->method('refresh') + ->with(['TestBible']) + ->willReturn(new ProvisioningResult('refresh', [ + new ModuleProvisioningResult( + 'TestBible', + ModuleProvisioningResult::ACTION_REFRESH, + ModuleProvisioningResult::STATUS_FAILED, + 'Remote mirror unavailable.', + ), + ])); + $store = $this->stateStore(); + $service = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-failure', + 'modules' => ['TestBible'], + 'provisioning_enabled' => true, + ]), + $catalog, + $snapshots, + $provisioning, + $store, + ); + + $result = $service->refresh(); + + self::assertFalse($result->succeeded()); + self::assertSame(['Native provisioning failed for: TestBible.'], $result->errors()); + self::assertSame('failed', $result->modules()[0]->status()); + self::assertSame('Snapshot rebuild failed.', $result->modules()[0]->message()); + self::assertSame(1, $store->load()->consecutiveFailures()); + self::assertStringContainsString( + 'Snapshot rebuild failed.', + (string) $store->load()->lastError(), + ); + } + + /** + * Verifies selected initialization installs a missing module before warming it. + * + * @return void + * @since 1.0.0 + */ + public function testInitializeInstallsMissingSelectedModule(): void + { + $metadata = $this->metadata(); + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturnOnConsecutiveCalls([], [$metadata]); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::once()) + ->method('get') + ->with('TestBible') + ->willReturn($this->snapshot()); + $provisioning = $this->createMock(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn( + new ProvisioningCapabilities('test', 'test/v1', true, false, false, false), + ); + $provisioning->expects(self::once()) + ->method('install') + ->with(['TestBible']) + ->willReturnCallback( + static function (): ProvisioningResult { + return new ProvisioningResult('install', [ + new ModuleProvisioningResult( + 'TestBible', + ModuleProvisioningResult::ACTION_INSTALL, + ModuleProvisioningResult::STATUS_CHANGED, + ), + ]); + }, + ); + $store = $this->stateStore(); + $service = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-install', + 'modules' => ['TestBible'], + 'provisioning_enabled' => true, + ]), + $catalog, + $snapshots, + $provisioning, + $store, + ); + + $result = $service->initialize(); + + self::assertTrue($result->succeeded()); + self::assertSame(['TestBible'], $result->provisioning()?->installed()); + self::assertSame('ready', $result->modules()[0]->status()); + } + + /** + * Verifies unavailable policy paths and orchestration failures remain explicit. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsInvalidTargetsAndPropagatesCatalogFailure(): void + { + $emptyCatalog = $this->createStub(ModuleCatalogInterface::class); + $emptyCatalog->method('translations')->willReturn([]); + $snapshots = $this->createStub(SnapshotManagerInterface::class); + $provisioning = $this->createStub(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn(ProvisioningCapabilities::abiV1()); + $service = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-policy', + 'modules' => ['Missing'], + 'provisioning_enabled' => false, + ]), + $emptyCatalog, + $snapshots, + $provisioning, + $this->stateStore(), + ); + $result = $service->initialize(); + + self::assertFalse($result->succeeded()); + self::assertStringContainsString('provisioning is disabled', $result->errors()[0]); + self::assertSame('The translation is not installed.', $result->modules()[0]->message()); + + try { + (new \ReflectionMethod($service, 'refresh'))->invoke($service, [123]); + self::fail('A non-string maintenance target was accepted.'); + } catch (\InvalidArgumentException) { + self::addToAssertionCount(1); + } + + $failingCatalog = $this->createStub(ModuleCatalogInterface::class); + $failingCatalog->method('translations') + ->willThrowException(new \RuntimeException('Catalog unavailable.')); + $failingService = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-catalog-failure', + ]), + $failingCatalog, + $snapshots, + $provisioning, + $this->stateStore(), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Catalog unavailable.'); + $failingService->refresh(); + } + + /** + * Verifies thrown backend failures become durable run errors without blocking local rebuilding. + * + * @return void + * @since 1.0.0 + */ + public function testRefreshCapturesThrownProvisioningFailure(): void + { + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturn([$this->metadata()]); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::once()) + ->method('refresh') + ->with('TestBible') + ->willReturn($this->snapshot()); + $provisioning = $this->createMock(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn( + new ProvisioningCapabilities('test', 'test/v1', false, false, true, false), + ); + $provisioning->expects(self::once()) + ->method('refresh') + ->with(['TestBible']) + ->willThrowException(new \RuntimeException('Native backend crashed.')); + $store = $this->stateStore(); + $service = $this->service( + Configuration::fromEnvironment([ + 'cache_path' => sys_get_temp_dir() . '/scripture-maintenance-native-failure', + 'modules' => ['TestBible'], + 'provisioning_enabled' => true, + ]), + $catalog, + $snapshots, + $provisioning, + $store, + ); + + $result = $service->refresh(); + + self::assertFalse($result->succeeded()); + self::assertSame( + ['Native provisioning failed: Native backend crashed.'], + $result->errors(), + ); + self::assertSame('refreshed', $result->modules()[0]->status()); + self::assertSame(1, $store->load()->consecutiveFailures()); + } + + /** + * Creates deterministic production orchestration around supplied test seams. + * + * @param Configuration $configuration Fixed policy. + * @param ModuleCatalogInterface $catalog Catalog seam. + * @param SnapshotManagerInterface $snapshots Snapshot seam. + * @param ProvisioningCoordinatorInterface $provisioning Provisioning seam. + * @param MaintenanceStateStoreInterface $store State seam. + * + * @return MaintenanceService + * @since 1.0.0 + */ + private function service( + Configuration $configuration, + ModuleCatalogInterface $catalog, + SnapshotManagerInterface $snapshots, + ProvisioningCoordinatorInterface $provisioning, + MaintenanceStateStoreInterface $store, + ): MaintenanceService { + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn(new \DateTimeImmutable('2026-07-26T12:01:00+00:00')); + + return new MaintenanceService( + $configuration, + $clock, + $catalog, + $snapshots, + $provisioning, + $store, + $this->directLock(), + new Dispatcher(), + ); + } + + /** + * Returns fixture translation metadata. + * + * @return TranslationMetadata + * @since 1.0.0 + */ + private function metadata(): TranslationMetadata + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $record = json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($record); + + return TranslationMetadata::fromRecord($record); + } + + /** + * Returns an uninitialized value used only as a type-safe manager result. + * + * @return SnapshotIndex + * @since 1.0.0 + */ + private function snapshot(): SnapshotIndex + { + return (new \ReflectionClass(SnapshotIndex::class))->newInstanceWithoutConstructor(); + } + + /** + * Creates an in-memory durable state seam. + * + * @return MaintenanceStateStoreInterface + * @since 1.0.0 + */ + private function stateStore(): MaintenanceStateStoreInterface + { + return new class () implements MaintenanceStateStoreInterface { + /** + * Current state. + * + * @var MaintenanceState + */ + private MaintenanceState $state; + + /** + * Creates empty state. + */ + public function __construct() + { + $this->state = MaintenanceState::empty(); + } + + /** + * Returns current state. + * + * @return MaintenanceState + */ + public function load(): MaintenanceState + { + return $this->state; + } + + /** + * Persists current state. + * + * @param MaintenanceState $state Replacement state. + * + * @return void + */ + public function save(MaintenanceState $state): void + { + $this->state = $state; + } + }; + } + + /** + * Creates a direct whole-run lock seam. + * + * @return MaintenanceLockInterface + * @since 1.0.0 + */ + private function directLock(): MaintenanceLockInterface + { + return new class () implements MaintenanceLockInterface { + /** + * Executes the operation directly. + * + * @template T + * + * @param callable(): T $operation Protected operation. + * + * @return T + */ + public function run(callable $operation): mixed + { + return $operation(); + } + }; + } +} diff --git a/tests/Unit/Maintenance/MaintenanceStateTest.php b/tests/Unit/Maintenance/MaintenanceStateTest.php index c845438..7d23840 100644 --- a/tests/Unit/Maintenance/MaintenanceStateTest.php +++ b/tests/Unit/Maintenance/MaintenanceStateTest.php @@ -16,6 +16,26 @@ */ final class MaintenanceStateTest extends TestCase { + /** + * Verifies empty state accessors and immediate due semantics. + * + * @return void + * @since 1.0.0 + */ + public function testEmptyStateIsDueAndHasNoHistory(): void + { + $state = MaintenanceState::empty(); + $now = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + self::assertTrue($state->isDue($now, new \DateInterval('P1M'))); + self::assertNull($state->nextDueAt(new \DateInterval('P1M'))); + self::assertNull($state->lastAttemptAt()); + self::assertNull($state->lastSuccessAt()); + self::assertNull($state->lastFailureAt()); + self::assertNull($state->lastError()); + self::assertSame(0, $state->consecutiveFailures()); + } + /** * Verifies monthly due calculations from the last complete success. * @@ -62,4 +82,97 @@ public function testFailureStateRoundTripsWithoutAdvancingSuccess(): void self::assertSame('Network unavailable', $restored->lastError()); self::assertSame(1, $restored->consecutiveFailures()); } + + /** + * Verifies successful recovery clears the error and failure counter. + * + * @return void + * @since 1.0.0 + */ + public function testSuccessAfterFailureResetsFailureState(): void + { + $failure = new \DateTimeImmutable('2026-07-25T12:00:00+00:00'); + $success = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $state = MaintenanceState::empty() + ->failedAt($failure, ' Temporary failure. ') + ->succeededAt($success); + + self::assertSame($success, $state->lastAttemptAt()); + self::assertSame($success, $state->lastSuccessAt()); + self::assertSame($failure, $state->lastFailureAt()); + self::assertNull($state->lastError()); + self::assertSame(0, $state->consecutiveFailures()); + } + + /** + * Verifies serialized state rejects invalid structures and timestamps. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidSerializedStateIsRejected(): void + { + try { + MaintenanceState::fromArray([]); + self::fail('An unsupported state structure was accepted.'); + } catch (\UnexpectedValueException) { + } + + try { + MaintenanceState::fromArray([ + 'format' => MaintenanceState::FORMAT, + 'consecutive_failures' => 0, + 'last_error' => [], + ]); + self::fail('A non-string last error was accepted.'); + } catch (\UnexpectedValueException) { + } + + try { + MaintenanceState::fromArray([ + 'format' => MaintenanceState::FORMAT, + 'consecutive_failures' => 0, + 'last_attempt_at' => 123, + ]); + self::fail('A non-string timestamp was accepted.'); + } catch (\UnexpectedValueException) { + } + + $this->expectException(\UnexpectedValueException::class); + MaintenanceState::fromArray([ + 'format' => MaintenanceState::FORMAT, + 'consecutive_failures' => 0, + 'last_attempt_at' => 'not a timestamp', + ]); + } + + /** + * Verifies serialized state cannot contain a negative failure counter. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsNegativeFailureCounter(): void + { + $this->expectException(\InvalidArgumentException::class); + MaintenanceState::fromArray([ + 'format' => MaintenanceState::FORMAT, + 'consecutive_failures' => -1, + ]); + } + + /** + * Verifies failed transitions require a useful diagnostic. + * + * @return void + * @since 1.0.0 + */ + public function testFailureRequiresDiagnostic(): void + { + $this->expectException(\InvalidArgumentException::class); + MaintenanceState::empty()->failedAt( + new \DateTimeImmutable('2026-07-26T12:00:00+00:00'), + ' ', + ); + } } diff --git a/tests/Unit/Maintenance/MaintenanceValueObjectsTest.php b/tests/Unit/Maintenance/MaintenanceValueObjectsTest.php new file mode 100644 index 0000000..aee6fff --- /dev/null +++ b/tests/Unit/Maintenance/MaintenanceValueObjectsTest.php @@ -0,0 +1,233 @@ +module()); + self::assertSame($status, $result->status()); + self::assertSame('diagnostic', $result->message()); + self::assertSame($failed, $result->failed()); + self::assertSame( + ['module' => 'KJV', 'status' => $status, 'message' => 'diagnostic'], + $result->toArray(), + ); + } + + /** + * Supplies every supported module status. + * + * @return iterable + * @since 1.0.0 + */ + public static function moduleStatuses(): iterable + { + yield 'ready' => [MaintenanceModuleResult::STATUS_READY, false]; + yield 'refreshed' => [MaintenanceModuleResult::STATUS_REFRESHED, false]; + yield 'skipped' => [MaintenanceModuleResult::STATUS_SKIPPED, false]; + yield 'failed' => [MaintenanceModuleResult::STATUS_FAILED, true]; + } + + /** + * Verifies aggregate accessors and nested serialization. + * + * @return void + * @since 1.0.0 + */ + public function testMaintenanceResultExposesCompleteOutcome(): void + { + $started = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $completed = $started->modify('+2 seconds'); + $module = new MaintenanceModuleResult( + 'KJV', + MaintenanceModuleResult::STATUS_READY, + ); + $provisioning = new ProvisioningResult( + 'install', + [ + new ModuleProvisioningResult( + 'KJV', + ModuleProvisioningResult::ACTION_INSTALL, + ModuleProvisioningResult::STATUS_SKIPPED, + 'Already installed.', + ), + ], + ); + $result = new MaintenanceResult( + 'initialize', + $started, + $completed, + false, + [$module], + $provisioning, + [], + 'Already current.', + ); + + self::assertSame('initialize', $result->operation()); + self::assertSame($started, $result->startedAt()); + self::assertSame($completed, $result->completedAt()); + self::assertFalse($result->due()); + self::assertSame([$module], $result->modules()); + self::assertSame($provisioning, $result->provisioning()); + self::assertSame([], $result->errors()); + self::assertSame('Already current.', $result->skipReason()); + self::assertTrue($result->succeeded()); + self::assertSame('initialize', $result->toArray()['operation']); + self::assertSame($provisioning->toArray(), $result->toArray()['provisioning']); + } + + /** + * Verifies every independent failure source fails the aggregate. + * + * @return void + * @since 1.0.0 + */ + public function testMaintenanceResultDetectsModuleOperationAndProvisioningFailures(): void + { + $now = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $failedModule = new MaintenanceModuleResult( + 'KJV', + MaintenanceModuleResult::STATUS_FAILED, + 'Snapshot failed.', + ); + $failedProvisioning = new ProvisioningResult( + 'install', + [ + new ModuleProvisioningResult( + 'KJV', + ModuleProvisioningResult::ACTION_INSTALL, + ModuleProvisioningResult::STATUS_FAILED, + 'Install failed.', + ), + ], + ); + + self::assertFalse( + (new MaintenanceResult('refresh', $now, $now, true, [$failedModule], null, [])) + ->succeeded(), + ); + self::assertFalse( + (new MaintenanceResult('refresh', $now, $now, true, [], null, ['Run failed.'])) + ->succeeded(), + ); + self::assertFalse( + (new MaintenanceResult('refresh', $now, $now, true, [], $failedProvisioning, [])) + ->succeeded(), + ); + } + + /** + * Verifies operational status exposes state, policy, and capabilities. + * + * @return void + * @since 1.0.0 + */ + public function testStatusExposesCompleteHealthSnapshot(): void + { + $checked = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $nextDue = $checked->modify('+1 month'); + $state = MaintenanceState::empty()->succeededAt($checked); + $capabilities = ProvisioningCapabilities::abiV1(); + $status = new MaintenanceStatus( + $checked, + false, + $nextDue, + $state, + $capabilities, + false, + ['KJV'], + ['KJV', 'WEB'], + ); + + self::assertFalse($status->due()); + self::assertSame($state, $status->state()); + self::assertSame([ + 'checked_at' => $checked->format(DATE_ATOM), + 'due' => false, + 'next_due_at' => $nextDue->format(DATE_ATOM), + 'provisioning_enabled' => false, + 'capabilities' => $capabilities->toArray(), + 'configured_modules' => ['KJV'], + 'installed_modules' => ['KJV', 'WEB'], + 'state' => $state->toArray(), + ], $status->toArray()); + } + + /** + * Verifies malformed maintenance values are rejected. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidMaintenanceValuesAreRejected(): void + { + try { + new MaintenanceModuleResult('', MaintenanceModuleResult::STATUS_READY); + self::fail('An empty module identifier was accepted.'); + } catch (\InvalidArgumentException) { + } + + try { + new MaintenanceModuleResult('KJV', 'unknown'); + self::fail('An unsupported module status was accepted.'); + } catch (\InvalidArgumentException) { + } + + $now = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + try { + new MaintenanceResult('', $now, $now, true, [], null, []); + self::fail('An empty maintenance operation was accepted.'); + } catch (\InvalidArgumentException) { + } + + try { + new MaintenanceResult('refresh', $now, $now->modify('-1 second'), true, [], null, []); + self::fail('Reverse timestamps were accepted.'); + } catch (\InvalidArgumentException) { + } + + try { + new MaintenanceResult('refresh', $now, $now, true, ['invalid'], null, []); + self::fail('A non-module result was accepted.'); + } catch (\InvalidArgumentException) { + } + + $this->expectException(\InvalidArgumentException::class); + new MaintenanceResult('refresh', $now, $now, true, [], null, ['']); + } +} diff --git a/tests/Unit/Module/ModuleIdentifierTest.php b/tests/Unit/Module/ModuleIdentifierTest.php new file mode 100644 index 0000000..936a1b8 --- /dev/null +++ b/tests/Unit/Module/ModuleIdentifierTest.php @@ -0,0 +1,82 @@ +expectException(\InvalidArgumentException::class); + ModuleIdentifier::normalize($identifier); + } + + /** + * Verifies invalid control bytes are rendered as bounded safe diagnostics. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidIdentifierDiagnosticIsPrintableAndBounded(): void + { + try { + ModuleIdentifier::normalize(str_repeat('A', 129) . "\0"); + self::fail('The oversized identifier was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringNotContainsString("\0", $exception->getMessage()); + self::assertStringEndsWith('...".', $exception->getMessage()); + self::assertLessThan(180, strlen($exception->getMessage())); + } + } + + /** + * Returns invalid identifier candidates. + * + * @return iterable + * @since 1.0.0 + */ + public static function invalidIdentifiers(): iterable + { + yield 'empty' => ['']; + yield 'dot' => ['.']; + yield 'parent' => ['..']; + yield 'slash' => ['KJV/../../evil']; + yield 'backslash' => ['KJV\\evil']; + yield 'control' => ["KJ\nV"]; + yield 'trailing NUL' => ["KJV\0"]; + yield 'unicode' => ['ΚJV']; + yield 'oversized' => [str_repeat('A', 129)]; + } +} diff --git a/tests/Unit/Provisioning/AbiV1ModuleProvisionerTest.php b/tests/Unit/Provisioning/AbiV1ModuleProvisionerTest.php new file mode 100644 index 0000000..f7ce7d8 --- /dev/null +++ b/tests/Unit/Provisioning/AbiV1ModuleProvisionerTest.php @@ -0,0 +1,59 @@ +capabilities(); + + self::assertSame('getbiblesword.ndjson/v1', $capabilities->contract()); + self::assertFalse($capabilities->isAvailable()); + } + + /** + * Verifies every mutation fails with the stable actionable exception. + * + * @return void + * @since 1.0.0 + */ + public function testEveryMutationIsRejected(): void + { + $provisioner = new AbiV1ModuleProvisioner(); + $operations = [ + static fn () => $provisioner->installTranslations(['KJV']), + static fn () => $provisioner->installAllTranslations(), + static fn () => $provisioner->refreshTranslations(['KJV']), + static fn () => $provisioner->removeTranslation('KJV'), + ]; + + foreach ($operations as $operation) { + try { + $operation(); + self::fail('ABI v1 unexpectedly accepted a mutating operation.'); + } catch (ProvisioningUnavailableException $exception) { + self::assertStringContainsString('preinstalled SWORD modules', $exception->getMessage()); + } + } + } +} diff --git a/tests/Unit/Provisioning/ProvisioningCoordinatorOperationsTest.php b/tests/Unit/Provisioning/ProvisioningCoordinatorOperationsTest.php new file mode 100644 index 0000000..2538a9d --- /dev/null +++ b/tests/Unit/Provisioning/ProvisioningCoordinatorOperationsTest.php @@ -0,0 +1,174 @@ +createMock(ModuleProvisionerInterface::class); + $provisioner->method('capabilities')->willReturn($capabilities); + $provisioner->expects(self::once()) + ->method('installTranslations') + ->with(['KJV']) + ->willReturn($this->provisioningResult( + 'install-selected', + 'KJV', + ModuleProvisioningResult::ACTION_INSTALL, + )); + $provisioner->expects(self::once()) + ->method('installAllTranslations') + ->willReturn(new ProvisioningResult('install-all', [])); + $provisioner->expects(self::once()) + ->method('removeTranslation') + ->with('WEB') + ->willReturn($this->provisioningResult( + 'remove', + 'WEB', + ModuleProvisioningResult::ACTION_REMOVE, + )); + + $lock = $this->createMock(ModuleRootLockInterface::class); + $lock->expects(self::exactly(3)) + ->method('write') + ->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $catalog = $this->createMock(ModuleCatalogInterface::class); + $catalog->expects(self::exactly(3))->method('clear'); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::exactly(3))->method('clear'); + $coordinator = new ProvisioningCoordinator( + $provisioner, + $lock, + $catalog, + $snapshots, + new Dispatcher(), + ); + + self::assertSame($capabilities, $coordinator->capabilities()); + self::assertSame(['KJV'], $coordinator->install([' KJV ', 'KJV'])->installed()); + self::assertSame([], $coordinator->installAll()->modules()); + self::assertSame(['WEB'], $coordinator->remove(' WEB ')->removed()); + } + + /** + * Verifies invalid requested identifiers fail before locking. + * + * @return void + * @since 1.0.0 + */ + public function testInvalidInstallRequestsFailBeforeLocking(): void + { + $provisioner = $this->createStub(ModuleProvisionerInterface::class); + $lock = $this->createMock(ModuleRootLockInterface::class); + $lock->expects(self::never())->method('write'); + $coordinator = new ProvisioningCoordinator( + $provisioner, + $lock, + $this->createStub(ModuleCatalogInterface::class), + $this->createStub(SnapshotManagerInterface::class), + new Dispatcher(), + ); + + foreach ([[], ['../KJV']] as $modules) { + try { + $coordinator->install($modules); + self::fail('The invalid install request was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + + try { + (new \ReflectionMethod($coordinator, 'install'))->invoke($coordinator, [123]); + self::fail('A non-string module identifier was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('strings', $exception->getMessage()); + } + } + + /** + * Verifies backend failures emit failure state and retain caches. + * + * @return void + * @since 1.0.0 + */ + public function testBackendFailureDoesNotInvalidateCaches(): void + { + $provisioner = $this->createStub(ModuleProvisionerInterface::class); + $provisioner->method('capabilities')->willReturn(ProvisioningCapabilities::abiV1()); + $provisioner->method('refreshTranslations')->willThrowException(new \RuntimeException('backend failed')); + $lock = $this->createStub(ModuleRootLockInterface::class); + $lock->method('write')->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $catalog = $this->createMock(ModuleCatalogInterface::class); + $catalog->expects(self::never())->method('clear'); + $snapshots = $this->createMock(SnapshotManagerInterface::class); + $snapshots->expects(self::never())->method('clear'); + $dispatcher = new Dispatcher(); + $failedEvents = 0; + $dispatcher->addListener(EventName::PROVISIONING_FAILED, static function () use (&$failedEvents): void { + ++$failedEvents; + }); + $coordinator = new ProvisioningCoordinator($provisioner, $lock, $catalog, $snapshots, $dispatcher); + + try { + $coordinator->refresh(); + self::fail('The backend failure was swallowed.'); + } catch (\RuntimeException $exception) { + self::assertSame('backend failed', $exception->getMessage()); + } + + self::assertSame(1, $failedEvents); + } + + /** + * Creates one changed provisioning result. + * + * @param string $operation Operation name. + * @param string $module Module identifier. + * @param string $action Module action. + * + * @return ProvisioningResult + * @since 1.0.0 + */ + private function provisioningResult( + string $operation, + string $module, + string $action, + ): ProvisioningResult { + return new ProvisioningResult($operation, [ + new ModuleProvisioningResult( + $module, + $action, + ModuleProvisioningResult::STATUS_CHANGED, + ), + ]); + } +} diff --git a/tests/Unit/Provisioning/ProvisioningCoordinatorTest.php b/tests/Unit/Provisioning/ProvisioningCoordinatorTest.php index a19d0a0..9aea496 100644 --- a/tests/Unit/Provisioning/ProvisioningCoordinatorTest.php +++ b/tests/Unit/Provisioning/ProvisioningCoordinatorTest.php @@ -8,6 +8,7 @@ use GetBible\Scripture\Catalog\ModuleCatalogInterface; use GetBible\Scripture\Domain\TranslationMetadata; +use GetBible\Scripture\Event\EventName; use GetBible\Scripture\Infrastructure\Lock\ModuleRootLockInterface; use GetBible\Scripture\Provisioning\ModuleProvisionerInterface; use GetBible\Scripture\Provisioning\ModuleProvisioningResult; @@ -234,12 +235,20 @@ public function clear(): void } }; + $dispatcher = new Dispatcher(); + $listenerCalls = 0; + $throwingListener = static function () use (&$listenerCalls): void { + ++$listenerCalls; + throw new \RuntimeException('An observer cannot change the provisioning outcome.'); + }; + $dispatcher->addListener(EventName::PROVISIONING_STARTED, $throwingListener); + $dispatcher->addListener(EventName::PROVISIONING_COMPLETED, $throwingListener); $coordinator = new ProvisioningCoordinator( $provisioner, $lock, $catalog, $snapshots, - new Dispatcher(), + $dispatcher, ); $result = $coordinator->refresh([' KJV ', 'KJV', 'WEB']); @@ -248,5 +257,6 @@ public function clear(): void self::assertSame(1, $lock->writes); self::assertSame(1, $catalog->clears); self::assertSame(1, $snapshots->clears); + self::assertSame(2, $listenerCalls); } } diff --git a/tests/Unit/Provisioning/ProvisioningValueObjectsTest.php b/tests/Unit/Provisioning/ProvisioningValueObjectsTest.php new file mode 100644 index 0000000..6a0b31e --- /dev/null +++ b/tests/Unit/Provisioning/ProvisioningValueObjectsTest.php @@ -0,0 +1,220 @@ +backend()); + self::assertSame('fixture/v1', $capabilities->contract()); + self::assertTrue($capabilities->canInstallSelected()); + self::assertFalse($capabilities->canInstallAll()); + self::assertTrue($capabilities->canRefresh()); + self::assertFalse($capabilities->canRemove()); + self::assertTrue($capabilities->isAvailable()); + self::assertSame([ + 'backend' => 'fixture', + 'contract' => 'fixture/v1', + 'install_selected' => true, + 'install_all' => false, + 'refresh' => true, + 'remove' => false, + ], $capabilities->toArray()); + } + + /** + * Verifies ABI v1 accurately reports no mutating support. + * + * @return void + * @since 1.0.0 + */ + public function testAbiV1CapabilitiesAreUnavailable(): void + { + $capabilities = ProvisioningCapabilities::abiV1(); + + self::assertSame('getbible/sword', $capabilities->backend()); + self::assertSame('getbiblesword.ndjson/v1', $capabilities->contract()); + self::assertFalse($capabilities->canInstallSelected()); + self::assertFalse($capabilities->canInstallAll()); + self::assertFalse($capabilities->canRefresh()); + self::assertFalse($capabilities->canRemove()); + self::assertFalse($capabilities->isAvailable()); + } + + /** + * Verifies backend and contract identifiers are required. + * + * @return void + * @since 1.0.0 + */ + public function testCapabilitiesRejectBlankIdentifiers(): void + { + foreach ([['', 'v1'], ['backend', " \t"]] as [$backend, $contract]) { + try { + new ProvisioningCapabilities($backend, $contract, false, false, false, false); + self::fail('A blank provisioning identifier was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertStringContainsString('identifiers', $exception->getMessage()); + } + } + } + + /** + * Verifies one module result exposes its complete serialized outcome. + * + * @return void + * @since 1.0.0 + */ + public function testModuleResultExposesCompleteOutcome(): void + { + $result = new ModuleProvisioningResult( + 'KJV', + ModuleProvisioningResult::ACTION_REFRESH, + ModuleProvisioningResult::STATUS_FAILED, + 'Network unavailable.', + ['attempt' => 2, 'retryable' => true], + ); + + self::assertSame('KJV', $result->module()); + self::assertSame(ModuleProvisioningResult::ACTION_REFRESH, $result->action()); + self::assertSame(ModuleProvisioningResult::STATUS_FAILED, $result->status()); + self::assertSame('Network unavailable.', $result->message()); + self::assertSame(['attempt' => 2, 'retryable' => true], $result->details()); + self::assertTrue($result->failed()); + self::assertSame([ + 'module' => 'KJV', + 'action' => 'refresh', + 'status' => 'failed', + 'message' => 'Network unavailable.', + 'details' => ['attempt' => 2, 'retryable' => true], + ], $result->toArray()); + } + + /** + * Verifies module outcomes reject missing identifiers and unknown values. + * + * @return void + * @since 1.0.0 + */ + public function testModuleResultRejectsInvalidMembers(): void + { + foreach ( + [ + ['', ModuleProvisioningResult::ACTION_INSTALL, ModuleProvisioningResult::STATUS_CHANGED], + ['KJV', 'upgrade', ModuleProvisioningResult::STATUS_CHANGED], + ['KJV', ModuleProvisioningResult::ACTION_INSTALL, 'unknown'], + ] as [$module, $action, $status] + ) { + try { + new ModuleProvisioningResult($module, $action, $status); + self::fail('An invalid module provisioning result was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** + * Verifies aggregate result filters preserve backend ordering. + * + * @return void + * @since 1.0.0 + */ + public function testProvisioningResultExposesEveryOutcomeGroup(): void + { + $installed = new ModuleProvisioningResult( + 'KJV', + ModuleProvisioningResult::ACTION_INSTALL, + ModuleProvisioningResult::STATUS_CHANGED, + ); + $updated = new ModuleProvisioningResult( + 'WEB', + ModuleProvisioningResult::ACTION_REFRESH, + ModuleProvisioningResult::STATUS_CHANGED, + ); + $skipped = new ModuleProvisioningResult( + 'ASV', + ModuleProvisioningResult::ACTION_REFRESH, + ModuleProvisioningResult::STATUS_SKIPPED, + ); + $removed = new ModuleProvisioningResult( + 'OLD', + ModuleProvisioningResult::ACTION_REMOVE, + ModuleProvisioningResult::STATUS_CHANGED, + ); + $failed = new ModuleProvisioningResult( + 'BROKEN', + ModuleProvisioningResult::ACTION_INSTALL, + ModuleProvisioningResult::STATUS_FAILED, + ); + $result = new ProvisioningResult('synchronize', [$installed, $updated, $skipped, $removed, $failed]); + + self::assertSame('synchronize', $result->operation()); + self::assertSame([$installed, $updated, $skipped, $removed, $failed], $result->modules()); + self::assertSame(['KJV'], $result->installed()); + self::assertSame(['WEB'], $result->updated()); + self::assertSame(['ASV'], $result->skipped()); + self::assertSame(['OLD'], $result->removed()); + self::assertSame(['BROKEN'], $result->failed()); + self::assertFalse($result->succeeded()); + self::assertTrue($result->changed()); + self::assertFalse($result->toArray()['succeeded']); + self::assertCount(5, $result->toArray()['modules']); + } + + /** + * Verifies an empty aggregate is successful and unchanged. + * + * @return void + * @since 1.0.0 + */ + public function testEmptyProvisioningResultIsSuccessfulAndUnchanged(): void + { + $result = new ProvisioningResult('noop', []); + + self::assertTrue($result->succeeded()); + self::assertFalse($result->changed()); + self::assertSame([], $result->failed()); + } + + /** + * Verifies aggregate operation names and outcome objects are validated. + * + * @return void + * @since 1.0.0 + */ + public function testProvisioningResultRejectsInvalidMembers(): void + { + foreach ([['', []], ['refresh', ['KJV']]] as [$operation, $modules]) { + try { + new ProvisioningResult($operation, $modules); + self::fail('An invalid aggregate provisioning result was accepted.'); + } catch (\InvalidArgumentException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } +} diff --git a/tests/Unit/Service/ScriptureBehaviorTest.php b/tests/Unit/Service/ScriptureBehaviorTest.php new file mode 100644 index 0000000..1734a58 --- /dev/null +++ b/tests/Unit/Service/ScriptureBehaviorTest.php @@ -0,0 +1,303 @@ +cachePath = sys_get_temp_dir() . '/getbible-scripture-facade-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($this->cachePath, 0700, true)); + } + + /** + * Removes the isolated cache root. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $this->deleteDirectory($this->cachePath); + } + + /** + * Verifies every application-facing facade operation delegates and invalidates safely. + * + * @return void + * @since 1.0.0 + */ + public function testDelegatesQueriesMaintenanceAndProvisioning(): void + { + $firstSnapshot = SnapshotFixtureFactory::create($this->cachePath); + $secondSnapshot = SnapshotFixtureFactory::create( + $this->cachePath, + new \DateTimeImmutable('2026-07-27T12:00:00+00:00'), + new \DateTimeImmutable('2026-08-27T12:00:00+00:00'), + ); + $activeSnapshot = $firstSnapshot; + $catalog = $this->createStub(ModuleCatalogInterface::class); + $catalog->method('translations')->willReturn([$firstSnapshot->metadata()]); + $snapshots = $this->createStub(SnapshotManagerInterface::class); + $snapshots->method('get')->willReturnCallback( + static function (string $module) use (&$activeSnapshot): SnapshotIndex { + self::assertSame('TestBible', $module); + + return $activeSnapshot; + }, + ); + $snapshots->method('refresh')->willReturn($secondSnapshot); + $capabilities = new ProvisioningCapabilities('test', 'test/v1', true, true, true, true); + $provisioningResult = new ProvisioningResult('test', []); + $provisioning = $this->provisioningMock($capabilities, $provisioningResult); + [$maintenance, $initialize, $refresh, $skipped, $due, $status] = $this->maintenanceMock( + $capabilities, + ); + $scripture = new Scripture($catalog, $snapshots, $provisioning, $maintenance); + + $translations = $scripture->translations(); + self::assertCount(1, $translations); + self::assertSame('TestBible', $translations[0]->name()->bytes()); + $translation = $scripture->translation(' TestBible '); + self::assertSame($translation, $scripture->translation('TestBible')); + self::assertSame('Word', $scripture->verse('TestBible', 'John', 1, 1)->stripped()?->requireUtf8()); + self::assertCount(1, $scripture->verses('TestBible', 'John', 1, 1, 1)); + self::assertSame($status, $scripture->maintenanceStatus()); + self::assertTrue($scripture->canProvisionModules()); + self::assertSame($capabilities, $scripture->provisioningCapabilities()); + + self::assertSame($initialize, $scripture->initialize(['TestBible'], true)); + $afterInitialize = $scripture->translation('TestBible'); + self::assertNotSame($translation, $afterInitialize); + + self::assertSame($refresh, $scripture->refresh(['TestBible'])); + $afterRefresh = $scripture->translation('TestBible'); + self::assertNotSame($afterInitialize, $afterRefresh); + + self::assertSame($skipped, $scripture->refreshIfDue(['TestBible'])); + self::assertSame($afterRefresh, $scripture->translation('TestBible')); + self::assertSame($due, $scripture->refreshIfDue(['TestBible'])); + self::assertNotSame($afterRefresh, $scripture->translation('TestBible')); + + $refreshed = $scripture->refreshTranslation('TestBible'); + self::assertSame('2026-07-27T12:00:00+00:00', $refreshed->activatedAt()->format(DATE_ATOM)); + self::assertSame($provisioningResult, $scripture->installTranslations(['TestBible'])); + self::assertSame($provisioningResult, $scripture->installAllTranslations()); + self::assertSame($provisioningResult, $scripture->refreshModules()); + self::assertSame($provisioningResult, $scripture->refreshSelectedModules(['TestBible'])); + self::assertSame($provisioningResult, $scripture->removeTranslation('TestBible')); + } + + /** + * Verifies both facade insertion paths enforce their bounded LRU cache. + * + * @return void + * @since 1.0.0 + */ + public function testTranslationCacheRemainsBounded(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + $catalog = $this->createStub(ModuleCatalogInterface::class); + $snapshots = $this->createStub(SnapshotManagerInterface::class); + $snapshots->method('get')->willReturn($snapshot); + $snapshots->method('refresh')->willReturn($snapshot); + $scripture = new Scripture( + $catalog, + $snapshots, + $this->createStub(ProvisioningCoordinatorInterface::class), + $this->createStub(MaintenanceServiceInterface::class), + ); + $seed = new Translation('TestBible', $snapshot); + $property = new \ReflectionProperty(Scripture::class, 'translations'); + + $property->setValue($scripture, $this->fullTranslationCache($seed)); + self::assertSame('TestBible', $scripture->translation('TestBible')->moduleName()); + $afterGet = $property->getValue($scripture); + self::assertIsArray($afterGet); + self::assertCount(32, $afterGet); + self::assertArrayNotHasKey('Seed0', $afterGet); + + $property->setValue($scripture, $this->fullTranslationCache($seed)); + self::assertSame('TestBible', $scripture->refreshTranslation('TestBible')->moduleName()); + $afterRefresh = $property->getValue($scripture); + self::assertIsArray($afterRefresh); + self::assertCount(32, $afterRefresh); + self::assertArrayNotHasKey('Seed0', $afterRefresh); + } + + /** + * Creates a full cache state for the facade's eviction boundary. + * + * @param Translation $translation Type-safe seed value. + * + * @return array + * @since 1.0.0 + */ + private function fullTranslationCache(Translation $translation): array + { + $translations = []; + + for ($index = 0; $index < 32; ++$index) { + $translations['Seed' . $index] = $translation; + } + + return $translations; + } + + /** + * Creates a provisioning mock covering every mutation operation. + * + * @param ProvisioningCapabilities $capabilities Fixed capability set. + * @param ProvisioningResult $result Fixed operation result. + * + * @return ProvisioningCoordinatorInterface&MockObject + * @since 1.0.0 + */ + private function provisioningMock( + ProvisioningCapabilities $capabilities, + ProvisioningResult $result, + ): ProvisioningCoordinatorInterface&MockObject { + $provisioning = $this->createMock(ProvisioningCoordinatorInterface::class); + $provisioning->method('capabilities')->willReturn($capabilities); + $provisioning->expects(self::once())->method('install')->with(['TestBible'])->willReturn($result); + $provisioning->expects(self::once())->method('installAll')->willReturn($result); + $provisioning->expects(self::exactly(2)) + ->method('refresh') + ->willReturnCallback( + static function (array $modules = []) use ($result): ProvisioningResult { + self::assertTrue($modules === [] || $modules === ['TestBible']); + + return $result; + }, + ); + $provisioning->expects(self::once())->method('remove')->with('TestBible')->willReturn($result); + + return $provisioning; + } + + /** + * Creates maintenance results and a mock covering each facade operation. + * + * @param ProvisioningCapabilities $capabilities Fixed capability set. + * + * @return array{ + * MaintenanceServiceInterface&MockObject, + * MaintenanceResult, + * MaintenanceResult, + * MaintenanceResult, + * MaintenanceResult, + * MaintenanceStatus + * } + * @since 1.0.0 + */ + private function maintenanceMock(ProvisioningCapabilities $capabilities): array + { + $now = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $initialize = new MaintenanceResult('initialize', $now, $now, true, [], null, []); + $refresh = new MaintenanceResult('refresh', $now, $now, true, [], null, []); + $skipped = new MaintenanceResult( + 'refresh-if-due', + $now, + $now, + false, + [], + null, + [], + 'Not due.', + ); + $due = new MaintenanceResult('refresh-if-due', $now, $now, true, [], null, []); + $status = new MaintenanceStatus( + $now, + true, + null, + MaintenanceState::empty(), + $capabilities, + true, + ['TestBible'], + ['TestBible'], + ); + $maintenance = $this->createMock(MaintenanceServiceInterface::class); + $maintenance->expects(self::once()) + ->method('initialize') + ->with(['TestBible'], true) + ->willReturn($initialize); + $maintenance->expects(self::once())->method('refresh')->with(['TestBible'])->willReturn($refresh); + $maintenance->expects(self::exactly(2)) + ->method('refreshIfDue') + ->with(['TestBible']) + ->willReturnOnConsecutiveCalls($skipped, $due); + $maintenance->expects(self::once())->method('status')->willReturn($status); + + return [$maintenance, $initialize, $refresh, $skipped, $due, $status]; + } + + /** + * Recursively removes a controlled test directory. + * + * @param string $path Controlled temporary path. + * + * @return void + * @since 1.0.0 + */ + private function deleteDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/Setup/FixedRuntimeInspector.php b/tests/Unit/Setup/FixedRuntimeInspector.php new file mode 100644 index 0000000..881f609 --- /dev/null +++ b/tests/Unit/Setup/FixedRuntimeInspector.php @@ -0,0 +1,40 @@ +report; + } +} diff --git a/tests/Unit/Setup/FreshContainerApplicationWarmerTest.php b/tests/Unit/Setup/FreshContainerApplicationWarmerTest.php new file mode 100644 index 0000000..d41a95a --- /dev/null +++ b/tests/Unit/Setup/FreshContainerApplicationWarmerTest.php @@ -0,0 +1,127 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-fresh-warmer-' + . bin2hex(random_bytes(8)); + require_once __DIR__ . '/NativeSwordFunctionOverrides.php'; + + if (!\class_exists(\GetBible\Sword\Engine::class, false)) { + require_once __DIR__ . '/../../Fixtures/Native/GetBible/Sword/Engine.php'; + } + + NativeRuntimeState::$extensionLoaded = true; + NativeRuntimeState::$engineClassAvailable = true; + NativeRuntimeState::$extensionVersion = '0.1.0'; + NativeRuntimeState::$throwMetadata = false; + NativeRuntimeState::$fixturePath = __DIR__ . '/../../Fixtures/test-bible.ndjson'; + } + + /** + * Removes the generated cache and restores the native probe. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + NativeRuntimeState::reset(); + $this->removeDirectory($this->directory); + } + + /** + * Verifies selected installed modules are warmed through the fresh container. + * + * @return void + * @since 1.0.0 + */ + public function testWarmsSelectedModuleThroughFreshContainer(): void + { + $configuration = Configuration::fromEnvironment([ + 'module_path' => $this->directory . '/sword', + 'cache_path' => $this->directory . '/cache', + 'modules' => ['TestBible'], + 'auto_refresh' => false, + 'lock_timeout' => 2, + ]); + + $result = (new FreshContainerApplicationWarmer())->warm( + $configuration, + ['TestBible'], + ); + + self::assertTrue($result->succeeded()); + self::assertCount(1, $result->modules()); + self::assertSame('TestBible', $result->modules()[0]->module()); + self::assertSame(MaintenanceModuleResult::STATUS_READY, $result->modules()[0]->status()); + self::assertFileExists($configuration->maintenanceStatePath()); + } + + /** + * Recursively removes one isolated test directory. + * + * @param string $directory Directory path. + * + * @return void + * @since 1.0.0 + */ + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir() && !$item->isLink()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Setup/NativeRuntimeState.php b/tests/Unit/Setup/NativeRuntimeState.php new file mode 100644 index 0000000..0ec9bb9 --- /dev/null +++ b/tests/Unit/Setup/NativeRuntimeState.php @@ -0,0 +1,79 @@ + + * @since 1.0.0 + */ + public array $modules = []; + + /** + * Records and successfully warms the candidate. + * + * @param Configuration $configuration Candidate configuration. + * @param list $modules Explicit targets. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + public function warm(Configuration $configuration, array $modules = []): MaintenanceResult + { + $this->configuration = $configuration; + $this->modules = $modules; + $now = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + return new MaintenanceResult( + 'initialize', + $now, + $now, + true, + array_map( + static fn (string $module): MaintenanceModuleResult => new MaintenanceModuleResult( + $module, + MaintenanceModuleResult::STATUS_READY, + ), + $modules, + ), + null, + [], + ); + } +} diff --git a/tests/Unit/Setup/RuntimePrerequisiteInspectorTest.php b/tests/Unit/Setup/RuntimePrerequisiteInspectorTest.php new file mode 100644 index 0000000..5c19ecc --- /dev/null +++ b/tests/Unit/Setup/RuntimePrerequisiteInspectorTest.php @@ -0,0 +1,135 @@ +inspect(); + + self::assertFalse($report->ready()); + self::assertFalse($report->extensionLoaded()); + self::assertNull($report->extensionVersion()); + self::assertNull($report->abiVersion()); + self::assertNull($report->contractIdentifier()); + self::assertNull($report->productVersion()); + self::assertNull($report->error()); + } + + /** + * Verifies compatible native metadata produces a ready report. + * + * @return void + * @since 1.0.0 + */ + public function testReportsCompatibleNativeMetadata(): void + { + $this->loadFakeEngine(); + NativeRuntimeState::$extensionLoaded = true; + NativeRuntimeState::$engineClassAvailable = true; + NativeRuntimeState::$extensionVersion = '0.1.0'; + + $report = (new RuntimePrerequisiteInspector())->inspect(); + + self::assertTrue($report->ready()); + self::assertSame('0.1.0', $report->extensionVersion()); + self::assertSame(1, $report->abiVersion()); + self::assertSame('getbiblesword.ndjson/v1', $report->contractIdentifier()); + self::assertSame('0.3.0', $report->productVersion()); + self::assertNull($report->error()); + } + + /** + * Verifies native metadata exceptions become safe diagnostic reports. + * + * @return void + * @since 1.0.0 + */ + public function testCapturesNativeMetadataFailure(): void + { + $this->loadFakeEngine(); + NativeRuntimeState::$extensionLoaded = true; + NativeRuntimeState::$engineClassAvailable = true; + NativeRuntimeState::$extensionVersion = '0.1.0'; + NativeRuntimeState::$throwMetadata = true; + + $report = (new RuntimePrerequisiteInspector())->inspect(); + + self::assertFalse($report->ready()); + self::assertTrue($report->extensionLoaded()); + self::assertTrue($report->engineClassAvailable()); + self::assertSame('0.1.0', $report->extensionVersion()); + self::assertNull($report->abiVersion()); + self::assertNull($report->contractIdentifier()); + self::assertNull($report->productVersion()); + self::assertSame('native metadata failed', $report->error()); + } + + /** + * Loads the deterministic Engine substitute when the real extension is absent. + * + * @return void + * @since 1.0.0 + */ + private function loadFakeEngine(): void + { + if (\extension_loaded('getbiblesword')) { + self::markTestSkipped('Native metadata substitution requires the extension to be absent.'); + } + + if (!\class_exists(\GetBible\Sword\Engine::class, false)) { + require_once __DIR__ . '/../../Fixtures/Native/GetBible/Sword/Engine.php'; + } + + NativeRuntimeState::$throwMetadata = false; + } +} diff --git a/tests/Unit/Setup/RuntimePrerequisiteReportTest.php b/tests/Unit/Setup/RuntimePrerequisiteReportTest.php new file mode 100644 index 0000000..43ed238 --- /dev/null +++ b/tests/Unit/Setup/RuntimePrerequisiteReportTest.php @@ -0,0 +1,61 @@ +ready()); + self::assertTrue($report->toArray()['abi']['compatible']); + self::assertTrue($report->toArray()['contract']['compatible']); + } + + /** + * Verifies an ABI mismatch is reported without being accepted. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsAbiMismatch(): void + { + $report = new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + 2, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + + self::assertFalse($report->ready()); + self::assertFalse($report->toArray()['abi']['compatible']); + } +} diff --git a/tests/Unit/Setup/SetupServiceFailureTest.php b/tests/Unit/Setup/SetupServiceFailureTest.php new file mode 100644 index 0000000..96631c1 --- /dev/null +++ b/tests/Unit/Setup/SetupServiceFailureTest.php @@ -0,0 +1,212 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-setup-failures-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes setup files created by a test. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $path = $this->directory . '/configuration.json'; + + if (is_file($path)) { + unlink($path); + } + + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + /** + * Verifies inspection delegates to the configured read-only inspector. + * + * @return void + * @since 1.0.0 + */ + public function testInspectReturnsInspectorReport(): void + { + $report = $this->readyRuntime(); + $inspector = $this->createMock(RuntimePrerequisiteInspectorInterface::class); + $inspector->expects(self::once())->method('inspect')->willReturn($report); + $warmer = $this->createStub(ApplicationWarmerInterface::class); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + $inspector, + $warmer, + ); + + self::assertSame($report, $service->inspect()); + } + + /** + * Verifies setup refuses to invent a persistence location. + * + * @return void + * @since 1.0.0 + */ + public function testApplyRequiresDurableConfigurationPath(): void + { + $previous = getenv('GETBIBLE_SCRIPTURE_CONFIG_PATH'); + putenv('GETBIBLE_SCRIPTURE_CONFIG_PATH'); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + $this->createStub(RuntimePrerequisiteInspectorInterface::class), + $this->createStub(ApplicationWarmerInterface::class), + ); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Supply an absolute configuration path'); + + $service->apply(new SetupRequest(null, ['cache_path' => '/srv/cache'])); + } finally { + $this->restoreEnvironment('GETBIBLE_SCRIPTURE_CONFIG_PATH', $previous); + } + } + + /** + * Verifies no-warm setup persists without invoking the warmer. + * + * @return void + * @since 1.0.0 + */ + public function testNoWarmPersistsWithoutInvokingWarmer(): void + { + $inspector = $this->createMock(RuntimePrerequisiteInspectorInterface::class); + $inspector->expects(self::once())->method('inspect')->willReturn($this->readyRuntime()); + $warmer = $this->createMock(ApplicationWarmerInterface::class); + $warmer->expects(self::never())->method('warm'); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + $inspector, + $warmer, + ); + + $result = $service->apply(new SetupRequest( + $this->directory . '/configuration.json', + ['cache_path' => '/srv/cache'], + false, + )); + + self::assertTrue($result->succeeded()); + self::assertFalse($result->warmRequested()); + self::assertNull($result->maintenance()); + self::assertFileExists($this->directory . '/configuration.json'); + } + + /** + * Verifies warming exceptions become stable setup diagnostics. + * + * @return void + * @since 1.0.0 + */ + public function testConvertsWarmerExceptionToResultError(): void + { + $inspector = $this->createStub(RuntimePrerequisiteInspectorInterface::class); + $inspector->method('inspect')->willReturn($this->readyRuntime()); + $warmer = $this->createMock(ApplicationWarmerInterface::class); + $warmer->expects(self::once()) + ->method('warm') + ->willThrowException(new \RuntimeException('snapshot storage is unavailable')); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + $inspector, + $warmer, + ); + + $result = $service->apply(new SetupRequest( + $this->directory . '/configuration.json', + ['cache_path' => '/srv/cache', 'modules' => ['KJV']], + true, + )); + + self::assertFalse($result->succeeded()); + self::assertNull($result->maintenance()); + self::assertSame( + ['Scripture warming failed: snapshot storage is unavailable'], + $result->errors(), + ); + } + + /** + * Creates a compatible native runtime report. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + private function readyRuntime(): RuntimePrerequisiteReport + { + return new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + RuntimePrerequisiteReport::EXPECTED_ABI, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + } + + /** + * Restores one process environment variable. + * + * @param string $name Variable name. + * @param string|false $value Previous value. + * + * @return void + * @since 1.0.0 + */ + private function restoreEnvironment(string $name, string|false $value): void + { + if (is_string($value)) { + putenv($name . '=' . $value); + + return; + } + + putenv($name); + } +} diff --git a/tests/Unit/Setup/SetupServiceTest.php b/tests/Unit/Setup/SetupServiceTest.php new file mode 100644 index 0000000..5378b9b --- /dev/null +++ b/tests/Unit/Setup/SetupServiceTest.php @@ -0,0 +1,190 @@ +directory = sys_get_temp_dir() + . '/getbible-scripture-setup-service-' + . bin2hex(random_bytes(8)); + } + + /** + * Removes isolated setup files. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $path = $this->directory . '/configuration.json'; + + if (is_file($path)) { + unlink($path); + } + + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + /** + * Verifies setup persists validated settings and warms through its seam. + * + * @return void + * @since 1.0.0 + */ + public function testPersistsAndWarmsConfiguration(): void + { + $warmer = new RecordingApplicationWarmer(); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + new FixedRuntimeInspector($this->readyRuntime()), + $warmer, + ); + $path = $this->directory . '/configuration.json'; + + $result = $service->apply(new SetupRequest( + $path, + [ + 'module_path' => '/srv/sword', + 'cache_path' => '/srv/cache', + 'modules' => ['KJV'], + ], + true, + )); + + self::assertTrue($result->succeeded()); + self::assertSame(['KJV'], $warmer->modules); + self::assertSame('/srv/sword', $warmer->configuration?->modulePath()); + self::assertSame(['KJV'], $service->configuration($path)->modules()); + } + + /** + * Verifies missing native prerequisites never call the warmer. + * + * @return void + * @since 1.0.0 + */ + public function testDoesNotWarmIncompatibleRuntime(): void + { + $warmer = new RecordingApplicationWarmer(); + $service = new SetupService( + new JsonConfigurationRepositoryFactory(), + new FixedRuntimeInspector(new RuntimePrerequisiteReport( + false, + false, + null, + null, + null, + null, + )), + $warmer, + ); + + $result = $service->apply(new SetupRequest( + $this->directory . '/configuration.json', + [ + 'cache_path' => '/srv/cache', + 'modules' => ['KJV'], + ], + true, + )); + + self::assertFalse($result->succeeded()); + self::assertNull($warmer->configuration); + self::assertSame( + ['The active PHP runtime is not ready for Scripture warming.'], + $result->errors(), + ); + } + + /** + * Verifies request settings override environment and persisted JSON. + * + * @return void + * @since 1.0.0 + */ + public function testRequestOverridesEnvironmentAndPersistedConfiguration(): void + { + $path = $this->directory . '/configuration.json'; + $factory = new JsonConfigurationRepositoryFactory(); + $factory->create($path)->save(Configuration::fromEnvironment([ + 'cache_path' => '/persisted/cache', + ])); + $previous = getenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH=/environment/cache'); + + try { + $service = new SetupService( + $factory, + new FixedRuntimeInspector($this->readyRuntime()), + new RecordingApplicationWarmer(), + ); + $result = $service->apply(new SetupRequest( + $path, + ['cache_path' => '/request/cache'], + false, + )); + + self::assertTrue($result->succeeded()); + self::assertSame('/request/cache', $result->configuration()->cachePath()); + } finally { + if (is_string($previous)) { + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH=' . $previous); + } else { + putenv('GETBIBLE_SCRIPTURE_CACHE_PATH'); + } + } + } + + /** + * Creates a compatible runtime report. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + private function readyRuntime(): RuntimePrerequisiteReport + { + return new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + RuntimePrerequisiteReport::EXPECTED_ABI, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + } +} diff --git a/tests/Unit/Setup/SetupValueObjectsTest.php b/tests/Unit/Setup/SetupValueObjectsTest.php new file mode 100644 index 0000000..498be5c --- /dev/null +++ b/tests/Unit/Setup/SetupValueObjectsTest.php @@ -0,0 +1,273 @@ + '/srv/cache', 'modules' => ['KJV']], + false, + ); + + self::assertSame('/srv/application/scripture.json', $request->configurationPath()); + self::assertSame( + ['cache_path' => '/srv/cache', 'modules' => ['KJV']], + $request->values(), + ); + self::assertFalse($request->warm()); + } + + /** + * Verifies a compatibility report exposes and serializes every diagnostic. + * + * @return void + * @since 1.0.0 + */ + public function testRuntimeReportExposesCompleteDiagnostics(): void + { + $report = new RuntimePrerequisiteReport( + true, + false, + '0.1.0', + 2, + 'other-contract', + '0.3.0', + 'Engine class unavailable.', + ); + + self::assertFalse($report->ready()); + self::assertTrue($report->extensionLoaded()); + self::assertFalse($report->engineClassAvailable()); + self::assertSame('0.1.0', $report->extensionVersion()); + self::assertSame(2, $report->abiVersion()); + self::assertSame('other-contract', $report->contractIdentifier()); + self::assertSame('0.3.0', $report->productVersion()); + self::assertSame('Engine class unavailable.', $report->error()); + self::assertSame([ + 'ready' => false, + 'extension_loaded' => true, + 'engine_class_available' => false, + 'extension_version' => '0.1.0', + 'abi' => [ + 'expected' => RuntimePrerequisiteReport::EXPECTED_ABI, + 'actual' => 2, + 'compatible' => false, + ], + 'contract' => [ + 'expected' => RuntimePrerequisiteReport::EXPECTED_CONTRACT, + 'actual' => 'other-contract', + 'compatible' => false, + ], + 'product_version' => '0.3.0', + 'error' => 'Engine class unavailable.', + ], $report->toArray()); + } + + /** + * Verifies an empty runtime inspection error is rejected. + * + * @return void + * @since 1.0.0 + */ + public function testRuntimeReportRejectsEmptyError(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be non-empty'); + + new RuntimePrerequisiteReport(false, false, null, null, null, null, ' '); + } + + /** + * Verifies a no-warm setup succeeds and serializes complete configuration. + * + * @return void + * @since 1.0.0 + */ + public function testSetupResultSerializesSuccessfulNoWarmOutcome(): void + { + $configuration = $this->configuration(); + $runtime = $this->readyRuntime(); + $result = new SetupResult( + '/srv/application/scripture.json', + $configuration, + $runtime, + false, + null, + ); + + self::assertTrue($result->succeeded()); + self::assertSame('/srv/application/scripture.json', $result->configurationPath()); + self::assertSame($configuration, $result->configuration()); + self::assertSame($runtime, $result->runtime()); + self::assertFalse($result->warmRequested()); + self::assertNull($result->maintenance()); + self::assertSame([], $result->errors()); + self::assertSame([ + 'module_path' => '/srv/sword', + 'cache_path' => '/srv/cache', + 'refresh_interval' => 'P7D', + 'auto_refresh' => false, + 'lock_timeout' => 45, + 'modules' => ['KJV'], + 'provisioning_enabled' => false, + 'install_all' => false, + ], $result->toArray()['configuration']); + } + + /** + * Verifies requested warming succeeds only with a successful result. + * + * @return void + * @since 1.0.0 + */ + public function testSetupResultReflectsWarmingOutcome(): void + { + $successful = new SetupResult( + '/srv/application/scripture.json', + $this->configuration(), + $this->readyRuntime(), + true, + $this->maintenance(MaintenanceModuleResult::STATUS_READY), + ); + $failed = new SetupResult( + '/srv/application/scripture.json', + $this->configuration(), + $this->readyRuntime(), + true, + $this->maintenance(MaintenanceModuleResult::STATUS_FAILED), + ); + + self::assertTrue($successful->succeeded()); + self::assertFalse($failed->succeeded()); + self::assertSame('initialize', $successful->maintenance()?->operation()); + self::assertFalse($failed->toArray()['succeeded']); + } + + /** + * Verifies explicit errors override otherwise successful outcomes. + * + * @return void + * @since 1.0.0 + */ + public function testSetupResultReportsExplicitErrors(): void + { + $result = new SetupResult( + '/srv/application/scripture.json', + $this->configuration(), + $this->readyRuntime(), + false, + null, + ['Persistence warning.'], + ); + + self::assertFalse($result->succeeded()); + self::assertSame(['Persistence warning.'], $result->errors()); + self::assertSame(['Persistence warning.'], $result->toArray()['errors']); + } + + /** + * Verifies empty setup errors are rejected. + * + * @return void + * @since 1.0.0 + */ + public function testSetupResultRejectsEmptyError(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must be non-empty'); + + new SetupResult( + '/srv/application/scripture.json', + $this->configuration(), + $this->readyRuntime(), + false, + null, + [' '], + ); + } + + /** + * Creates complete candidate configuration. + * + * @return Configuration + * @since 1.0.0 + */ + private function configuration(): Configuration + { + return Configuration::fromEnvironment([ + 'module_path' => '/srv/sword', + 'cache_path' => '/srv/cache', + 'refresh_interval' => 'P7D', + 'auto_refresh' => false, + 'lock_timeout' => 45, + 'modules' => ['KJV'], + ]); + } + + /** + * Creates a compatible runtime report. + * + * @return RuntimePrerequisiteReport + * @since 1.0.0 + */ + private function readyRuntime(): RuntimePrerequisiteReport + { + return new RuntimePrerequisiteReport( + true, + true, + '0.1.0', + RuntimePrerequisiteReport::EXPECTED_ABI, + RuntimePrerequisiteReport::EXPECTED_CONTRACT, + '0.3.0', + ); + } + + /** + * Creates one deterministic maintenance result. + * + * @param string $status Module status. + * + * @return MaintenanceResult + * @since 1.0.0 + */ + private function maintenance(string $status): MaintenanceResult + { + $time = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + + return new MaintenanceResult( + 'initialize', + $time, + $time, + true, + [new MaintenanceModuleResult('KJV', $status)], + null, + [], + ); + } +} diff --git a/tests/Unit/Snapshot/SnapshotIndexBehaviorTest.php b/tests/Unit/Snapshot/SnapshotIndexBehaviorTest.php new file mode 100644 index 0000000..2f6521f --- /dev/null +++ b/tests/Unit/Snapshot/SnapshotIndexBehaviorTest.php @@ -0,0 +1,372 @@ +cachePath = sys_get_temp_dir() . '/getbible-snapshot-index-' . bin2hex(random_bytes(8)); + self::assertTrue(mkdir($this->cachePath, 0700, true)); + } + + /** + * Removes the isolated cache root. + * + * @return void + * @since 1.0.0 + */ + protected function tearDown(): void + { + $this->deleteDirectory($this->cachePath); + } + + /** + * Verifies metadata, index enumeration, hydration, and freshness accessors. + * + * @return void + * @since 1.0.0 + */ + public function testExposesCompleteValidatedSnapshotSurface(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + $metadata = $snapshot->metadata(); + $book = $snapshot->bookMetadata('2:4'); + + self::assertSame('TestBible', $metadata->name()->requireUtf8()); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/D', $snapshot->generationId()); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/D', $snapshot->indexSha256()); + self::assertSame(['2:4'], $snapshot->bookKeys()); + self::assertSame(2, $book['testament']); + self::assertSame(4, $book['position']); + self::assertSame('John', $book['name']->requireUtf8()); + self::assertSame('John', $book['abbreviation']->requireUtf8()); + self::assertSame('KJV', $book['versification']->requireUtf8()); + self::assertSame([1], $snapshot->chapterNumbers('2:4')); + self::assertSame([1], $snapshot->verseNumbers('2:4', 1)); + self::assertSame('Word', $snapshot->verse('2:4', 1, 1)->stripped()?->requireUtf8()); + self::assertCount(1, $snapshot->verses('2:4', 1)); + self::assertCount(1, $snapshot->verses('2:4', 1, 1, 1)); + self::assertSame([], $snapshot->verses('2:4', 1, 2, 3)); + self::assertCount(1, $snapshot->configEntries()); + self::assertCount(1, $snapshot->configEntriesNamed('DistributionLicense')); + self::assertSame([], $snapshot->configEntriesNamed('Missing')); + self::assertSame([], $snapshot->introductions()); + self::assertSame([], $snapshot->introductions('2:4')); + self::assertCount(1, $snapshot->introductions('2:4', 1)); + self::assertFileExists($snapshot->rawExportPath()); + self::assertSame('2026-07-26T12:00:00+00:00', $snapshot->activatedAt()->format(DATE_ATOM)); + self::assertSame('2026-08-26T12:00:00+00:00', $snapshot->expiresAt()->format(DATE_ATOM)); + self::assertFalse($snapshot->isExpired(new \DateTimeImmutable('2026-08-26T11:59:59+00:00'))); + self::assertTrue($snapshot->isExpired(new \DateTimeImmutable('2026-08-26T12:00:00+00:00'))); + } + + /** + * Verifies absent references are reported through the domain exception. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsAbsentBookChapterAndVerseReferences(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + + foreach ( + [ + static fn (): mixed => $snapshot->bookMetadata('2:99'), + static fn (): mixed => $snapshot->chapterNumbers('2:99'), + static fn (): mixed => $snapshot->verseNumbers('2:4', 99), + static fn (): mixed => $snapshot->verse('2:4', 1, 99), + ] as $operation + ) { + try { + $operation(); + self::fail('An absent snapshot reference must fail.'); + } catch (ReferenceNotFoundException) { + self::addToAssertionCount(1); + } + } + } + + /** + * Verifies records are authenticated again when lazily hydrated. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsRecordModifiedAfterSnapshotOpen(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + $modulePath = $snapshot->rawExportPath(); + $contents = file_get_contents($modulePath); + self::assertIsString($contents); + $modified = str_replace('"V29yZA=="', '"V29yZB=="', $contents); + self::assertSame(strlen($contents), strlen($modified)); + self::assertNotFalse(file_put_contents($modulePath, $modified)); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('digest'); + + $snapshot->verse('2:4', 1, 1); + } + + /** + * Verifies pointer-bound digest and immutable stream integrity checks. + * + * @return void + * @since 1.0.0 + */ + public function testOpenRejectsDigestAndStreamTampering(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + $generationPath = dirname($snapshot->rawExportPath()); + $activatedAt = $snapshot->activatedAt(); + $expiresAt = $snapshot->expiresAt(); + unset($snapshot); + + try { + SnapshotIndex::open($generationPath, $activatedAt, $expiresAt, str_repeat('0', 64)); + self::fail('A mismatched pointer digest must fail.'); + } catch (ContractException $exception) { + self::assertStringContainsString('digest', $exception->getMessage()); + } + + self::assertNotFalse(file_put_contents($generationPath . '/module.ndjson', 'tamper', FILE_APPEND)); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('integrity manifest'); + + SnapshotIndex::open($generationPath, $activatedAt, $expiresAt); + } + + /** + * Verifies invalid generation identities, incomplete files, and malformed indexes fail closed. + * + * @return void + * @since 1.0.0 + */ + public function testOpenRejectsInvalidAndIncompleteGenerations(): void + { + $activatedAt = new \DateTimeImmutable('2026-07-26T12:00:00+00:00'); + $expiresAt = new \DateTimeImmutable('2026-08-26T12:00:00+00:00'); + + try { + SnapshotIndex::open($this->cachePath . '/invalid', $activatedAt, $expiresAt); + self::fail('An invalid generation identity was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('identity', $exception->getMessage()); + } + + $generationPath = $this->cachePath + . '/translations/TestBible/generations/' + . str_repeat('a', 64); + self::assertTrue(mkdir($generationPath, 0700, true)); + self::assertNotFalse(file_put_contents($generationPath . '/index.json', "{}\n")); + + try { + SnapshotIndex::open($generationPath, $activatedAt, $expiresAt); + self::fail('An incomplete generation was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('incomplete', $exception->getMessage()); + } + + self::assertNotFalse(file_put_contents($generationPath . '/module.ndjson', "x\n")); + self::assertNotFalse(file_put_contents($generationPath . '/index.json', "{invalid\n")); + + try { + SnapshotIndex::open($generationPath, $activatedAt, $expiresAt); + self::fail('Malformed snapshot index JSON was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('index JSON is invalid', $exception->getMessage()); + self::assertInstanceOf(\JsonException::class, $exception->getPrevious()); + } + + self::assertNotFalse(file_put_contents($generationPath . '/index.json', "{}\n")); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('Snapshot index must be a JSON object'); + SnapshotIndex::open($generationPath, $activatedAt, $expiresAt); + } + + /** + * Verifies lazily accessed nested book and verse index structures are validated. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMalformedNestedBookAndVerseIndexes(): void + { + $snapshot = SnapshotFixtureFactory::create($this->cachePath); + $generationPath = dirname($snapshot->rawExportPath()); + $activatedAt = $snapshot->activatedAt(); + $expiresAt = $snapshot->expiresAt(); + unset($snapshot); + + $index = $this->readIndex($generationPath); + $books = StructuredData::object($index['books'] ?? null, 'Fixture books'); + $book = StructuredData::object($books['2:4'] ?? null, 'Fixture book'); + $book['testament'] = 'invalid'; + $books['2:4'] = $book; + $index['books'] = $books; + $snapshot = $this->writeAndOpenIndex($generationPath, $index, $activatedAt, $expiresAt); + + try { + $snapshot->bookMetadata('2:4'); + self::fail('Malformed nested book metadata was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('metadata is invalid', $exception->getMessage()); + } + + unset($snapshot); + $index = $this->readIndex($generationPath); + $books = StructuredData::object($index['books'] ?? null, 'Fixture books'); + $book = StructuredData::object($books['2:4'] ?? null, 'Fixture book'); + $book['testament'] = 2; + $chapters = StructuredData::map($book['chapters'] ?? null, 'Fixture chapters'); + $chapter = StructuredData::object($chapters['1'] ?? null, 'Fixture chapter'); + $verses = StructuredData::object($chapter['verses'] ?? null, 'Fixture verses'); + $location = $verses['1:0'] ?? null; + $chapter['verses'] = ['invalid' => $location]; + $chapters['1'] = $chapter; + $book['chapters'] = $chapters; + $books['2:4'] = $book; + $index['books'] = $books; + $snapshot = $this->writeAndOpenIndex($generationPath, $index, $activatedAt, $expiresAt); + + try { + $snapshot->verses('2:4', 1); + self::fail('A malformed verse coordinate was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('coordinate is invalid', $exception->getMessage()); + } + + unset($snapshot); + $index = $this->readIndex($generationPath); + $books = StructuredData::object($index['books'] ?? null, 'Fixture books'); + $book = StructuredData::object($books['2:4'] ?? null, 'Fixture book'); + $chapters = StructuredData::map($book['chapters'] ?? null, 'Fixture chapters'); + $chapter = StructuredData::object($chapters['1'] ?? null, 'Fixture chapter'); + $chapter['verses'] = ['1:0' => 'invalid']; + $chapters['1'] = $chapter; + $book['chapters'] = $chapters; + $books['2:4'] = $book; + $index['books'] = $books; + $snapshot = $this->writeAndOpenIndex($generationPath, $index, $activatedAt, $expiresAt); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('verse index is invalid'); + $snapshot->verses('2:4', 1); + } + + /** + * Reads a fixture index as a mutable test map. + * + * @param string $generationPath Controlled generation path. + * + * @return array + * @since 1.0.0 + */ + private function readIndex(string $generationPath): array + { + $json = file_get_contents($generationPath . '/index.json'); + self::assertIsString($json); + $index = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return StructuredData::object($index, 'Fixture snapshot index'); + } + + /** + * Atomically replaces and opens a deliberately modified fixture index. + * + * @param string $generationPath Controlled generation path. + * @param array $index Modified index. + * @param \DateTimeImmutable $activatedAt Activation time. + * @param \DateTimeImmutable $expiresAt Expiration time. + * + * @return SnapshotIndex + * @since 1.0.0 + */ + private function writeAndOpenIndex( + string $generationPath, + array $index, + \DateTimeImmutable $activatedAt, + \DateTimeImmutable $expiresAt, + ): SnapshotIndex { + $json = json_encode( + $index, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ) . "\n"; + self::assertSame( + strlen($json), + file_put_contents($generationPath . '/index.json', $json), + ); + + return SnapshotIndex::open( + $generationPath, + $activatedAt, + $expiresAt, + hash('sha256', $json), + ); + } + + /** + * Recursively removes a controlled test directory. + * + * @param string $path Controlled temporary path. + * + * @return void + * @since 1.0.0 + */ + private function deleteDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/Snapshot/SnapshotRecordObserverTest.php b/tests/Unit/Snapshot/SnapshotRecordObserverTest.php new file mode 100644 index 0000000..3e09016 --- /dev/null +++ b/tests/Unit/Snapshot/SnapshotRecordObserverTest.php @@ -0,0 +1,202 @@ +records(); + $observer = new SnapshotRecordObserver(); + $observer->onRecord($records[1], 0, 100); + $observer->onRecord($records[3], 100, 100); + $scope = StructuredData::object($records[4]['scope'] ?? null, 'Fixture scope'); + $scope['versification'] = $this->byteValue('LXX'); + $records[4]['scope'] = $scope; + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('Book metadata changes'); + + $observer->onRecord($records[4], 200, 100); + } + + /** + * Verifies different books cannot expose the same lookup alias. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsAmbiguousBookAlias(): void + { + $records = $this->records(); + $observer = new SnapshotRecordObserver(); + $observer->onRecord($records[1], 0, 100); + $observer->onRecord($records[3], 100, 100); + $scope = StructuredData::object($records[4]['scope'] ?? null, 'Fixture scope'); + $scope['book'] = 5; + $records[4]['scope'] = $scope; + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('ambiguous'); + + $observer->onRecord($records[4], 200, 100); + } + + /** + * Verifies module-, book-, and chapter-level introductions are indexed separately. + * + * @return void + * @since 1.0.0 + */ + public function testIndexesIntroductionScopes(): void + { + $records = $this->records(); + $observer = new SnapshotRecordObserver(); + $observer->onRecord($records[1], 0, 100); + + $moduleIntroduction = $records[3]; + $moduleScope = StructuredData::object( + $moduleIntroduction['scope'] ?? null, + 'Fixture module scope', + ); + $moduleScope['testament'] = 0; + $moduleScope['book'] = 0; + $moduleScope['chapter'] = 0; + $moduleScope['verse'] = 0; + $moduleScope['intro_scope'] = 'module'; + unset($moduleScope['book_name'], $moduleScope['book_abbreviation']); + $moduleIntroduction['scope'] = $moduleScope; + $observer->onRecord($moduleIntroduction, 100, 100); + + $bookIntroduction = $records[3]; + $bookScope = StructuredData::object( + $bookIntroduction['scope'] ?? null, + 'Fixture book scope', + ); + $bookScope['chapter'] = 0; + $bookScope['verse'] = 0; + $bookScope['intro_scope'] = 'book'; + $bookIntroduction['scope'] = $bookScope; + $observer->onRecord($bookIntroduction, 200, 100); + $observer->onRecord($records[3], 300, 100); + + $index = $observer->index( + str_repeat('a', 64), + new \DateTimeImmutable('2026-07-26T12:00:00+00:00'), + ); + $books = StructuredData::object($index['books'] ?? null, 'Generated books'); + $book = StructuredData::object($books['2:4'] ?? null, 'Generated book'); + $chapters = StructuredData::map($book['chapters'] ?? null, 'Generated chapters'); + $chapter = StructuredData::object($chapters['1'] ?? null, 'Generated chapter'); + + self::assertCount(1, StructuredData::list($index['introductions'] ?? null, 'Module introductions')); + self::assertCount(1, StructuredData::list($book['introductions'] ?? null, 'Book introductions')); + self::assertCount(1, StructuredData::list($chapter['introductions'] ?? null, 'Chapter introductions')); + } + + /** + * Verifies duplicate verse coordinates cannot overwrite an earlier record. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsDuplicateVerseCoordinate(): void + { + $records = $this->records(); + $observer = new SnapshotRecordObserver(); + $observer->onRecord($records[1], 0, 100); + $observer->onRecord($records[4], 100, 100); + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('Duplicate verse coordinate'); + + $observer->onRecord($records[4], 200, 100); + } + + /** + * Verifies snapshots require exactly a Bible module before finalization. + * + * @return void + * @since 1.0.0 + */ + public function testRejectsMissingAndNonBibleModule(): void + { + try { + (new SnapshotRecordObserver())->index( + str_repeat('a', 64), + new \DateTimeImmutable('2026-07-26T12:00:00+00:00'), + ); + self::fail('A snapshot without module metadata was accepted.'); + } catch (ContractException $exception) { + self::assertStringContainsString('requires one Bible module', $exception->getMessage()); + } + + $records = $this->records(); + $records[1]['classification'] = 'commentary'; + + $this->expectException(ContractException::class); + $this->expectExceptionMessage('classification="bible"'); + (new SnapshotRecordObserver())->onRecord($records[1], 0, 100); + } + + /** + * Loads decoded fixture records. + * + * @return list> + * @since 1.0.0 + */ + private function records(): array + { + $lines = file(__DIR__ . '/../../Fixtures/test-bible.ndjson'); + self::assertIsArray($lines); + $records = []; + + foreach ($lines as $line) { + $records[] = StructuredData::object( + json_decode($line, true, 512, JSON_THROW_ON_ERROR), + 'Fixture record', + ); + } + + return $records; + } + + /** + * Creates a verified byte envelope. + * + * @param string $bytes Exact bytes. + * + * @return array{base64: string, encoding: string, sha256: string, size: int, utf8: string} + * @since 1.0.0 + */ + private function byteValue(string $bytes): array + { + return [ + 'base64' => base64_encode($bytes), + 'encoding' => 'base64', + 'sha256' => hash('sha256', $bytes), + 'size' => strlen($bytes), + 'utf8' => $bytes, + ]; + } +} diff --git a/tests/Unit/Snapshot/TranslationSnapshotManagerTest.php b/tests/Unit/Snapshot/TranslationSnapshotManagerTest.php index 2ef4ce5..95f6606 100644 --- a/tests/Unit/Snapshot/TranslationSnapshotManagerTest.php +++ b/tests/Unit/Snapshot/TranslationSnapshotManagerTest.php @@ -125,6 +125,13 @@ public function clear(): void */ public int $calls = 0; + /** + * Whether extraction should fail after recording the call. + * + * @var bool + */ + public bool $fail = false; + /** * Creates a fixture-backed extractor. * @@ -171,6 +178,11 @@ public function streamModule( int $artifactChunkSize = 1048576, ): int { ++$this->calls; + + if ($this->fail) { + throw new \RuntimeException('Expected extraction failure.'); + } + $contents = file_get_contents($this->fixture); if ($contents === false || fwrite($destination, $contents) !== strlen($contents)) { @@ -226,6 +238,78 @@ public function now(): \DateTimeImmutable $manager->get('TestBible'); self::assertSame(1, $extractor->calls); + + $pointerPath = $this->cachePath . '/translations/TestBible/current.json'; + $pointerJson = file_get_contents($pointerPath); + self::assertIsString($pointerJson); + $pointer = json_decode($pointerJson, true, 32, JSON_THROW_ON_ERROR); + self::assertIsArray($pointer); + $pointer['activated_at'] = '2026-07-27T12:00:00+00:00'; + $pointer['expires_at'] = '2026-08-27T12:00:00+00:00'; + self::assertNotFalse(file_put_contents( + $pointerPath, + json_encode($pointer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n", + )); + $reopened = $manager->get('TestBible'); + + self::assertSame('2026-07-27T12:00:00+00:00', $reopened->activatedAt()->format(DATE_ATOM)); + self::assertSame(1, $extractor->calls); + + $generationPath = $this->cachePath + . '/translations/TestBible/generations/' + . $reopened->generationId(); + self::assertTrue(touch($generationPath, (new \DateTimeImmutable('2026-07-26T12:00:00+00:00'))->getTimestamp())); + self::assertNotFalse(file_put_contents($pointerPath, "{corrupt\n")); + $recovered = $manager->get('TestBible'); + + self::assertSame($reopened->generationId(), $recovered->generationId()); + self::assertSame(1, $extractor->calls); + self::assertStringContainsString( + '"index_sha256"', + (string) file_get_contents($pointerPath), + ); + + $generation = $recovered->generationId(); + unset($translation, $reopened, $recovered, $manager); + gc_collect_cycles(); + + $modulePath = $this->cachePath + . '/translations/TestBible/generations/' + . $generation + . '/module.ndjson'; + self::assertNotFalse(file_put_contents($modulePath, 'tamper', FILE_APPEND)); + $manager = new TranslationSnapshotManager( + $configuration, + $clock, + $catalog, + $extractor, + new ContractV1Validator(), + new Dispatcher(), + new FileModuleRootLock($configuration), + ); + $repaired = new Translation('TestBible', $manager->get('TestBible')); + + self::assertSame('Word', $repaired->book('John')->chapter(1)->verse(1)->stripped()?->requireUtf8()); + self::assertSame(2, $extractor->calls); + + $refreshed = $manager->refresh(' TestBible '); + self::assertSame($generation, $refreshed->generationId()); + self::assertSame(3, $extractor->calls); + + $manager->clear(); + self::assertSame($generation, $manager->get('TestBible')->generationId()); + self::assertSame(3, $extractor->calls); + + $extractor->fail = true; + + try { + $manager->refresh('TestBible'); + self::fail('A native extraction failure was not propagated.'); + } catch (\RuntimeException $exception) { + self::assertSame('Expected extraction failure.', $exception->getMessage()); + } + + self::assertSame(4, $extractor->calls); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..621806d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +