diff --git a/.editorconfig b/.editorconfig
index 36dfb48..15144ff 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -14,5 +14,8 @@ trim_trailing_whitespace = true
[*.yml]
indent_size = 2
+[phars.xml]
+indent_size = 2
+
[*.neon]
indent_style = tab
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000..652863c
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,24 @@
+# Security Policy
+
+## Supported Versions
+
+Security fixes are applied to all active versions listed in the
+[version map](https://github.com/cakephp/queue/wiki#cakephp-version-map).
+Versions marked as EOL no longer receive fixes.
+
+## Reporting a Vulnerability
+
+If you've found a security issue in the CakePHP Queue plugin, please use the following procedure
+instead of the normal bug reporting system. Instead of using the bug tracker,
+or one of the support forums please send an email to security [at] cakephp.org. Emails
+sent to this address go to the CakePHP core team on a private mailing list.
+
+For each report, we try to first confirm the vulnerability. Once confirmed,
+the CakePHP team will take the following actions:
+
+* Acknowledge to the reporter that we've received the issue, and are
+ working on a fix. We ask that the reporter keep the issue confidential until we announce it.
+* Get a fix/patch prepared.
+* Prepare a post describing the vulnerability, and the possible exploits.
+* Release new versions of all affected versions.
+* Prominently feature the problem in the release announcement
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 73a69c3..8441c68 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,94 +3,20 @@ name: CI
on:
push:
branches:
- - master
+ - 2.x
pull_request:
branches:
- '*'
+ workflow_dispatch:
+
+permissions:
+ contents: read
jobs:
testsuite:
- runs-on: ubuntu-18.04
- strategy:
- fail-fast: false
- matrix:
- php-version: ['7.2', '7.4', '8.0', '8.1']
- prefer-lowest: ['']
- include:
- - php-version: '7.2'
- prefer-lowest: 'prefer-lowest'
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php-version }}
- extensions: mbstring, intl
- coverage: pcov
-
- - name: Get composer cache directory
- id: composer-cache
- run: echo "::set-output name=dir::$(composer config cache-files-dir)"
-
- - name: Get date part for cache key
- id: key-date
- run: echo "::set-output name=date::$(date +'%Y-%m')"
-
- - name: Cache composer dependencies
- uses: actions/cache@v3
- with:
- path: ${{ steps.composer-cache.outputs.dir }}
- key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }}
-
- - name: Composer install
- run: |
- if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then
- composer update --prefer-lowest --prefer-stable
- elif ${{ matrix.php-version == '8.1' }}; then
- composer update --ignore-platform-reqs
- else
- composer update
- fi
-
- - name: Run PHPUnit
- run: |
- if ${{ matrix.php-version == '7.4'}}; then
- vendor/bin/phpunit --coverage-clover=coverage.xml
- else
- vendor/bin/phpunit
- fi
-
- - name: Code Coverage Report
- if: matrix.php-version == '7.4'
- uses: codecov/codecov-action@v3
+ uses: cakephp/.github/.github/workflows/testsuite-with-db.yml@5.x
+ secrets: inherit
cs-stan:
- name: Coding Standard & Static Analysis
- runs-on: ubuntu-18.04
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '7.4'
- extensions: mbstring, intl
- coverage: none
- tools: cs2pr
-
- - name: Composer Install
- run: composer stan-setup
-
- - name: Run PHP CodeSniffer
- run: vendor/bin/phpcs --report=checkstyle src/ tests/ | cs2pr
-
- - name: Run psalm
- if: success() || failure()
- run: vendor/bin/psalm.phar --output-format=github
-
- - name: Run phpstan
- if: success() || failure()
- run: vendor/bin/phpstan.phar analyse --error-format=github
+ uses: cakephp/.github/.github/workflows/cs-stan.yml@5.x
+ secrets: inherit
diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml
index 097bf45..b6f78a4 100644
--- a/.github/workflows/deploy_docs_1x.yml
+++ b/.github/workflows/deploy_docs_1x.yml
@@ -4,14 +4,15 @@ name: 'deploy_docs_1x'
on:
push:
branches:
- - master
+ - 1.x
+ workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Cloning repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml
new file mode 100644
index 0000000..48be028
--- /dev/null
+++ b/.github/workflows/deploy_docs_2x.yml
@@ -0,0 +1,25 @@
+---
+name: 'deploy_docs_2x'
+
+on:
+ push:
+ branches:
+ - 2.x
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cloning repo
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Push to dokku
+ uses: dokku/github-action@master
+ with:
+ git_remote_url: 'ssh://dokku@apps.cakephp.org:22/queue-docs-2'
+ git_push_flags: '-f'
+ ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY }}
+ branch: '2.x'
diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml
new file mode 100644
index 0000000..2fa4c61
--- /dev/null
+++ b/.github/workflows/docs-validation.yml
@@ -0,0 +1,27 @@
+name: Documentation Validation
+
+on:
+ push:
+ branches:
+ - 2.x
+ paths:
+ - 'docs/**'
+ - '.github/**'
+ pull_request:
+ paths:
+ - 'docs/**'
+ - '.github/**'
+
+jobs:
+ validate:
+ uses: cakephp/.github/.github/workflows/docs-validation.yml@5.x
+ with:
+ docs-path: 'docs'
+ vitepress-path: 'docs/.vitepress'
+ enable-config-js-check: true
+ enable-json-lint: true
+ enable-toc-check: true
+ enable-spell-check: true
+ enable-markdown-lint: true
+ enable-link-check: true
+ tools-ref: '5.x'
diff --git a/.gitignore b/.gitignore
index 6be9eff..1df2e86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,7 @@
/tags
/composer.lock
/phpunit.xml
-/phpcs.xml
+/tools
/vendor
*.mo
debug.log
@@ -14,7 +14,8 @@ error.log
/tests/test_app/config/Queue/*
/tests/test_app/config/TestsQueue/schema-dump-test.lock
/tests/test_app/Plugin/TestBlog/config/Queue/*
-.phpunit.result.cache
+.phpunit.cache
+.phpcs.cache
# IDE and editor specific files #
#################################
diff --git a/.phive/phars.xml b/.phive/phars.xml
new file mode 100644
index 0000000..848f478
--- /dev/null
+++ b/.phive/phars.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/Dockerfile b/Dockerfile
index 478f14c..d1f1295 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,25 +1,36 @@
-# Basic docker based environment
-# Necessary to trick dokku into building the documentation
-# using dockerfile instead of herokuish
-FROM ubuntu:17.04
-
-# Add basic tools
-RUN apt-get update && \
- apt-get install -y build-essential \
- software-properties-common \
- curl \
- git \
- libxml2 \
- libffi-dev \
- libssl-dev && \
- LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php && \
- apt-get update && \
- apt-get install -y php7.2-cli php7.2-mbstring php7.2-xml php7.2-zip php7.2-intl php7.2-opcache php7.2-sqlite &&\
- apt-get clean &&\
- rm -rf /var/lib/apt/lists/*
-
-WORKDIR /code
-
-VOLUME ["/code"]
-
-CMD [ '/bin/bash' ]
+# ----------------------
+# 1. Build stage
+# ----------------------
+FROM node:24-alpine AS builder
+
+# Git is required because docs/package.json pulls a dependency from GitHub.
+RUN apk add --no-cache git openssh-client
+
+WORKDIR /app/docs
+
+# Copy dependency manifests first to preserve Docker layer caching.
+COPY docs/ ./
+RUN npm ci
+
+# Increase max-old-space-size to avoid memory issues during build
+#ENV NODE_OPTIONS="--max-old-space-size=8192"
+
+# Build the site.
+RUN npm run docs:build
+
+# ----------------------
+# 2. Runtime stage (angie)
+# ----------------------
+FROM docker.angie.software/angie:latest AS runner
+
+# Copy built files
+COPY --from=builder /app/docs/.vitepress/dist /usr/share/angie/html
+
+# Expose port
+EXPOSE 80
+
+# Health check (optional)
+HEALTHCHECK CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1
+
+# Start angie
+CMD ["angie", "-g", "daemon off;"]
diff --git a/README.md b/README.md
index bfb5e2a..3543135 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,18 @@
# Queue plugin for CakePHP
-
+[](https://github.com/cakephp/queue/actions/workflows/ci.yml)
[](LICENSE.txt)
[](https://codecov.io/github/cakephp/queue?branch=master)
+[](https://github.com/cakephp/queue)
[](https://packagist.org/packages/cakephp/queue)
-This is a Queue system for CakePHP 4.
+This is a Queue system for CakePHP.
The plugin consists of a CakePHP shell wrapper and Queueing libraries for the [php-queue](https://php-enqueue.github.io) queue library.
## Installation
-You can install this plugin into your CakePHP application using [Composer](http://getcomposer.org).
+You can install this plugin into your CakePHP application using [Composer](https://getcomposer.org).
Run the following command
```sh
@@ -34,7 +35,7 @@ bin/cake plugin load Cake/Queue
Or you can manually add the loading statement in the **src/Application.php** file of your application:
```php
-public function bootstrap()
+public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('Cake/Queue');
@@ -45,4 +46,4 @@ Additionally, you will need to configure the ``default`` queue configuration in
## Documentation
-Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/queue/1/).
+Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/queue/2/).
diff --git a/composer.json b/composer.json
index 0702200..a59d9b1 100644
--- a/composer.json
+++ b/composer.json
@@ -1,16 +1,19 @@
{
"name": "cakephp/queue",
"description": "Queue plugin for CakePHP",
- "keywords": ["cakephp", "queue"],
- "homepage": "https://github.com/cakephp/queue",
- "type": "cakephp-plugin",
"license": "MIT",
+ "type": "cakephp-plugin",
+ "keywords": [
+ "cakephp",
+ "queue"
+ ],
"authors": [
{
"name": "CakePHP Community",
"homepage": "https://github.com/cakephp/queue/graphs/contributors"
}
],
+ "homepage": "https://github.com/cakephp/queue",
"support": {
"issues": "https://github.com/cakephp/queue/issues",
"forum": "https://stackoverflow.com/tags/cakephp",
@@ -18,16 +21,21 @@
"source": "https://github.com/cakephp/queue"
},
"require": {
- "php": ">=7.2.0",
- "cakephp/cakephp": "^4.1",
+ "php": ">=8.1",
+ "cakephp/cakephp": "^5.1.0",
"enqueue/simple-client": "^0.10",
- "psr/log": "^1.1 || ^2.0"
+ "psr/log": "^3.0"
},
"require-dev": {
- "cakephp/bake": "^2.1",
- "cakephp/cakephp-codesniffer": "^4.0",
+ "cakephp/bake": "^3.5.1",
+ "cakephp/cakephp-codesniffer": "^5.0",
"enqueue/fs": "^0.10",
- "phpunit/phpunit": "^8.5 || ^9.3"
+ "phpunit/phpunit": "^10.5.32 || ^11.3.3 || ^12.0.9"
+ },
+ "suggest": {
+ "cakephp/bake": "Required if you want to generate jobs.",
+ "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs.",
+ "cakedc/cakephp-enqueue": "Required if you want store jobs in database."
},
"autoload": {
"psr-4": {
@@ -36,30 +44,32 @@
},
"autoload-dev": {
"psr-4": {
- "Cake\\Test\\": "vendor/cakephp/cakephp/tests/",
"Cake\\Queue\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/",
"TestApp\\": "tests/test_app/src/"
}
},
- "suggest": {
- "cakephp/bake": "Required if you want to generate jobs."
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ },
+ "sort-packages": true
},
"scripts": {
"check": [
"@cs-check",
"@test"
],
- "cs-check": "phpcs --colors -p src/ tests/",
- "cs-fix": "phpcbf --colors -p src/ tests/",
- "stan": "phpstan analyse src/ && psalm.phar --show-info=false",
- "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.5 psalm/phar:~4.22 && mv composer.backup composer.json",
+ "cs-check": "phpcs",
+ "cs-fix": "phpcbf",
+ "stan": "@phpstan",
+ "phpstan": "tools/phpstan analyse",
+ "stan-baseline": "tools/phpstan --generate-baseline",
+ "stan-setup": "phive install",
+ "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"^2.2\" && mv composer.backup composer.json",
+ "rector-check": "vendor/bin/rector process --dry-run",
+ "rector-fix": "vendor/bin/rector process",
"test": "phpunit",
"test-coverage": "phpunit --coverage-clover=clover.xml"
- },
- "config": {
- "sort-packages": true,
- "allow-plugins": {
- "dealerdirect/phpcodesniffer-composer-installer": true
- }
}
}
diff --git a/config/Migrations/20221007202459_CreateFailedJobs.php b/config/Migrations/20221007202459_CreateFailedJobs.php
new file mode 100644
index 0000000..b067bdc
--- /dev/null
+++ b/config/Migrations/20221007202459_CreateFailedJobs.php
@@ -0,0 +1,58 @@
+table('queue_failed_jobs');
+ $table->addColumn('class', 'string', [
+ 'length' => 255,
+ 'null' => false,
+ 'default' => null,
+ ])
+ ->addColumn('method', 'string', [
+ 'length' => 255,
+ 'null' => false,
+ 'default' => null,
+ ])
+ ->addColumn('data', 'text', [
+ 'null' => false,
+ 'default' => null,
+ ])
+ ->addColumn('config', 'string', [
+ 'length' => 255,
+ 'null' => false,
+ 'default' => null,
+ ])
+ ->addColumn('priority', 'string', [
+ 'length' => 255,
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('queue', 'string', [
+ 'length' => 255,
+ 'null' => false,
+ 'default' => null,
+ ])
+ ->addColumn('exception', 'text', [
+ 'null' => true,
+ 'default' => null,
+ ])
+ ->addColumn('created', 'datetime', [
+ 'default' => null,
+ 'limit' => null,
+ 'null' => true,
+ ])
+ ->create();
+ }
+}
diff --git a/docs.Dockerfile b/docs.Dockerfile
deleted file mode 100644
index 97d2bbb..0000000
--- a/docs.Dockerfile
+++ /dev/null
@@ -1,26 +0,0 @@
-# Generate the HTML output.
-FROM markstory/cakephp-docs-builder as builder
-
-COPY docs /data/docs
-
-RUN cd /data/docs-builder && \
- # In the future repeat website for each version
- make website LANGS="en" SOURCE=/data/docs DEST=/data/website/
-
-# Build a small nginx container with just the static site in it.
-FROM markstory/cakephp-docs-builder:runtime as runtime
-
-# Configure search index script
-ENV LANGS="en"
-ENV SEARCH_SOURCE="/usr/share/nginx/html"
-ENV SEARCH_URL_PREFIX="/queue/1"
-
-COPY --from=builder /data/docs /data/docs
-COPY --from=builder /data/website /data/website
-COPY --from=builder /data/docs-builder/nginx.conf /etc/nginx/conf.d/default.conf
-
-# Move each version into place
-RUN cp -R /data/website/html/* /usr/share/nginx/html \
- && rm -rf /data/website/
-
-RUN ln -s /usr/share/nginx/html /usr/share/nginx/html/
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..974d64e
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+*/public/
+.vitepress/cache
+.vitepress/dist
diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js
new file mode 100644
index 0000000..396b8a0
--- /dev/null
+++ b/docs/.vitepress/config.js
@@ -0,0 +1,53 @@
+import baseConfig from '@cakephp/docs-skeleton/config'
+import { createRequire } from 'module'
+
+const require = createRequire(import.meta.url)
+const tocEn = require('./toc_en.json')
+
+const versions = {
+ text: '2.x',
+ items: [
+ { text: '2.x (current)', link: 'https://book.cakephp.org/queue/2/', target: '_self' },
+ { text: '1.x', link: 'https://book.cakephp.org/queue/1/en/', target: '_self' },
+ ],
+}
+
+export default {
+ extends: baseConfig,
+ srcDir: '.',
+ title: 'Queue',
+ description: 'CakePHP Queue Documentation',
+ base: '/queue/2/',
+ rewrites: {
+ 'en/:slug*': ':slug*',
+ },
+ sitemap: {
+ hostname: 'https://book.cakephp.org/queue/2/',
+ },
+ themeConfig: {
+ siteTitle: false,
+ pluginName: 'Queue',
+ socialLinks: [
+ { icon: 'github', link: 'https://github.com/cakephp/queue' },
+ ],
+ editLink: {
+ pattern: 'https://github.com/cakephp/queue/edit/2.x/docs/:path',
+ text: 'Edit this page on GitHub',
+ },
+ sidebar: tocEn,
+ nav: [
+ { text: 'CakePHP', link: 'https://cakephp.org' },
+ { text: 'API', link: 'https://api.cakephp.org/queue/' },
+ { ...versions },
+ ],
+ },
+ locales: {
+ root: {
+ label: 'English',
+ lang: 'en',
+ themeConfig: {
+ sidebar: tocEn,
+ },
+ },
+ },
+}
diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js
new file mode 100644
index 0000000..e33e19e
--- /dev/null
+++ b/docs/.vitepress/theme/index.js
@@ -0,0 +1 @@
+export { default } from '@cakephp/docs-skeleton'
diff --git a/docs/.vitepress/toc_en.json b/docs/.vitepress/toc_en.json
new file mode 100644
index 0000000..3fc01ab
--- /dev/null
+++ b/docs/.vitepress/toc_en.json
@@ -0,0 +1,29 @@
+{
+ "/": [
+ {
+ "text": "Getting Started",
+ "collapsed": false,
+ "items": [
+ { "text": "Installation and Configuration", "link": "/installation" },
+ { "text": "Defining and Queueing Jobs", "link": "/jobs" }
+ ]
+ },
+ {
+ "text": "Processing Work",
+ "collapsed": false,
+ "items": [
+ { "text": "Queueing Mail", "link": "/mail" },
+ { "text": "Custom Processors", "link": "/processors" },
+ { "text": "Running Workers", "link": "/workers" }
+ ]
+ },
+ {
+ "text": "Operations",
+ "collapsed": false,
+ "items": [
+ { "text": "Failed Jobs", "link": "/failed-jobs" },
+ { "text": "Worker Events", "link": "/events" }
+ ]
+ }
+ ]
+}
diff --git a/docs/config/__init__.py b/docs/config/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/docs/config/all.py b/docs/config/all.py
deleted file mode 100644
index f04051a..0000000
--- a/docs/config/all.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# Global configuration information used across all the
-# translations of documentation.
-#
-# Import the base theme configuration
-from cakephpsphinx.config.all import *
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-
-# The full version, including alpha/beta/rc tags.
-release = '1.x'
-
-# The search index version.
-search_version = 'queue-1'
-
-# The marketing display name for the book.
-version_name = ''
-
-# Project name shown in the black header bar
-project = 'CakePHP Queue'
-
-# Other versions that display in the version picker menu.
-version_list = [
- {'name': '1.x', 'number': '/queue/1/', 'title': '1.x', 'current': True},
-]
-
-# Languages available.
-languages = ['en']
-
-# The GitHub branch name for this version of the docs
-# for edit links to point at.
-branch = 'master'
-
-# Current version being built
-version = '1.x'
-
-show_root_link = True
-
-repository = 'cakephp/queue'
-
-source_path = 'docs/'
-
-hide_page_contents = ('search', '404', 'contents')
diff --git a/docs/en/conf.py b/docs/en/conf.py
deleted file mode 100644
index 25f4da8..0000000
--- a/docs/en/conf.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import sys, os
-
-# Append the top level directory of the docs, so we can import from the config dir.
-sys.path.insert(0, os.path.abspath('..'))
-
-# Pull in all the configuration options defined in the global config file..
-from config.all import *
-
-
-# Language in use for this directory.
-language = 'en'
diff --git a/docs/en/contents.rst b/docs/en/contents.rst
deleted file mode 100644
index 9ba94c3..0000000
--- a/docs/en/contents.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-Contents
-########
-
-.. toctree::
- :maxdepth: 2
- :caption: CakePHP Queue
-
- /index
diff --git a/docs/en/events.md b/docs/en/events.md
new file mode 100644
index 0000000..677b516
--- /dev/null
+++ b/docs/en/events.md
@@ -0,0 +1,13 @@
+# Worker Events
+
+Workers dispatch events during normal message processing. If your queue config defines a `listener`, that class can subscribe to these events and react to worker activity.
+
+## Available Events
+
+- `Processor.message.exception`: dispatched when a message throws an exception. Arguments: `message`, `exception`.
+- `Processor.message.invalid`: dispatched when a message contains an invalid callable. Arguments: `message`.
+- `Processor.message.reject`: dispatched when a message finishes with `Processor::REJECT`. Arguments: `message`.
+- `Processor.message.success`: dispatched when a message finishes with `Processor::ACK`. Arguments: `message`.
+- `Processor.message.failure`: dispatched when a message finishes with `Processor::REQUEUE`. Arguments: `message`.
+- `Processor.message.seen`: dispatched when a message is first observed by the worker. Arguments: `message`.
+- `Processor.message.start`: dispatched immediately before processing begins. Arguments: `message`.
diff --git a/docs/en/failed-jobs.md b/docs/en/failed-jobs.md
new file mode 100644
index 0000000..e0038a2
--- /dev/null
+++ b/docs/en/failed-jobs.md
@@ -0,0 +1,48 @@
+# Failed Jobs
+
+By default, jobs that throw exceptions are requeued indefinitely. A job is treated as failed only when it returns `Processor::REQUEUE` or throws an exception and no retry attempts remain.
+
+Retry limits can come from:
+
+- The job class property `public static $maxAttempts`.
+- The worker command option `--max-attempts`.
+
+If `storeFailedJobs` is enabled, failed jobs are rejected, written to the `queue_failed_jobs` table, and can later be requeued manually. The original `config`, `queue`, and `priority` options are preserved.
+
+Your broker may also support dead-letter queues. Those are separate transport-level features. Queue's failed-job storage is transport-agnostic and only tracks jobs that exhaust Queue's retry handling.
+
+## Requeue Failed Jobs
+
+Push failed jobs back onto the queue:
+
+```bash
+bin/cake queue requeue
+```
+
+Filters:
+
+- Positional `ids` argument: comma-separated failed job IDs.
+- `--class`: filter by job class.
+- `--queue`: filter by queue name.
+- `--config`: filter by queue config.
+- `--force` or `-f`: skip the confirmation prompt.
+
+If no filters are provided, all stored failed jobs are requeued.
+
+## Purge Failed Jobs
+
+Delete failed jobs from the database:
+
+```bash
+bin/cake queue purge_failed
+```
+
+Filters:
+
+- Positional `ids` argument: comma-separated failed job IDs.
+- `--class`: filter by job class.
+- `--queue`: filter by queue name.
+- `--config`: filter by queue config.
+- `--force` or `-f`: skip the confirmation prompt.
+
+If no filters are provided, all stored failed jobs are deleted.
diff --git a/docs/en/index.md b/docs/en/index.md
new file mode 100644
index 0000000..0bd8b38
--- /dev/null
+++ b/docs/en/index.md
@@ -0,0 +1,15 @@
+# Queue
+
+The Queue plugin provides an easy-to-use interface for the [php-enqueue](https://php-enqueue.github.io) project, which abstracts a wide range of queuing backends for CakePHP applications. Use it to move long-running work such as email delivery, notifications, or report generation out of the request cycle.
+
+Queue jobs are regular PHP classes. They can receive constructor dependencies from your application's container and are processed by the included `queue worker` command.
+
+## Documentation Map
+
+- [Installation and Configuration](/installation) covers plugin setup, transport configuration, and failed-job storage.
+- [Defining and Queueing Jobs](/jobs) explains job classes, message handling, unique jobs, and `QueueManager::push()`.
+- [Queueing Mail](/mail) shows how to queue mailer actions or send mail through the queue transport.
+- [Custom Processors](/processors) explains how to replace the default message processor.
+- [Running Workers](/workers) documents the worker command and its options.
+- [Failed Jobs](/failed-jobs) covers storing, requeueing, and purging failed jobs.
+- [Worker Events](/events) lists the events emitted while jobs are processed.
diff --git a/docs/en/index.rst b/docs/en/index.rst
deleted file mode 100644
index 0387c25..0000000
--- a/docs/en/index.rst
+++ /dev/null
@@ -1,308 +0,0 @@
-Queue
-#####
-
-The Queue plugin provides an easy-to-use interface for the `php-queue
-`_ project, which abstracts dozens of queuing
-backends for use within your application. Queues can be used to increase the
-performance of your application by deferring long-running processes - such as
-email or notification sending - until a later time.
-
-Installation
-============
-
-You can install this plugin into your CakePHP application using `composer
-`_.
-
-The recommended way to install composer packages is:
-
-.. code-block:: bash
-
- composer require cakephp/queue
-
-Install the transport you wish to use. For a list of available transports, see
-`this page `_. The example below is for
-pure-php redis:
-
-.. code-block:: bash
-
- composer require enqueue/redis predis/predis:^1
-
-Ensure that the plugin is loaded in your ``src/Application.php`` file, within
-the ``Application::bootstrap()`` function::
-
- $this->addPlugin('Cake/Queue');
-
-Configuration
-=============
-
-The following configuration should be present in the config array of your **config/app.php**::
-
- // ...
- 'Queue' => [
- 'default' => [
- // A DSN for your configured backend. default: null
- 'url' => 'redis:',
-
- // The queue that will be used for sending messages. default: default
- // This can be overriden when queuing or processing messages
- 'queue' => 'default',
-
- // The name of a configured logger, default: null
- 'logger' => 'stdout',
-
- // The name of an event listener class to associate with the worker
- 'listener' => \App\Listener\WorkerListener::class,
-
- // The amount of time in milliseconds to sleep if no jobs are currently available. default: 10000
- 'receiveTimeout' => 10000,
- ]
- ],
- // ...
-
-The ``Queue`` config key can contain one or more queue configurations. Each of
-these is used for interacting with a different queuing backend.
-
-Usage
-=====
-
-Defining Jobs
--------------
-
-Create a Job class::
-
- getArgument('id');
- $data = $message->getArgument('data');
-
- $this->log(sprintf('%d %s', $id, $data));
-
- return Processor::ACK;
- }
- }
-
-The passed ``Message`` object has the following methods:
-
-- ``getArgument($key = null, $default = null)``: Can return the entire passed
- dataset or a value based on a ``Hash::get()`` notation key.
-- ``getContext()``: Returns the original context object.
-- ``getOriginalMessage()``: Returns the original queue message object.
-- ``getParsedBody()``: Returns the parsed queue message body.
-
-A job *may* return any of the following values:
-
-- ``Processor::ACK``: Use this constant when the message is processed
- successfully. The message will be removed from the queue.
-- ``Processor::REJECT``: Use this constant when the message could not be
- processed. The message will be removed from the queue.
-- ``Processor::REQUEUE``: Use this constant when the message is not valid or
- could not be processed right now but we can try again later. The original
- message is removed from the queue but a copy is published to the queue again.
-
-The job **may** also return a null value, which is interpreted as
-``Processor::ACK``. Failure to respond with a valid type will result in an
-interpreted message failure and requeue of the message.
-
-Properties:
-
-- ``maxAttempts``: The maximum number of times the job may be requeued as a result
- of an exception or by explicitly returning ``Processor::REQUEUE``. If
- provided, this value will override the value provided in the worker command
- line option ``--max-attempts``. If a value is not provided by the job or by
- the command line option, the job may be requeued an infinite number of times.
-
-Queueing
---------
-
-Queue the messages using the included `Queue\QueueManager` class::
-
- use App\Job\ExampleJob;
- use Cake\Queue\QueueManager;
-
- $data = ['id' => 7, 'is_premium' => true];
- $options = ['config' => 'default'];
-
- QueueManager::push(ExampleJob::class, $data, $options);
-
-Arguments:
-
-- ``$className``: The class that will have it's execute method invoked when the
- job is processed.
-- ``$data`` (optional): A json-serializable array of data that will be
- passed to your job as a message. It should be key-value pairs.
-- ``$options`` (optional): An array of optional data for message queueing.
-
-The following keys are valid for use within the ``options`` array:
-
-- ``config``:
-
- - default: default
- - description: A queue config name
- - type: string
-
-- ``delay``:
-
- - default: ``null``
- - description: Time - in integer seconds - to delay message, after which it will be processed. Not all message brokers accept this.
- - type: integer
-
-- ``expires``:
-
- - default: ``null``
- - description: Time - in integer seconds - after which the message expires.
- The message will be removed from the queue if this time is exceeded and it
- has not been consumed.
- - type: integer
-
-- ``priority``:
-
- - default: ``null``
- - type: constant
- - valid values:
-
- - ``\Enqueue\Client\MessagePriority::VERY_LOW``
- - ``\Enqueue\Client\MessagePriority::LOW``
- - ``\Enqueue\Client\MessagePriority::NORMAL``
- - ``\Enqueue\Client\MessagePriority::HIGH``
- - ``\Enqueue\Client\MessagePriority::VERY_HIGH``
-
-- ``queue``:
-
- - default: from queue ``config`` array or string ``default`` if empty
- - description: The name of a queue to use
- - type: string
-
-Queuing Mailer Actions
-----------------------
-
-Mailer actions can be queued by adding the ``Queue\Mailer\QueueTrait`` to the
-mailer class. The following example shows how to setup the trait within a mailer
-class::
-
- setTo($emailAddress)
- ->setSubject(sprintf('Welcome %s', $username));
- }
-
- // ... other actions here ...
- }
-
-It is now possible to use the ``UserMailer`` to send out user-related emails in
-a delayed fashion from anywhere in our application. To queue the mailer action,
-use the ``push()`` method on a mailer instance::
-
- $this->getMailer('User')->push('welcome', ['example@example.com', 'josegonzalez']);
-
-This ``QueueTrait::push()`` call will generate an intermediate ``MailerJob``
-that handles processing of the email message. If the MailerJob is unable to
-instantiate the Email or Mailer instances, it is interpreted as
-a ``Processor::REJECT``. An invalid ``action`` is also interpreted as
-a ``Processor::REJECT``, as will the action throwing
-a ``BadMethodCallException``. Any non-exception result will be seen as
-a ``Processor:ACK``.
-
-The exposed ``QueueTrait::push()`` method has a similar signature to
-``Mailer::send()``, and also supports an ``$options`` array argument. The
-options this array holds are the same options as those available for
-``QueueManager::push()``.
-
-Run the worker
-==============
-
-Once a message is queued, you may run a worker via the included ``queue worker`` shell:
-
-.. code-block:: bash
-
- bin/cake queue worker
-
-This shell can take a few different options:
-
-- ``--config`` (default: default): Name of a queue config to use
-- ``--queue`` (default: default): Name of queue to bind to
-- ``--processor`` (default: ``null``): Name of processor to bind to
-- ``--logger`` (default: ``stdout``): Name of a configured logger
-- ``--max-jobs`` (default: ``null``): Maximum number of jobs to process. Worker will exit after limit is reached.
-- ``--max-runtime`` (default: ``null``): Maximum number of seconds to run. Worker will exit after limit is reached.
-- ``--max-attempts`` (default: ``null``): Maximum number of times each job will be attempted. Maximum attempts defined on a job will override this value.
-- ``--verbose`` or ``-v`` (default: ``null``): Provide verbose output, displaying the current values for:
-
- - Max Iterations
- - Max Runtime
- - Runtime: Time since the worker started, the worker will finish when Runtime is over Max Runtime value
-
-Worker Events
-=============
-
-The worker shell may invoke the events during normal execution. These events may
-be listened to by the associated ``listener`` in the Queue config.
-
-- ``Processor.message.exception``:
-
- - description: Dispatched when a message throws an exception.
- - arguments: ``message`` and ``exception``
-
-- ``Processor.message.invalid``:
-
- - description: Dispatched when a message has an invalid callable.
- - arguments: ``message``
-
-- ``Processor.message.reject``:
-
- - description: Dispatched when a message completes and is to be rejected.
- - arguments: ``message``
-
-- ``Processor.message.success``:
-
- - description: Dispatched when a message completes and is to be acknowledged.
- - arguments: ``message``
-
-- ``Processor.message.failure``:
-
- - description: Dispatched when a message completes and is to be requeued.
- - arguments: ``message``
-
-- ``Processor.message.seen``:
-
- - description: Dispatched when a message is seen.
- - arguments: ``message``
-
-- ``Processor.message.start``:
-
- - description: Dispatched before a message is started.
- - arguments: ``message``
diff --git a/docs/en/installation.md b/docs/en/installation.md
new file mode 100644
index 0000000..0719c2a
--- /dev/null
+++ b/docs/en/installation.md
@@ -0,0 +1,75 @@
+# Installation and Configuration
+
+## Installation
+
+Install the plugin with Composer:
+
+```bash
+composer require cakephp/queue
+```
+
+Install the transport package for the broker you want to use. For the list of supported transports, see [php-enqueue transport docs](https://php-enqueue.github.io/transport/). For example, a Redis setup can use:
+
+```bash
+composer require enqueue/redis predis/predis:^3
+```
+
+Load the plugin in your application's `bootstrap()` method:
+
+```php
+$this->addPlugin('Cake/Queue');
+```
+
+## Configuration
+
+Define one or more queue connections in `config/app.php`:
+
+```php
+'Queue' => [
+ 'default' => [
+ // DSN or transport config array. Required.
+ 'url' => 'redis://myusername:mypassword@example.com:1000',
+
+ // Queue name used for publishing and consuming messages.
+ 'queue' => 'default',
+
+ // Optional logger configured through CakePHP's Log class.
+ 'logger' => 'stdout',
+
+ // Optional listener class attached to worker processor events.
+ 'listener' => \App\Listener\WorkerListener::class,
+
+ // Optional processor class. Defaults to Cake\Queue\Queue\Processor.
+ 'processor' => \App\Queue\CustomProcessor::class,
+
+ // Sleep time in milliseconds when no messages are available.
+ 'receiveTimeout' => 10000,
+
+ // Persist jobs that exceed their allowed retry count.
+ 'storeFailedJobs' => true,
+
+ // Cache used to track unique jobs.
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ ],
+],
+```
+
+The `Queue` config key may contain multiple named connections. Each connection can point to a different backend or queue topology.
+
+## Failed Job Storage
+
+When `storeFailedJobs` is enabled, install the migrations plugin and create the `queue_failed_jobs` table:
+
+```bash
+composer require cakephp/migrations:^3.1
+```
+
+```bash
+bin/cake migrations migrate --plugin Cake/Queue
+```
+
+## Unique Jobs
+
+If a job sets `public static $shouldBeUnique = true`, you must configure `uniqueCache`. The cache duration should be longer than the maximum time the job might remain queued, or duplicate jobs may become possible.
diff --git a/docs/en/jobs.md b/docs/en/jobs.md
new file mode 100644
index 0000000..e61698c
--- /dev/null
+++ b/docs/en/jobs.md
@@ -0,0 +1,91 @@
+# Defining and Queueing Jobs
+
+## Defining Jobs
+
+Jobs are classes that implement `Cake\Queue\Job\JobInterface`. They receive a `Cake\Queue\Job\Message` instance and return one of the `Interop\Queue\Processor` status constants.
+
+```php
+getArgument('id');
+ $data = $message->getArgument('data');
+
+ $this->log(sprintf('%d %s', $id, $data));
+
+ return Processor::ACK;
+ }
+}
+```
+
+Job classes can use constructor injection in the same way as controllers or commands.
+
+## Message Access
+
+The `Message` object exposes:
+
+- `getArgument($key = null, $default = null)` to fetch the full payload or a nested value using `Hash::get()` notation.
+- `getContext()` to access the original queue context.
+- `getOriginalMessage()` to access the broker-specific message object.
+- `getParsedBody()` to inspect the parsed queue body.
+
+## Return Values
+
+A job may return:
+
+- `Processor::ACK` when processing succeeded and the message should be removed.
+- `Processor::REJECT` when processing failed permanently and the message should be removed.
+- `Processor::REQUEUE` when the job should be retried later.
+- `null`, which is treated as `Processor::ACK`.
+
+Returning any other value is treated as a failure and results in the message being requeued.
+
+## Job Properties
+
+- `maxAttempts` limits how many times a job can be retried after an exception or explicit `Processor::REQUEUE`. If unset, the worker's `--max-attempts` option applies. If neither is set, retries are unlimited.
+- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration.
+
+## Queueing Jobs
+
+Use `Cake\Queue\QueueManager` to publish a job:
+
+```php
+use App\Job\ExampleJob;
+use Cake\Queue\QueueManager;
+
+$data = ['id' => 7, 'is_premium' => true];
+$options = ['config' => 'default'];
+
+QueueManager::push(ExampleJob::class, $data, $options);
+```
+
+Arguments:
+
+- The first argument is the job class name.
+- The second argument is an optional JSON-serializable payload array.
+- The third argument is an optional array of queueing options.
+
+Supported options:
+
+- `config`: queue config name. Defaults to `default`.
+- `delay`: delay processing by a number of seconds. Broker support varies.
+- `expires`: expire the message after a number of seconds if it has not been consumed.
+- `priority`: one of `\Enqueue\Client\MessagePriority::VERY_LOW`, `LOW`, `NORMAL`, `HIGH`, or `VERY_HIGH`.
+- `queue`: queue name to use. Defaults to the configured queue, then `default`.
diff --git a/docs/en/mail.md b/docs/en/mail.md
new file mode 100644
index 0000000..ada848c
--- /dev/null
+++ b/docs/en/mail.md
@@ -0,0 +1,64 @@
+# Queueing Mail
+
+## Queueing Mailer Actions
+
+Add `Cake\Queue\Mailer\QueueTrait` to a mailer to make its actions queueable:
+
+```php
+setTo($emailAddress)
+ ->setSubject(sprintf('Welcome %s', $username));
+ }
+}
+```
+
+Queue an action with `push()`:
+
+```php
+$this->getMailer('User')->push('welcome', ['example@example.com', 'josegonzalez']);
+```
+
+`QueueTrait::push()` creates an intermediate `MailerJob` that executes the mailer action later. Its signature mirrors `Mailer::send()` and also accepts the same queueing `$options` supported by `QueueManager::push()`.
+
+If the mailer or email instance cannot be created, the action name is invalid, or the action throws `BadMethodCallException`, the message is rejected.
+
+## Delivering Mail Through Queue Jobs
+
+If you do not use mailers, configure `Cake\Queue\Mailer\Transport\QueueTransport` as an email transport:
+
+```php
+use Cake\Queue\Mailer\Transport\QueueTransport;
+
+return [
+ 'EmailTransport' => [
+ 'default' => [
+ 'className' => MailTransport::class,
+ ],
+ 'queue' => [
+ 'className' => QueueTransport::class,
+ 'transport' => 'default',
+ ],
+ ],
+ 'Email' => [
+ 'default' => [
+ 'transport' => 'queue',
+ ],
+ ],
+];
+```
+
+With this configuration, sending mail with the `default` email profile creates a queue message. When that message is processed, the configured underlying transport sends the email.
diff --git a/docs/en/processors.md b/docs/en/processors.md
new file mode 100644
index 0000000..6e562a1
--- /dev/null
+++ b/docs/en/processors.md
@@ -0,0 +1,126 @@
+# Custom Processors
+
+Queue connections can use a custom processor class by setting the `processor` option in the queue configuration. The class must implement `Interop\Queue\Processor`.
+
+```php
+'Queue' => [
+ 'default' => [
+ 'url' => 'redis://localhost:6379',
+ 'queue' => 'default',
+ ],
+ 'timed' => [
+ 'url' => 'redis://localhost:6379',
+ 'queue' => 'timed',
+ 'processor' => \App\Queue\TimedProcessor::class,
+ ],
+],
+```
+
+Example processor:
+
+```php
+dispatchEvent('Processor.message.seen', ['queueMessage' => $message]);
+
+ $jobMessage = new Message($message, $context, $this->container);
+ try {
+ $jobMessage->getCallable();
+ } catch (RuntimeException | Error $e) {
+ $this->logger->debug('Invalid callable for message. Rejecting message from queue.');
+ $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]);
+
+ return InteropProcessor::REJECT;
+ }
+
+ $startTime = microtime(true) * 1000;
+ $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]);
+
+ try {
+ $response = $this->processMessage($jobMessage);
+ } catch (Throwable $e) {
+ $message->setProperty('jobException', $e);
+
+ $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage()));
+ $this->dispatchEvent('Processor.message.exception', [
+ 'message' => $jobMessage,
+ 'exception' => $e,
+ 'duration' => (int)((microtime(true) * 1000) - $startTime),
+ ]);
+
+ return Result::requeue('Exception occurred while processing message');
+ }
+
+ $duration = (int)((microtime(true) * 1000) - $startTime);
+
+ if ($response === InteropProcessor::ACK) {
+ $this->logger->debug('Message processed successfully');
+ $this->dispatchEvent('Processor.message.success', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
+
+ return InteropProcessor::ACK;
+ }
+
+ if ($response === InteropProcessor::REJECT) {
+ $this->logger->debug('Message processed with rejection');
+ $this->dispatchEvent('Processor.message.reject', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
+
+ return InteropProcessor::REJECT;
+ }
+
+ $this->logger->debug('Message processed with failure, requeuing');
+ $this->dispatchEvent('Processor.message.failure', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
+
+ return InteropProcessor::REQUEUE;
+ }
+}
+```
+
+If no processor is configured, the plugin uses `Cake\Queue\Queue\Processor`.
+
+The worker command's `--processor` option is separate from the config `processor` key:
+
+- Config `processor` selects the PHP class used to handle messages.
+- `--processor` sets the processor name used for Enqueue topic binding.
+
+Examples:
+
+```bash
+bin/cake queue worker --config=timed
+```
+
+```bash
+bin/cake queue worker --config=timed --processor=my-topic-processor
+```
diff --git a/docs/en/workers.md b/docs/en/workers.md
new file mode 100644
index 0000000..f703acd
--- /dev/null
+++ b/docs/en/workers.md
@@ -0,0 +1,31 @@
+# Running Workers
+
+Once jobs have been queued, start a worker with:
+
+```bash
+bin/cake queue worker
+```
+
+The plugin also registers `bin/cake worker` as an alias for the same command.
+
+## Options
+
+- `--config` or `-c`: queue config name. Defaults to `default`.
+- `--queue` or `-Q`: queue name to bind to. Defaults to the queue defined by the selected config.
+- `--processor` or `-p`: processor name used for Enqueue topic binding.
+- `--logger` or `-l`: CakePHP logger name. Used when running with verbose output.
+- `--max-jobs` or `-i`: stop after processing a number of jobs.
+- `--max-runtime` or `-r`: stop after running for a number of seconds.
+- `--max-attempts` or `-a`: maximum retry count for each job unless the job class overrides it.
+- `--verbose` or `-v`: enable verbose output through the selected logger.
+
+## How Workers Are Built
+
+When a worker starts it:
+
+- Loads the named queue config.
+- Instantiates the configured processor class or falls back to `Cake\Queue\Queue\Processor`.
+- Attaches retry limiting and optional failed-job persistence.
+- Applies optional consumption limits such as max jobs and max runtime.
+- Attaches the configured listener class, if any, to processor events.
+- Binds the processor to the selected queue and consumes messages.
diff --git a/docs/package-lock.json b/docs/package-lock.json
new file mode 100644
index 0000000..699d31e
--- /dev/null
+++ b/docs/package-lock.json
@@ -0,0 +1,2138 @@
+{
+ "name": "docs",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "docs",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "@cakephp/docs-skeleton": "https://github.com/cakephp/docs-skeleton.git#node-package",
+ "vitepress": "^2.0.0-alpha.16"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@cakephp/docs-skeleton": {
+ "version": "1.0.0",
+ "resolved": "git+ssh://git@github.com/cakephp/docs-skeleton.git#9501c6d47a80645604fceb31328368eeb6a21bc6",
+ "bin": {
+ "cakedocs": "bin/cakedocs.js"
+ },
+ "peerDependencies": {
+ "vitepress": "^2.0.0-alpha.15"
+ }
+ },
+ "node_modules/@docsearch/css": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz",
+ "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==",
+ "license": "MIT"
+ },
+ "node_modules/@docsearch/js": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.3.tgz",
+ "integrity": "sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==",
+ "license": "MIT"
+ },
+ "node_modules/@docsearch/sidepanel-js": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.3.tgz",
+ "integrity": "sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@iconify-json/simple-icons": {
+ "version": "1.2.88",
+ "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.88.tgz",
+ "integrity": "sha512-+cvi1qCuvReL29ehi6t62L4fb7GDXe+UlGHFcsJcV7I2l9wtqn9XE2IBKcDr3CI5iGUGS5ISnXv699pSGpyx1Q==",
+ "license": "CC0-1.0",
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@shikijs/core": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz",
+ "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4",
+ "hast-util-to-html": "^9.0.5"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz",
+ "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.4"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz",
+ "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz",
+ "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "3.23.0"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz",
+ "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "3.23.0"
+ }
+ },
+ "node_modules/@shikijs/transformers": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz",
+ "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "3.23.0",
+ "@shikijs/types": "3.23.0"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz",
+ "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
+ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+ "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
+ "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/shared": "3.5.39",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
+ "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
+ "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@vue/compiler-core": "3.5.39",
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.15",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
+ "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
+ "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^8.1.5"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
+ "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-shared": "^8.1.5",
+ "birpc": "^2.6.1",
+ "hookable": "^5.5.3",
+ "perfect-debounce": "^2.0.0"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
+ "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
+ "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
+ "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.39",
+ "@vue/shared": "3.5.39"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
+ "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.39",
+ "@vue/runtime-core": "3.5.39",
+ "@vue/shared": "3.5.39",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
+ "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.39",
+ "@vue/shared": "3.5.39"
+ },
+ "peerDependencies": {
+ "vue": "3.5.39"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
+ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
+ "license": "MIT"
+ },
+ "node_modules/@vueuse/core": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
+ "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.3.0",
+ "@vueuse/shared": "14.3.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/integrations": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.3.0.tgz",
+ "integrity": "sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vueuse/core": "14.3.0",
+ "@vueuse/shared": "14.3.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "async-validator": "^4",
+ "axios": "^1",
+ "change-case": "^5",
+ "drauu": "^0.4",
+ "focus-trap": "^7 || ^8",
+ "fuse.js": "^7",
+ "idb-keyval": "^6",
+ "jwt-decode": "^4",
+ "nprogress": "^0.2",
+ "qrcode": "^1.5",
+ "sortablejs": "^1",
+ "universal-cookie": "^7 || ^8",
+ "vue": "^3.5.0"
+ },
+ "peerDependenciesMeta": {
+ "async-validator": {
+ "optional": true
+ },
+ "axios": {
+ "optional": true
+ },
+ "change-case": {
+ "optional": true
+ },
+ "drauu": {
+ "optional": true
+ },
+ "focus-trap": {
+ "optional": true
+ },
+ "fuse.js": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "jwt-decode": {
+ "optional": true
+ },
+ "nprogress": {
+ "optional": true
+ },
+ "qrcode": {
+ "optional": true
+ },
+ "sortablejs": {
+ "optional": true
+ },
+ "universal-cookie": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
+ "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
+ "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/focus-trap": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz",
+ "integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==",
+ "license": "MIT",
+ "dependencies": {
+ "tabbable": "^6.5.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "license": "MIT"
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mark.js": {
+ "version": "8.11.1",
+ "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz",
+ "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==",
+ "license": "MIT"
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/minisearch": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
+ "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
+ "license": "MIT",
+ "dependencies": {
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
+ }
+ },
+ "node_modules/perfect-debounce": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/shiki": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz",
+ "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "3.23.0",
+ "@shikijs/engine-javascript": "3.23.0",
+ "@shikijs/engine-oniguruma": "3.23.0",
+ "@shikijs/langs": "3.23.0",
+ "@shikijs/themes": "3.23.0",
+ "@shikijs/types": "3.23.0",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
+ "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitepress": {
+ "version": "2.0.0-alpha.17",
+ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz",
+ "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@docsearch/css": "^4.5.3",
+ "@docsearch/js": "^4.5.3",
+ "@docsearch/sidepanel-js": "^4.5.3",
+ "@iconify-json/simple-icons": "^1.2.69",
+ "@shikijs/core": "^3.22.0",
+ "@shikijs/transformers": "^3.22.0",
+ "@shikijs/types": "^3.22.0",
+ "@types/markdown-it": "^14.1.2",
+ "@vitejs/plugin-vue": "^6.0.4",
+ "@vue/devtools-api": "^8.0.5",
+ "@vue/shared": "^3.5.27",
+ "@vueuse/core": "^14.2.0",
+ "@vueuse/integrations": "^14.2.0",
+ "focus-trap": "^8.0.0",
+ "mark.js": "8.11.1",
+ "minisearch": "^7.2.0",
+ "shiki": "^3.22.0",
+ "vite": "^7.3.1",
+ "vue": "^3.5.27"
+ },
+ "bin": {
+ "vitepress": "bin/vitepress.js"
+ },
+ "peerDependencies": {
+ "markdown-it-mathjax3": "^4",
+ "oxc-minify": "*",
+ "postcss": "^8"
+ },
+ "peerDependenciesMeta": {
+ "markdown-it-mathjax3": {
+ "optional": true
+ },
+ "oxc-minify": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.39",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
+ "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.39",
+ "@vue/compiler-sfc": "3.5.39",
+ "@vue/runtime-dom": "3.5.39",
+ "@vue/server-renderer": "3.5.39",
+ "@vue/shared": "3.5.39"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 0000000..f140773
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "docs",
+ "version": "1.0.0",
+ "description": "",
+ "main": "config.js",
+ "scripts": {
+ "docs:dev": "vitepress dev",
+ "docs:build": "vitepress build",
+ "docs:preview": "vitepress preview"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "module",
+ "dependencies": {
+ "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package",
+ "vitepress": "^2.0.0-alpha.16"
+ }
+}
diff --git a/docs/public/favicon/apple-touch-icon.png b/docs/public/favicon/apple-touch-icon.png
new file mode 100644
index 0000000..c6d073d
Binary files /dev/null and b/docs/public/favicon/apple-touch-icon.png differ
diff --git a/docs/public/favicon/favicon-96x96.png b/docs/public/favicon/favicon-96x96.png
new file mode 100644
index 0000000..6642e0c
Binary files /dev/null and b/docs/public/favicon/favicon-96x96.png differ
diff --git a/docs/public/favicon/favicon.ico b/docs/public/favicon/favicon.ico
new file mode 100644
index 0000000..405aa94
Binary files /dev/null and b/docs/public/favicon/favicon.ico differ
diff --git a/docs/public/favicon/favicon.svg b/docs/public/favicon/favicon.svg
new file mode 100644
index 0000000..805ef4b
--- /dev/null
+++ b/docs/public/favicon/favicon.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/docs/public/favicon/site.webmanifest b/docs/public/favicon/site.webmanifest
new file mode 100644
index 0000000..4f23fb3
--- /dev/null
+++ b/docs/public/favicon/site.webmanifest
@@ -0,0 +1,21 @@
+{
+ "name": "CakePHP",
+ "short_name": "CakePHP",
+ "icons": [
+ {
+ "src": "/favicon/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/favicon/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/docs/public/favicon/web-app-manifest-192x192.png b/docs/public/favicon/web-app-manifest-192x192.png
new file mode 100644
index 0000000..b5df299
Binary files /dev/null and b/docs/public/favicon/web-app-manifest-192x192.png differ
diff --git a/docs/public/favicon/web-app-manifest-512x512.png b/docs/public/favicon/web-app-manifest-512x512.png
new file mode 100644
index 0000000..6a522de
Binary files /dev/null and b/docs/public/favicon/web-app-manifest-512x512.png differ
diff --git a/docs/public/fonts/cakedingbats-webfont.eot b/docs/public/fonts/cakedingbats-webfont.eot
new file mode 100644
index 0000000..0800d1e
Binary files /dev/null and b/docs/public/fonts/cakedingbats-webfont.eot differ
diff --git a/docs/public/fonts/cakedingbats-webfont.svg b/docs/public/fonts/cakedingbats-webfont.svg
new file mode 100644
index 0000000..d2afda5
--- /dev/null
+++ b/docs/public/fonts/cakedingbats-webfont.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/public/fonts/cakedingbats-webfont.ttf b/docs/public/fonts/cakedingbats-webfont.ttf
new file mode 100644
index 0000000..78ad6c8
Binary files /dev/null and b/docs/public/fonts/cakedingbats-webfont.ttf differ
diff --git a/docs/public/fonts/cakedingbats-webfont.woff b/docs/public/fonts/cakedingbats-webfont.woff
new file mode 100644
index 0000000..a95e1b3
Binary files /dev/null and b/docs/public/fonts/cakedingbats-webfont.woff differ
diff --git a/docs/public/fonts/cakedingbats-webfont.woff2 b/docs/public/fonts/cakedingbats-webfont.woff2
new file mode 100644
index 0000000..2cd9fdd
Binary files /dev/null and b/docs/public/fonts/cakedingbats-webfont.woff2 differ
diff --git a/docs/public/logo.svg b/docs/public/logo.svg
new file mode 100644
index 0000000..829c8e9
--- /dev/null
+++ b/docs/public/logo.svg
@@ -0,0 +1,27 @@
+
+
+
+
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..007dfc4
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,12 @@
+
+
+ src/
+ tests/
+
+
+
+
+
+
+ tests/comparisons/*
+
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 3edb1fc..0f8d406 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -1,7 +1,9 @@
-
+ src/
+ tests/
+
tests/comparisons/*
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 0000000..b2d742e
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,7 @@
+parameters:
+ ignoreErrors:
+ -
+ message: '#^Parameter \#1 \$callback of static method Closure\:\:fromCallable\(\) expects callable\(\)\: mixed, array\{mixed, string\} given\.$#'
+ identifier: argument.type
+ count: 1
+ path: src/Job/Message.php
diff --git a/phpstan.neon b/phpstan.neon
index f5a1214..82f9a15 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,7 +1,14 @@
+includes:
+ - phpstan-baseline.neon
+
parameters:
- level: 6
- checkMissingIterableValueType: false
+ level: 8
+ treatPhpDocTypesAsCertain: false
bootstrapFiles:
- tests/bootstrap.php
paths:
- src/
+ ignoreErrors:
+ - identifier: missingType.iterableValue
+ - identifier: missingType.generics
+ - identifier: trait.unused
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 5ab59dd..fa7442a 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,18 +1,34 @@
-
-
-
-
- ./src/
-
-
-
-
- tests/TestCase
-
-
+
+
+
+ tests/TestCase
+
+
+
+
+
+
+
+
+
+
+ ./src/
+
+
+
+
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
deleted file mode 100644
index c5f6fe5..0000000
--- a/psalm.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..0f74ba1
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,36 @@
+withPhpVersion(PhpVersion::PHP_82)
+ ->withPaths([
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ ->withSkip([
+ DisallowedEmptyRuleFixerRector::class,
+ SimplifyIfElseToTernaryRector::class,
+ MakeInheritedMethodVisibilitySameAsParentRector::class,
+ RemoveNullTagValueNodeRector::class,
+ RemoveUselessReturnTagRector::class,
+ DocblockReturnArrayFromDirectArrayInstanceRector::class => [
+ __DIR__ . '/src/Mailer/Transport/QueueTransport.php',
+ ],
+ 'tests/TestCase/Queue/ProcessorTest.php',
+ ])
+ ->withParallel()
+ ->withPreparedSets(
+ deadCode: true,
+ codeQuality: true,
+ codingStyle: true,
+ typeDeclarationDocblocks: true,
+ );
diff --git a/src/Command/JobCommand.php b/src/Command/JobCommand.php
index 5c7f260..7148df9 100644
--- a/src/Command/JobCommand.php
+++ b/src/Command/JobCommand.php
@@ -17,11 +17,12 @@
namespace Cake\Queue\Command;
use Bake\Command\SimpleBakeCommand;
+use Cake\Console\Arguments;
use Cake\Console\ConsoleOptionParser;
class JobCommand extends SimpleBakeCommand
{
- public $pathFragment = 'Job/';
+ public string $pathFragment = 'Job/';
/**
* @inheritDoc
@@ -47,6 +48,23 @@ public function template(): string
return 'Cake/Queue.job';
}
+ /**
+ * @inheritDoc
+ */
+ public function templateData(Arguments $arguments): array
+ {
+ $parentData = parent::templateData($arguments);
+
+ $maxAttempts = $arguments->getOption('max-attempts');
+
+ $data = [
+ 'isUnique' => $arguments->getOption('unique'),
+ 'maxAttempts' => $maxAttempts ? (int)$maxAttempts : null,
+ ];
+
+ return array_merge($parentData, $data);
+ }
+
/**
* Gets the option parser instance and configures it.
*
@@ -61,6 +79,15 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar
->setDescription('Bake a queue job class.')
->addArgument('name', [
'help' => 'The name of the queue job class to create.',
+ ])
+ ->addOption('max-attempts', [
+ 'help' => 'The maximum number of times the job may be attempted.',
+ 'default' => null,
+ ])
+ ->addOption('unique', [
+ 'help' => 'Whether there should be only one instance of a job on the queue at a time.',
+ 'boolean' => true,
+ 'default' => false,
]);
}
}
diff --git a/src/Command/PurgeFailedCommand.php b/src/Command/PurgeFailedCommand.php
new file mode 100644
index 0000000..c54cc8d
--- /dev/null
+++ b/src/Command/PurgeFailedCommand.php
@@ -0,0 +1,130 @@
+setDescription('Delete failed jobs.');
+
+ $parser->addArgument('ids', [
+ 'required' => false,
+ 'help' => 'Delete jobs by the FailedJob ID (comma-separated).',
+ ]);
+ $parser->addOption('class', [
+ 'help' => 'Delete jobs by the job class.',
+ ]);
+ $parser->addOption('queue', [
+ 'help' => 'Delete jobs by the queue the job was received on.',
+ ]);
+ $parser->addOption('config', [
+ 'help' => 'Delete jobs by the config used to queue the job.',
+ ]);
+ $parser->addOption('force', [
+ 'help' => 'Automatically assume yes in response to confirmation prompt.',
+ 'short' => 'f',
+ 'boolean' => true,
+ ]);
+
+ return $parser;
+ }
+
+ /**
+ * @param \Cake\Console\Arguments $args Arguments
+ * @param \Cake\Console\ConsoleIo $io ConsoleIo
+ * @return int
+ */
+ public function execute(Arguments $args, ConsoleIo $io): int
+ {
+ /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */
+ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs');
+
+ $jobsToDelete = $failedJobsTable->find();
+
+ if ($args->hasArgument('ids')) {
+ $idsArg = $args->getArgument('ids');
+
+ if ($idsArg !== null) {
+ $ids = explode(',', $idsArg);
+
+ $jobsToDelete->whereInList($failedJobsTable->aliasField('id'), $ids);
+ }
+ }
+
+ if ($args->hasOption('class')) {
+ $jobsToDelete->where(['class' => $args->getOption('class')]);
+ }
+
+ if ($args->hasOption('queue')) {
+ $jobsToDelete->where(['queue' => $args->getOption('queue')]);
+ }
+
+ if ($args->hasOption('config')) {
+ $jobsToDelete->where(['config' => $args->getOption('config')]);
+ }
+
+ $deletingCount = $jobsToDelete->count();
+
+ if (!$deletingCount) {
+ $io->out('0 jobs found.');
+
+ return self::CODE_SUCCESS;
+ }
+
+ if (!$args->getOption('force')) {
+ $confirmed = $io->askChoice(sprintf('Delete %s jobs?', $deletingCount), ['y', 'n'], 'n');
+
+ if ($confirmed !== 'y') {
+ return self::CODE_SUCCESS;
+ }
+ }
+
+ $io->out(sprintf('Deleting %s jobs.', $deletingCount));
+
+ $failedJobsTable->deleteManyOrFail($jobsToDelete);
+
+ $io->success($deletingCount . ' jobs deleted.');
+
+ return self::CODE_SUCCESS;
+ }
+}
diff --git a/src/Command/RequeueCommand.php b/src/Command/RequeueCommand.php
new file mode 100644
index 0000000..ce00f7c
--- /dev/null
+++ b/src/Command/RequeueCommand.php
@@ -0,0 +1,165 @@
+setDescription('Requeue failed jobs.');
+
+ $parser->addArgument('ids', [
+ 'required' => false,
+ 'help' => 'Requeue jobs by the FailedJob ID (comma-separated).',
+ ]);
+ $parser->addOption('class', [
+ 'help' => 'Requeue jobs by the job class.',
+ ]);
+ $parser->addOption('queue', [
+ 'help' => 'Requeue jobs by the queue the job was received on.',
+ ]);
+ $parser->addOption('config', [
+ 'help' => 'Requeue jobs by the config used to queue the job.',
+ ]);
+ $parser->addOption('force', [
+ 'help' => 'Automatically assume yes in response to confirmation prompt.',
+ 'short' => 'f',
+ 'boolean' => true,
+ ]);
+
+ return $parser;
+ }
+
+ /**
+ * @param \Cake\Console\Arguments $args Arguments
+ * @param \Cake\Console\ConsoleIo $io ConsoleIo
+ * @return int
+ */
+ public function execute(Arguments $args, ConsoleIo $io): int
+ {
+ /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */
+ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs');
+
+ $jobsToRequeueQuery = $failedJobsTable->find();
+
+ if ($args->hasArgument('ids')) {
+ $idsArg = $args->getArgument('ids');
+
+ if ($idsArg !== null) {
+ $ids = explode(',', $idsArg);
+
+ $jobsToRequeueQuery->whereInList('id', $ids);
+ }
+ }
+
+ if ($args->hasOption('class')) {
+ $jobsToRequeueQuery->where(['class' => $args->getOption('class')]);
+ }
+
+ if ($args->hasOption('queue')) {
+ $jobsToRequeueQuery->where(['queue' => $args->getOption('queue')]);
+ }
+
+ if ($args->hasOption('config')) {
+ $jobsToRequeueQuery->where(['config' => $args->getOption('config')]);
+ }
+
+ $requeueingCount = $jobsToRequeueQuery->count();
+
+ if (!$requeueingCount) {
+ $io->out('0 jobs found.');
+
+ return self::CODE_SUCCESS;
+ }
+
+ if (!$args->getOption('force')) {
+ $confirmed = $io->askChoice(sprintf('Requeue %s jobs?', $requeueingCount), ['y', 'n'], 'n');
+
+ if ($confirmed !== 'y') {
+ return self::CODE_SUCCESS;
+ }
+ }
+
+ $io->out(sprintf('Requeueing %s jobs.', $requeueingCount));
+
+ $succeededCount = 0;
+ $failedCount = 0;
+
+ /** @var array<\Cake\Queue\Model\Entity\FailedJob> $jobsToRequeue */
+ $jobsToRequeue = $jobsToRequeueQuery->all();
+ foreach ($jobsToRequeue as $failedJob) {
+ $io->verbose(sprintf('Requeueing FailedJob with ID %d.', $failedJob->id));
+ try {
+ QueueManager::push(
+ [$failedJob->class, $failedJob->method],
+ $failedJob->decoded_data,
+ [
+ 'config' => $failedJob->config,
+ 'priority' => $failedJob->priority,
+ 'queue' => $failedJob->queue,
+ ],
+ );
+
+ $failedJobsTable->deleteOrFail($failedJob);
+
+ $succeededCount++;
+ } catch (Exception $e) {
+ $io->err('Exception occurred while requeueing FailedJob with ID ' . $failedJob->id);
+ $io->err((string)$e);
+
+ $failedCount++;
+ }
+ }
+
+ if ($failedCount !== 0) {
+ $io->err(sprintf('Failed to requeue %d jobs.', $failedCount));
+ }
+
+ if ($succeededCount !== 0) {
+ $io->success($succeededCount . ' jobs requeued.');
+ }
+
+ return self::CODE_SUCCESS;
+ }
+}
diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php
index 5e95860..44605d1 100644
--- a/src/Command/WorkerCommand.php
+++ b/src/Command/WorkerCommand.php
@@ -21,9 +21,12 @@
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Core\Configure;
+use Cake\Core\ContainerInterface;
use Cake\Log\Log;
use Cake\Queue\Consumption\LimitAttemptsExtension;
use Cake\Queue\Consumption\LimitConsumedMessagesExtension;
+use Cake\Queue\Consumption\RemoveUniqueJobIdFromCacheExtension;
+use Cake\Queue\Listener\FailedJobsListener;
use Cake\Queue\Queue\Processor;
use Cake\Queue\QueueManager;
use DateTime;
@@ -31,6 +34,7 @@
use Enqueue\Consumption\Extension\LimitConsumptionTimeExtension;
use Enqueue\Consumption\Extension\LoggerExtension;
use Enqueue\Consumption\ExtensionInterface;
+use Interop\Queue\Processor as InteropProcessor;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
@@ -39,6 +43,14 @@
*/
class WorkerCommand extends Command
{
+ /**
+ * @param \Cake\Core\ContainerInterface|null $container DI container instance
+ */
+ public function __construct(
+ protected readonly ?ContainerInterface $container = null,
+ ) {
+ }
+
/**
* Get the command name.
*
@@ -94,7 +106,7 @@ public function getOptionParser(): ConsoleOptionParser
'short' => 'a',
]);
$parser->setDescription(
- 'Runs a queue worker that consumes from the named queue.'
+ 'Runs a queue worker that consumes from the named queue.',
);
return $parser;
@@ -109,21 +121,32 @@ public function getOptionParser(): ConsoleOptionParser
*/
protected function getQueueExtension(Arguments $args, LoggerInterface $logger): ExtensionInterface
{
+ $limitAttempsExtension = new LimitAttemptsExtension((int)$args->getOption('max-attempts') ?: null);
+
+ $limitAttempsExtension->getEventManager()->on(new FailedJobsListener());
+
+ $configKey = (string)$args->getOption('config');
+ $config = QueueManager::getConfig($configKey);
+
$extensions = [
new LoggerExtension($logger),
- new LimitAttemptsExtension((int)$args->getOption('max-attempts') ?: null),
+ $limitAttempsExtension,
];
- if (!is_null($args->getOption('max-jobs'))) {
+ if ($args->getOption('max-jobs') !== null) {
$maxJobs = (int)$args->getOption('max-jobs');
$extensions[] = new LimitConsumedMessagesExtension($maxJobs);
}
- if (!is_null($args->getOption('max-runtime'))) {
+ if ($args->getOption('max-runtime') !== null) {
$endTime = new DateTime(sprintf('+%d seconds', (int)$args->getOption('max-runtime')));
$extensions[] = new LimitConsumptionTimeExtension($endTime);
}
+ if (isset($config['uniqueCacheKey'])) {
+ $extensions[] = new RemoveUniqueJobIdFromCacheExtension($config['uniqueCacheKey']);
+ }
+
return new ChainExtension($extensions);
}
@@ -144,22 +167,50 @@ protected function getLogger(Arguments $args): LoggerInterface
}
/**
+ * Creates and returns a Processor object
+ *
* @param \Cake\Console\Arguments $args Arguments
* @param \Cake\Console\ConsoleIo $io ConsoleIo
- * @return int|void|null
+ * @param \Psr\Log\LoggerInterface $logger Logger instance
+ * @return \Interop\Queue\Processor
*/
- public function execute(Arguments $args, ConsoleIo $io)
+ protected function getProcessor(Arguments $args, ConsoleIo $io, LoggerInterface $logger): InteropProcessor
{
- $logger = $this->getLogger($args);
- $processor = new Processor($logger);
- $extension = $this->getQueueExtension($args, $logger);
+ $configKey = (string)$args->getOption('config');
+ $config = QueueManager::getConfig($configKey);
+
+ $processorClass = $config['processor'] ?? Processor::class;
+ if (!class_exists($processorClass)) {
+ $io->error(sprintf('Processor class %s not found', $processorClass));
+ $this->abort();
+ }
+
+ if (!is_subclass_of($processorClass, InteropProcessor::class)) {
+ $io->error(sprintf('Processor class %s must implement Interop\Queue\Processor', $processorClass));
+ $this->abort();
+ }
+
+ return new $processorClass($logger, $this->container);
+ }
+
+ /**
+ * @param \Cake\Console\Arguments $args Arguments
+ * @param \Cake\Console\ConsoleIo $io ConsoleIo
+ * @return int
+ */
+ public function execute(Arguments $args, ConsoleIo $io): int
+ {
$config = (string)$args->getOption('config');
if (!Configure::check(sprintf('Queue.%s', $config))) {
$io->error(sprintf('Configuration key "%s" was not found', $config));
$this->abort();
}
+ $logger = $this->getLogger($args);
+ $processor = $this->getProcessor($args, $io, $logger);
+ $extension = $this->getQueueExtension($args, $logger);
+
$hasListener = Configure::check(sprintf('Queue.%s.listener', $config));
if ($hasListener) {
$listenerClassName = Configure::read(sprintf('Queue.%s.listener', $config));
@@ -170,15 +221,21 @@ public function execute(Arguments $args, ConsoleIo $io)
/** @var \Cake\Event\EventListenerInterface $listener */
$listener = new $listenerClassName();
- $processor->getEventManager()->on($listener);
+
+ if ($processor instanceof Processor) {
+ $processor->getEventManager()->on($listener);
+ }
}
+
$client = QueueManager::engine($config);
$queue = $args->getOption('queue')
? (string)$args->getOption('queue')
- : Configure::read("Queue.{$config}.queue", 'default');
- $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : null;
+ : Configure::read(sprintf('Queue.%s.queue', $config), 'default');
+ $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : 'default';
$client->bindTopic($queue, $processor, $processorName);
$client->consume($extension);
+
+ return self::CODE_SUCCESS;
}
}
diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php
index bc85281..721431e 100644
--- a/src/Consumption/LimitAttemptsExtension.php
+++ b/src/Consumption/LimitAttemptsExtension.php
@@ -3,6 +3,7 @@
namespace Cake\Queue\Consumption;
+use Cake\Event\EventDispatcherTrait;
use Cake\Queue\Job\Message;
use Enqueue\Consumption\Context\MessageResult;
use Enqueue\Consumption\MessageResultExtensionInterface;
@@ -12,27 +13,23 @@
class LimitAttemptsExtension implements MessageResultExtensionInterface
{
/**
- * The property key used to set the number of times a message was attempted.
- *
- * @var string
+ * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Job\Message>
*/
- public const ATTEMPTS_PROPERTY = 'attempts';
+ use EventDispatcherTrait;
/**
- * The maximum number of times a job may be attempted. $maxAttempts defined on a
- * Job will override this value.
+ * The property key used to set the number of times a message was attempted.
*
- * @var int|null
+ * @var string
*/
- protected $maxAttempts;
+ public const ATTEMPTS_PROPERTY = 'attempts';
/**
- * @param int|null $maxAttempts The maximum number of times a job may be attempted.
- * @return void
+ * @param int|null $maxAttempts The maximum number of times a job may be attempted. $maxAttempts defined on a Job will override this value.
*/
- public function __construct(?int $maxAttempts = null)
- {
- $this->maxAttempts = $maxAttempts;
+ public function __construct(
+ protected readonly ?int $maxAttempts = null,
+ ) {
}
/**
@@ -41,7 +38,7 @@ public function __construct(?int $maxAttempts = null)
*/
public function onResult(MessageResult $context): void
{
- if ($context->getResult() !== Processor::REQUEUE) {
+ if ($context->getResult() != Processor::REQUEUE) {
return;
}
@@ -59,7 +56,15 @@ public function onResult(MessageResult $context): void
if ($attemptNumber >= $maxAttempts) {
$context->changeResult(
- Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts))
+ Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)),
+ );
+
+ $exception = (string)$message->getProperty('jobException');
+
+ $this->dispatchEvent(
+ 'Consumption.LimitAttemptsExtension.failed',
+ ['exception' => $exception, 'logger' => $context->getLogger()],
+ $jobMessage,
);
return;
@@ -74,7 +79,7 @@ public function onResult(MessageResult $context): void
$producer->send($consumer->getQueue(), $newMessage);
$context->changeResult(
- Result::reject('A copy of the message was sent with an incremented attempt count.')
+ Result::reject('A copy of the message was sent with an incremented attempt count.'),
);
}
}
diff --git a/src/Consumption/LimitConsumedMessagesExtension.php b/src/Consumption/LimitConsumedMessagesExtension.php
index 69f5e75..608be83 100644
--- a/src/Consumption/LimitConsumedMessagesExtension.php
+++ b/src/Consumption/LimitConsumedMessagesExtension.php
@@ -18,22 +18,14 @@
*/
class LimitConsumedMessagesExtension implements PreConsumeExtensionInterface, PostConsumeExtensionInterface
{
- /**
- * @var int
- */
- protected $messageLimit;
-
- /**
- * @var int
- */
- protected $messageConsumed = 0;
+ protected int $messageConsumed = 0;
/**
* @param int $messageLimit The number of messages to process before exiting.
*/
- public function __construct(int $messageLimit)
- {
- $this->messageLimit = $messageLimit;
+ public function __construct(
+ protected readonly int $messageLimit,
+ ) {
}
/**
@@ -79,7 +71,7 @@ protected function shouldBeStopped(LoggerInterface $logger): bool
$logger->debug(sprintf(
'[LimitConsumedMessagesExtension] Message consumption is interrupted since the message limit ' .
'reached. limit: "%s"',
- $this->messageLimit
+ $this->messageLimit,
));
return true;
diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php
new file mode 100644
index 0000000..3a70e17
--- /dev/null
+++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php
@@ -0,0 +1,44 @@
+getMessage();
+
+ $jobMessage = new Message($message, $context->getContext());
+
+ [$class, $method] = $jobMessage->getTarget();
+
+ if (empty($class::$shouldBeUnique)) {
+ return;
+ }
+
+ $data = $jobMessage->getArgument();
+
+ $uniqueId = QueueManager::getUniqueId($class, $method, $data);
+
+ Cache::delete($uniqueId, $this->cache);
+ }
+}
diff --git a/src/Job/MailerJob.php b/src/Job/MailerJob.php
index 66db133..3d6d71e 100644
--- a/src/Job/MailerJob.php
+++ b/src/Job/MailerJob.php
@@ -41,13 +41,13 @@ public function execute(Message $message): ?string
try {
$mailer = $this->getMailer($mailerName, $mailerConfig);
- } catch (MissingMailerException $e) {
+ } catch (MissingMailerException $missingMailerException) {
return Processor::REJECT;
}
try {
$mailer->send($action, $args, $headers);
- } catch (BadMethodCallException $e) {
+ } catch (BadMethodCallException $badMethodCallException) {
return Processor::REJECT;
}
diff --git a/src/Job/Message.php b/src/Job/Message.php
index 200dcf6..f1b504c 100644
--- a/src/Job/Message.php
+++ b/src/Job/Message.php
@@ -16,6 +16,7 @@
*/
namespace Cake\Queue\Job;
+use Cake\Core\ContainerInterface;
use Cake\Utility\Hash;
use Closure;
use Interop\Queue\Context;
@@ -26,37 +27,28 @@
class Message implements JsonSerializable
{
/**
- * @var \Interop\Queue\Context
+ * @var array
*/
- protected $context;
+ protected array $parsedBody;
- /**
- * @var \Interop\Queue\Message
- */
- protected $originalMessage;
-
- /**
- * @var array
- */
- protected $parsedBody;
-
- /**
- * @var \Closure|null
- */
- protected $callable;
+ protected ?Closure $callable = null;
/**
* @param \Interop\Queue\Message $originalMessage Queue message.
* @param \Interop\Queue\Context $context Context.
+ * @param \Cake\Core\ContainerInterface|null $container DI container instance
*/
- public function __construct(QueueMessage $originalMessage, Context $context)
- {
- $this->context = $context;
- $this->originalMessage = $originalMessage;
+ public function __construct(
+ protected readonly QueueMessage $originalMessage,
+ protected readonly Context $context,
+ protected readonly ?ContainerInterface $container = null,
+ ) {
$this->parsedBody = json_decode($originalMessage->getBody(), true);
}
/**
+ * Get the queue context.
+ *
* @return \Interop\Queue\Context
*/
public function getContext(): Context
@@ -65,6 +57,8 @@ public function getContext(): Context
}
/**
+ * Get the original queue message.
+ *
* @return \Interop\Queue\Message
*/
public function getOriginalMessage(): QueueMessage
@@ -73,7 +67,9 @@ public function getOriginalMessage(): QueueMessage
}
/**
- * @return array
+ * Get the parsed message body.
+ *
+ * @return array
*/
public function getParsedBody(): array
{
@@ -88,15 +84,20 @@ public function getParsedBody(): array
*
* @return \Closure
*/
- public function getCallable()
+ public function getCallable(): Closure
{
- if ($this->callable) {
+ if ($this->callable instanceof Closure) {
return $this->callable;
}
$target = $this->getTarget();
+ if ($this->container && $this->container->has($target[0])) {
+ $object = $this->container->get($target[0]);
+ } else {
+ $object = new $target[0]();
+ }
- $this->callable = Closure::fromCallable([new $target[0](), $target[1]]);
+ $this->callable = Closure::fromCallable([$object, $target[1]]);
return $this->callable;
}
@@ -104,16 +105,18 @@ public function getCallable()
/**
* Get the target class and method.
*
- * @return array
+ * @return array{string, string}
+ * @phpstan-return array{class-string, string}
*/
- protected function getTarget(): array
+ public function getTarget(): array
{
+ /** @var array{class-string, string}|null $target */
$target = $this->parsedBody['class'] ?? null;
if (!is_array($target) || count($target) !== 2) {
throw new RuntimeException(sprintf(
'Message class should be in the form `[class, method]` got `%s`',
- json_encode($target)
+ json_encode($target),
));
}
@@ -125,8 +128,9 @@ protected function getTarget(): array
* @param mixed $default Default value.
* @return mixed
*/
- public function getArgument($key = null, $default = null)
+ public function getArgument(mixed $key = null, mixed $default = null): mixed
{
+ // support old jobs that still use args key
if (array_key_exists('data', $this->parsedBody)) {
$data = $this->parsedBody['data'];
} else {
@@ -144,7 +148,7 @@ public function getArgument($key = null, $default = null)
/**
* The maximum number of attempts allowed by the job.
*
- * @return null|int
+ * @return int|null
*/
public function getMaxAttempts(): ?int
{
@@ -156,19 +160,21 @@ public function getMaxAttempts(): ?int
}
/**
+ * Convert the message to a string representation.
+ *
* @return string
- * @psalm-suppress InvalidToString
*/
- public function __toString()
+ public function __toString(): string
{
- return json_encode($this);
+ return (string)json_encode($this);
}
/**
- * @return array
+ * Serialize the message to JSON.
+ *
+ * @return array
*/
- #[\ReturnTypeWillChange]
- public function jsonSerialize()
+ public function jsonSerialize(): array
{
return $this->parsedBody;
}
diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php
new file mode 100644
index 0000000..5e76926
--- /dev/null
+++ b/src/Job/SendMailJob.php
@@ -0,0 +1,91 @@
+getArgument('transport');
+ $config = $message->getArgument('config', []);
+ $transport = $this->getTransport($transportClassName, $config);
+
+ $emailMessage = new MailerMessage();
+ $data = json_decode($message->getArgument('emailMessage'), true);
+ if (!is_array($data)) {
+ throw new InvalidArgumentException('Email Message cannot be decoded.');
+ }
+
+ $emailMessage->createFromArray($data);
+ $result = $transport->send($emailMessage);
+ } catch (Exception $exception) {
+ Log::error(sprintf('An error has occurred processing message: %s', $exception->getMessage()));
+ }
+
+ if (!$result) {
+ return Processor::REJECT;
+ }
+
+ return Processor::ACK;
+ }
+
+ /**
+ * Initialize transport
+ *
+ * @param string $transportClassName Transport class name
+ * @param array $config Transport config
+ * @throws \InvalidArgumentException if empty transport class name, class does not exist or send method is not defined for class
+ * @return \Cake\Mailer\AbstractTransport
+ */
+ protected function getTransport(string $transportClassName, array $config): AbstractTransport
+ {
+ if ($transportClassName === '') {
+ throw new InvalidArgumentException('Transport class name is empty.');
+ }
+
+ if (
+ !class_exists($transportClassName) ||
+ !method_exists($transportClassName, 'send')
+ ) {
+ return TransportFactory::get($transportClassName);
+ }
+
+ $transport = new $transportClassName($config);
+
+ if (!($transport instanceof AbstractTransport)) {
+ throw new InvalidArgumentException('Provided class does not extend AbstractTransport.');
+ }
+
+ return $transport;
+ }
+}
diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php
new file mode 100644
index 0000000..c983fb4
--- /dev/null
+++ b/src/Listener/FailedJobsListener.php
@@ -0,0 +1,101 @@
+
+ */
+ public function implementedEvents(): array
+ {
+ return [
+ 'Consumption.LimitAttemptsExtension.failed' => 'storeFailedJob',
+ ];
+ }
+
+ /**
+ * @param \Cake\Event\EventInterface $event EventInterface.
+ * @return void
+ */
+ public function storeFailedJob(object $event): void
+ {
+ /** @var \Cake\Queue\Job\Message $jobMessage */
+ $jobMessage = $event->getSubject();
+
+ [$class, $method] = $jobMessage->getTarget();
+
+ $originalMessage = $jobMessage->getOriginalMessage();
+
+ $originalMessageBody = json_decode($originalMessage->getBody(), true);
+
+ ['data' => $data, 'requeueOptions' => $requeueOptions] = $originalMessageBody;
+
+ $config = QueueManager::getConfig($requeueOptions['config']);
+
+ if (!($config['storeFailedJobs'] ?? false)) {
+ return;
+ }
+
+ /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */
+ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs');
+
+ $failedJob = $failedJobsTable->newEntity([
+ 'class' => $class,
+ 'method' => $method,
+ 'data' => json_encode($data),
+ 'config' => $requeueOptions['config'],
+ 'priority' => $requeueOptions['priority'],
+ 'queue' => $requeueOptions['queue'],
+ 'exception' => $event->getData('exception'),
+ ]);
+
+ try {
+ $failedJobsTable->saveOrFail($failedJob);
+ /** @phpstan-ignore-next-line */
+ } catch (PersistenceFailedException $persistenceFailedException) {
+ $logger = $event->getData('logger');
+
+ if (!$logger) {
+ throw new RuntimeException(
+ sprintf('`logger` was not defined on %s event.', $event->getName()),
+ 0,
+ $persistenceFailedException,
+ );
+ }
+
+ if (!($logger instanceof LoggerInterface)) {
+ throw new RuntimeException(
+ sprintf('`logger` is not an instance of `LoggerInterface` on %s event.', $event->getName()),
+ 0,
+ $persistenceFailedException,
+ );
+ }
+
+ $logger->error((string)$persistenceFailedException);
+ }
+ }
+}
diff --git a/src/Mailer/QueueTrait.php b/src/Mailer/QueueTrait.php
index b9a4a5d..fe94110 100644
--- a/src/Mailer/QueueTrait.php
+++ b/src/Mailer/QueueTrait.php
@@ -31,9 +31,9 @@ trait QueueTrait
* @param string $action The name of the mailer action to trigger.
* @param array $args Arguments to pass to the triggered mailer action.
* @param array $headers Headers to set.
- * @param array $options an array of options for publishing the job
- * @return void
+ * @param array $options an array of options for publishing the job
* @throws \Cake\Mailer\Exception\MissingActionException
+ * @return void
*/
public function push(string $action, array $args = [], array $headers = [], array $options = []): void
{
diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php
new file mode 100644
index 0000000..da45783
--- /dev/null
+++ b/src/Mailer/Transport/QueueTransport.php
@@ -0,0 +1,93 @@
+
+ */
+ protected array $_defaultConfig = [
+ 'options' => [],
+ 'transport' => MailTransport::class,
+ ];
+
+ /**
+ * @inheritDoc
+ */
+ public function send(Message $message): array
+ {
+ $data = $this->prepareData($message);
+ $options = $this->getConfig('options');
+ $this->enqueueJob($data, $options);
+
+ $headers = $message->getHeadersString(
+ [
+ 'from',
+ 'to',
+ 'subject',
+ 'sender',
+ 'replyTo',
+ 'readReceipt',
+ 'returnPath',
+ 'cc',
+ 'bcc',
+ ],
+ );
+
+ return ['headers' => $headers, 'message' => 'Message has been enqueued'];
+ }
+
+ /**
+ * Add job to queue
+ *
+ * @param array $data Data to be sent to job
+ * @param array $options Job options
+ * @return void
+ */
+ protected function enqueueJob(array $data, array $options): void
+ {
+ QueueManager::push(
+ [SendMailJob::class, 'execute'],
+ $data,
+ $options,
+ );
+ }
+
+ /**
+ * Prepare data for job
+ *
+ * @param \Cake\Mailer\Message $message Email message
+ * @return array
+ */
+ protected function prepareData(Message $message): array
+ {
+ return [
+ 'transport' => $this->getConfig('transport'),
+ 'config' => is_array($this->getConfig('config')) ? $this->getConfig('config') : $this->getConfig(),
+ 'emailMessage' => json_encode($message),
+ ];
+ }
+}
diff --git a/src/Model/Entity/FailedJob.php b/src/Model/Entity/FailedJob.php
new file mode 100644
index 0000000..62f1fc4
--- /dev/null
+++ b/src/Model/Entity/FailedJob.php
@@ -0,0 +1,55 @@
+ true`
+ * means that any field not defined in the map will be accessible by default
+ *
+ * @var array
+ */
+ protected array $_accessible = [
+ 'class' => true,
+ 'method' => true,
+ 'data' => true,
+ 'config' => true,
+ 'priority' => true,
+ 'queue' => true,
+ 'exception' => true,
+ 'created' => true,
+ ];
+
+ /**
+ * @see \Cake\Queue\Model\Entity\FailedJob::$decoded_data
+ * @return array
+ */
+ protected function _getDecodedData(): array
+ {
+ return json_decode($this->data, true);
+ }
+}
diff --git a/src/Model/Table/FailedJobsTable.php b/src/Model/Table/FailedJobsTable.php
new file mode 100644
index 0000000..208b325
--- /dev/null
+++ b/src/Model/Table/FailedJobsTable.php
@@ -0,0 +1,96 @@
+ newEntities(array $data, array $options = [])
+ * @method \Cake\Queue\Model\Entity\FailedJob get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, ...$args)
+ * @method \Cake\Queue\Model\Entity\FailedJob findOrCreate($search, ?callable $callback = null, $options = [])
+ * @method \Cake\Queue\Model\Entity\FailedJob patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
+ * @method array<\Cake\Queue\Model\Entity\FailedJob> patchEntities(iterable $entities, array $data, array $options = [])
+ * @method \Cake\Queue\Model\Entity\FailedJob|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
+ * @method \Cake\Queue\Model\Entity\FailedJob saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
+ * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false saveMany(iterable $entities, $options = [])
+ * @method iterable<\Cake\Queue\Model\Entity\FailedJob> saveManyOrFail(iterable $entities, $options = [])
+ * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false deleteMany(iterable $entities, $options = [])
+ * @method iterable<\Cake\Queue\Model\Entity\FailedJob> deleteManyOrFail(iterable $entities, $options = [])
+ * @mixin \Cake\ORM\Behavior\TimestampBehavior
+ */
+class FailedJobsTable extends Table
+{
+ /**
+ * Initialize method
+ *
+ * @param array $config The configuration for the Table.
+ * @return void
+ */
+ public function initialize(array $config): void
+ {
+ parent::initialize($config);
+
+ $this->setTable('queue_failed_jobs');
+ $this->setDisplayField('id');
+ $this->setPrimaryKey('id');
+
+ $this->addBehavior('Timestamp');
+ }
+
+ /**
+ * Default validation rules.
+ *
+ * @param \Cake\Validation\Validator $validator Validator instance.
+ * @return \Cake\Validation\Validator
+ */
+ public function validationDefault(Validator $validator): Validator
+ {
+ $validator
+ ->integer('id')
+ ->allowEmptyString('id', null, 'create');
+
+ $validator
+ ->scalar('class')
+ ->maxLength('class', 255)
+ ->requirePresence('class', 'create')
+ ->notEmptyString('class');
+
+ $validator
+ ->scalar('method')
+ ->maxLength('method', 255)
+ ->requirePresence('method', 'create')
+ ->notEmptyString('method');
+
+ $validator
+ ->scalar('data')
+ ->requirePresence('data', 'create')
+ ->notEmptyString('data');
+
+ $validator
+ ->scalar('config')
+ ->maxLength('config', 255)
+ ->notEmptyString('config');
+
+ $validator
+ ->scalar('priority')
+ ->maxLength('priority', 255)
+ ->allowEmptyString('priority');
+
+ $validator
+ ->scalar('queue')
+ ->maxLength('queue', 255)
+ ->notEmptyString('queue');
+
+ $validator
+ ->scalar('exception')
+ ->allowEmptyString('exception');
+
+ return $validator;
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
index 20cfd91..65812ab 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -16,61 +16,9 @@
*/
namespace Cake\Queue;
-use Cake\Console\CommandCollection;
-use Cake\Core\BasePlugin;
-use Cake\Core\Configure;
-use Cake\Core\PluginApplicationInterface;
-use Cake\Queue\Command\JobCommand;
-use Cake\Queue\Command\WorkerCommand;
-
/**
- * Plugin for Queue
+ * @deprecated 2.2.1 Use QueuePlugin instead
*/
-class Plugin extends BasePlugin
+class Plugin extends QueuePlugin
{
- /**
- * Plugin name.
- *
- * @var string
- */
- protected $name = 'Cake/Queue';
-
- /**
- * Load routes or not
- *
- * @var bool
- */
- protected $routesEnabled = false;
-
- /**
- * Load the Queue configuration
- *
- * @param \Cake\Core\PluginApplicationInterface $app The host application
- * @return void
- */
- public function bootstrap(PluginApplicationInterface $app): void
- {
- foreach (Configure::read('Queue') as $key => $data) {
- if (QueueManager::getConfig($key) === null) {
- QueueManager::setConfig($key, $data);
- }
- }
- }
-
- /**
- * Add console commands for the plugin.
- *
- * @param \Cake\Console\CommandCollection $commands The command collection to update
- * @return \Cake\Console\CommandCollection
- */
- public function console(CommandCollection $commands): CommandCollection
- {
- if (class_exists('Bake\Command\SimpleBakeCommand')) {
- $commands->add('bake job', JobCommand::class);
- }
-
- return $commands
- ->add('queue worker', WorkerCommand::class)
- ->add('worker', WorkerCommand::class);
- }
}
diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php
index b092f57..bff6628 100644
--- a/src/Queue/Processor.php
+++ b/src/Queue/Processor.php
@@ -16,34 +16,34 @@
*/
namespace Cake\Queue\Queue;
+use Cake\Core\ContainerInterface;
use Cake\Event\EventDispatcherTrait;
use Cake\Queue\Job\Message;
+use Enqueue\Consumption\Result;
use Error;
-use Exception;
use Interop\Queue\Context;
use Interop\Queue\Message as QueueMessage;
use Interop\Queue\Processor as InteropProcessor;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
+use Throwable;
class Processor implements InteropProcessor
{
- use EventDispatcherTrait;
-
/**
- * @var \Psr\Log\LoggerInterface
+ * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Queue\Processor>
*/
- protected $logger;
+ use EventDispatcherTrait;
/**
- * Processor constructor
- *
* @param \Psr\Log\LoggerInterface $logger Logger instance.
+ * @param \Cake\Core\ContainerInterface|null $container DI container instance
*/
- public function __construct(?LoggerInterface $logger = null)
- {
- $this->logger = $logger ?: new NullLogger();
+ public function __construct(
+ protected readonly LoggerInterface $logger = new NullLogger(),
+ protected readonly ?ContainerInterface $container = null,
+ ) {
}
/**
@@ -51,13 +51,13 @@ public function __construct(?LoggerInterface $logger = null)
*
* @param \Interop\Queue\Message $message Message.
* @param \Interop\Queue\Context $context Context.
- * @return string|object with __toString method implemented
+ * @return object|string with __toString method implemented
*/
- public function process(QueueMessage $message, Context $context)
+ public function process(QueueMessage $message, Context $context): string|object
{
$this->dispatchEvent('Processor.message.seen', ['queueMessage' => $message]);
- $jobMessage = new Message($message, $context);
+ $jobMessage = new Message($message, $context, $this->container);
try {
$jobMessage->getCallable();
} catch (RuntimeException | Error $e) {
@@ -67,45 +67,60 @@ public function process(QueueMessage $message, Context $context)
return InteropProcessor::REJECT;
}
+ $startTime = microtime(true) * 1000;
$this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]);
try {
$response = $this->processMessage($jobMessage);
- } catch (Exception $e) {
- $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage()));
+ } catch (Throwable $throwable) {
+ $message->setProperty('jobException', $throwable);
+
+ $this->logger->debug(sprintf('Message encountered exception: %s', $throwable->getMessage()));
$this->dispatchEvent('Processor.message.exception', [
'message' => $jobMessage,
- 'exception' => $e,
+ 'exception' => $throwable,
+ 'duration' => (int)((microtime(true) * 1000) - $startTime),
]);
- return InteropProcessor::REQUEUE;
+ return Result::requeue('Exception occurred while processing message');
}
+ $duration = (int)((microtime(true) * 1000) - $startTime);
+
if ($response === InteropProcessor::ACK) {
- $this->logger->debug('Message processed sucessfully');
- $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]);
+ $this->logger->debug('Message processed successfully');
+ $this->dispatchEvent('Processor.message.success', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
return InteropProcessor::ACK;
}
if ($response === InteropProcessor::REJECT) {
$this->logger->debug('Message processed with rejection');
- $this->dispatchEvent('Processor.message.reject', ['message' => $jobMessage]);
+ $this->dispatchEvent('Processor.message.reject', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
return InteropProcessor::REJECT;
}
$this->logger->debug('Message processed with failure, requeuing');
- $this->dispatchEvent('Processor.message.failure', ['message' => $jobMessage]);
+ $this->dispatchEvent('Processor.message.failure', [
+ 'message' => $jobMessage,
+ 'duration' => $duration,
+ ]);
return InteropProcessor::REQUEUE;
}
/**
* @param \Cake\Queue\Job\Message $message Message.
- * @return string|object with __toString method implemented
+ * @return object|string with __toString method implemented
*/
- public function processMessage(Message $message)
+ public function processMessage(Message $message): string|object
{
$callable = $message->getCallable();
$response = $callable($message);
diff --git a/src/QueueManager.php b/src/QueueManager.php
index 2c1c6b0..9a8bd89 100644
--- a/src/QueueManager.php
+++ b/src/QueueManager.php
@@ -17,28 +17,30 @@
namespace Cake\Queue;
use BadMethodCallException;
+use Cake\Cache\Cache;
use Cake\Core\App;
use Cake\Log\Log;
use Enqueue\Client\Message as ClientMessage;
use Enqueue\SimpleClient\SimpleClient;
use InvalidArgumentException;
use LogicException;
+use Psr\Log\LoggerInterface;
class QueueManager
{
/**
* Configuration sets.
*
- * @var array
+ * @var array>
*/
- protected static $_config = [];
+ protected static array $_config = [];
/**
* Queue clients
*
- * @var array
+ * @var array
*/
- protected static $_clients = [];
+ protected static array $_clients = [];
/**
* This method can be used to define configuration adapters for an application.
@@ -71,27 +73,29 @@ class QueueManager
* QueueManager::setConfig($arrayOfConfig);
* ```
*
- * @param string|array $key The name of the configuration, or an array of multiple configs.
- * @param array $config An array of name => configuration data for adapter.
+ * @param array>|string $key The name of the configuration, or an array of multiple configs.
+ * @param array|null $config An array of name => configuration data for adapter.
* @throws \BadMethodCallException When trying to modify an existing config.
* @throws \LogicException When trying to store an invalid structured config array.
* @return void
*/
- public static function setConfig($key, $config = null): void
+ public static function setConfig(string|array $key, ?array $config = null): void
{
if ($config === null) {
if (!is_array($key)) {
throw new LogicException('If config is null, key must be an array.');
}
+
foreach ($key as $name => $settings) {
static::setConfig($name, $settings);
}
return;
+ } elseif (is_array($key)) {
+ throw new LogicException('If config is not null, key must be a string.');
}
if (isset(static::$_config[$key])) {
- /** @psalm-suppress PossiblyInvalidArgument */
throw new BadMethodCallException(sprintf('Cannot reconfigure existing key `%s`', $key));
}
@@ -119,7 +123,18 @@ public static function setConfig($key, $config = null): void
}
}
- /** @psalm-suppress InvalidPropertyAssignmentValue */
+ if (!empty($config['uniqueCache'])) {
+ $cacheDefaults = [
+ 'duration' => '+24 hours',
+ ];
+
+ $cacheConfig = [...$cacheDefaults, ...$config['uniqueCache']];
+
+ $config['uniqueCacheKey'] = 'Cake/Queue.queueUnique.' . $key;
+
+ Cache::setConfig($config['uniqueCacheKey'], $cacheConfig);
+ }
+
static::$_config[$key] = $config;
}
@@ -127,13 +142,23 @@ public static function setConfig($key, $config = null): void
* Reads existing configuration.
*
* @param string $key The name of the configuration.
- * @return mixed Configuration data at the named key or null if the key does not exist.
+ * @return array|null Configuration data at the named key or null if the key does not exist.
*/
- public static function getConfig(string $key)
+ public static function getConfig(string $key): ?array
{
return static::$_config[$key] ?? null;
}
+ /**
+ * Get the configured queue keys.
+ *
+ * @return array List of configured queue configuration keys.
+ */
+ public static function configured(): array
+ {
+ return array_keys(static::$_config);
+ }
+
/**
* Remove a configured queue adapter.
*
@@ -167,7 +192,7 @@ public static function engine(string $name): SimpleClient
static::$_clients[$name] = new SimpleClient($config['url'], $logger);
static::$_clients[$name]->setupBroker();
- if (!is_null($config['receiveTimeout'])) {
+ if ($config['receiveTimeout'] !== null) {
static::$_clients[$name]->getQueueConsumer()->setReceiveTimeout($config['receiveTimeout']);
}
@@ -177,11 +202,11 @@ public static function engine(string $name): SimpleClient
/**
* Push a single job onto the queue.
*
- * @param string|string[] $className The classname of a job that implements the
+ * @param array|string $className The classname of a job that implements the
* \Cake\Queue\Job\JobInterface. The class will be constructed by
* \Cake\Queue\Processor and have the execute method invoked.
- * @param array $data An array of data that will be passed to the job.
- * @param array $options An array of options for publishing the job:
+ * @param array $data An array of data that will be passed to the job.
+ * @param array $options An array of options for publishing the job:
* - `config` - A queue config name. Defaults to 'default'.
* - `delay` - Time (in integer seconds) to delay message, after which it
* will be processed. Not all message brokers accept this. Default `null`.
@@ -196,26 +221,58 @@ public static function engine(string $name): SimpleClient
* - `\Enqueue\Client\MessagePriority::VERY_HIGH`
* - `queue` - The name of a queue to use, from queue `config` array or
* string 'default' if empty.
- * @return void
*/
- public static function push($className, array $data = [], array $options = []): void
+ public static function push(string|array $className, array $data = [], array $options = []): void
{
[$class, $method] = is_array($className) ? $className : [$className, 'execute'];
$class = App::className($class, 'Job', 'Job');
if (is_null($class)) {
- throw new InvalidArgumentException("`$class` class does not exist.");
+ throw new InvalidArgumentException(sprintf('`%s` class does not exist.', $class));
}
$name = $options['config'] ?? 'default';
- $config = static::getConfig($name);
+ $config = static::getConfig($name) + [
+ 'logger' => null,
+ ];
+
+ $logger = $config['logger'] ? Log::engine($config['logger']) : null;
+
+ if (!empty($class::$shouldBeUnique)) {
+ if (empty($config['uniqueCache'])) {
+ throw new InvalidArgumentException(
+ $class . '::$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing.',
+ );
+ }
+
+ $uniqueId = static::getUniqueId($class, $method, $data);
+
+ if (Cache::read($uniqueId, $config['uniqueCacheKey'])) {
+ if ($logger instanceof LoggerInterface) {
+ $logger->debug(
+ sprintf(
+ 'An identical instance of %s already exists on the queue. This push will be ignored.',
+ $class,
+ ),
+ );
+ }
+
+ return;
+ }
+ }
+
$queue = $options['queue'] ?? $config['queue'] ?? 'default';
$message = new ClientMessage([
'class' => [$class, $method],
'args' => [$data],
'data' => $data,
+ 'requeueOptions' => [
+ 'config' => $name,
+ 'priority' => $options['priority'] ?? null,
+ 'queue' => $queue,
+ ],
]);
if (isset($options['delay'])) {
@@ -232,5 +289,48 @@ public static function push($className, array $data = [], array $options = []):
$client = static::engine($name);
$client->sendEvent($queue, $message);
+
+ if (!empty($class::$shouldBeUnique)) {
+ $uniqueId = static::getUniqueId($class, $method, $data);
+
+ Cache::add($uniqueId, true, $config['uniqueCacheKey']);
+ }
+ }
+
+ /**
+ * @param class-string $class Class name
+ * @param string $method Method name
+ * @param array $data Message data
+ */
+ public static function getUniqueId(string $class, string $method, array $data): string
+ {
+ $data = static::sortUniqueValues($data);
+
+ $hashInput = implode('', [
+ $class,
+ $method,
+ json_encode($data),
+ ]);
+
+ return hash('md5', $hashInput);
+ }
+
+ /**
+ * Recursively sort an array by key
+ *
+ * @param array $data The data to sort
+ * @return array The sorted array
+ */
+ protected static function sortUniqueValues(array $data): array
+ {
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ $data[$key] = static::sortUniqueValues($value);
+ }
+ }
+
+ ksort($data);
+
+ return $data;
}
}
diff --git a/src/QueuePlugin.php b/src/QueuePlugin.php
new file mode 100644
index 0000000..9f7202b
--- /dev/null
+++ b/src/QueuePlugin.php
@@ -0,0 +1,100 @@
+ $data) {
+ if (QueueManager::getConfig($key) === null) {
+ QueueManager::setConfig($key, $data);
+ }
+ }
+ }
+
+ /**
+ * Add console commands for the plugin.
+ *
+ * @param \Cake\Console\CommandCollection $commands The command collection to update
+ * @return \Cake\Console\CommandCollection
+ */
+ public function console(CommandCollection $commands): CommandCollection
+ {
+ if (class_exists(SimpleBakeCommand::class)) {
+ $commands->add('bake job', JobCommand::class);
+ }
+
+ return $commands
+ ->add('queue worker', WorkerCommand::class)
+ ->add('worker', WorkerCommand::class)
+ ->add('queue requeue', RequeueCommand::class)
+ ->add('queue purge_failed', PurgeFailedCommand::class);
+ }
+
+ /**
+ * Add DI container to Worker command
+ *
+ * @param \Cake\Core\ContainerInterface $container The DI container
+ * @return void
+ */
+ public function services(ContainerInterface $container): void
+ {
+ $container->add(ContainerInterface::class, $container);
+ $container
+ ->add(WorkerCommand::class)
+ ->addArgument(ContainerInterface::class);
+ }
+}
diff --git a/src/TestSuite/Constraint/Queue/JobCount.php b/src/TestSuite/Constraint/Queue/JobCount.php
new file mode 100644
index 0000000..913c9fc
--- /dev/null
+++ b/src/TestSuite/Constraint/Queue/JobCount.php
@@ -0,0 +1,40 @@
+getJobs();
+
+ foreach ($jobs as $job) {
+ if ($job['jobClass'] === $jobClass) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Assertion message
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ if ($this->at !== null) {
+ return sprintf('job #%d was queued', $this->at);
+ }
+
+ return 'job was queued';
+ }
+}
diff --git a/src/TestSuite/Constraint/Queue/JobQueuedTimes.php b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php
new file mode 100644
index 0000000..69bdda8
--- /dev/null
+++ b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php
@@ -0,0 +1,56 @@
+times = $times;
+ }
+
+ /**
+ * Checks if job was queued the expected number of times
+ *
+ * @param mixed $other Job class name
+ * @return bool
+ */
+ public function matches(mixed $other): bool
+ {
+ $jobClass = $other;
+ $jobs = TestQueueClient::getQueuedJobsByClass($jobClass);
+ $actualCount = count($jobs);
+
+ return $actualCount === $this->times;
+ }
+
+ /**
+ * Assertion message
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return sprintf('job was queued %d times', $this->times);
+ }
+}
diff --git a/src/TestSuite/Constraint/Queue/NoJobQueued.php b/src/TestSuite/Constraint/Queue/NoJobQueued.php
new file mode 100644
index 0000000..d698b31
--- /dev/null
+++ b/src/TestSuite/Constraint/Queue/NoJobQueued.php
@@ -0,0 +1,37 @@
+at = $at;
+ }
+
+ /**
+ * Get the jobs or job to check
+ *
+ * @return array>
+ */
+ protected function getJobs(): array
+ {
+ $jobs = TestQueueClient::getQueuedJobs();
+
+ if ($this->at !== null) {
+ if (!isset($jobs[$this->at])) {
+ return [];
+ }
+
+ return [$jobs[$this->at]];
+ }
+
+ return $jobs;
+ }
+}
diff --git a/src/TestSuite/QueueTrait.php b/src/TestSuite/QueueTrait.php
new file mode 100644
index 0000000..240771b
--- /dev/null
+++ b/src/TestSuite/QueueTrait.php
@@ -0,0 +1,299 @@
+ 'value']);
+ *
+ * $this->assertJobQueued('MyJob');
+ * $this->assertJobQueuedWith('MyJob', ['data' => 'value']);
+ * }
+ * }
+ * ```
+ */
+trait QueueTrait
+{
+ /**
+ * Setup test queue client
+ *
+ * Replaces all queue configs with test transport
+ * to capture jobs instead of queuing them.
+ *
+ * @return void
+ */
+ #[Before]
+ public function setupTestQueueClient(): void
+ {
+ TestQueueClient::clearQueuedJobs();
+
+ if (!QueueManager::configured()) {
+ QueueManager::setConfig('default', [
+ 'url' => 'null:',
+ ]);
+ }
+
+ TestQueueClient::replaceAllClients();
+ }
+
+ /**
+ * Cleanup queued jobs
+ *
+ * Clears all captured jobs after each test.
+ *
+ * @return void
+ */
+ #[After]
+ public function cleanupQueueTrait(): void
+ {
+ TestQueueClient::clearQueuedJobs();
+ }
+
+ /**
+ * Assert a job was queued
+ *
+ * @param string $jobClass Job class name
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueued(string $jobClass, string $message = ''): void
+ {
+ $this->assertThat($jobClass, new JobQueued(), $message);
+ }
+
+ /**
+ * Assert a job was not queued
+ *
+ * @param string $jobClass Job class name
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobNotQueued(string $jobClass, string $message = ''): void
+ {
+ $jobs = TestQueueClient::getQueuedJobsByClass($jobClass);
+ $this->assertEmpty(
+ $jobs,
+ $message ?: sprintf('Job %s was queued unexpectedly', $jobClass),
+ );
+ }
+
+ /**
+ * Assert no jobs were queued
+ *
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertNoJobsQueued(string $message = ''): void
+ {
+ $this->assertThat(null, new NoJobQueued(), $message);
+ }
+
+ /**
+ * Assert a specific count of jobs were queued
+ *
+ * @param int $count Expected job count
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobCount(int $count, string $message = ''): void
+ {
+ $this->assertThat($count, new JobCount(), $message);
+ }
+
+ /**
+ * Assert a job was queued with specific data
+ *
+ * @param string $jobClass Job class name
+ * @param array $data Expected data
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueuedWith(
+ string $jobClass,
+ array $data,
+ string $message = '',
+ ): void {
+ $jobs = TestQueueClient::getQueuedJobsByClass($jobClass);
+ $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass));
+
+ $found = false;
+ foreach ($jobs as $job) {
+ if ($job['data'] === $data) {
+ $found = true;
+ break;
+ }
+ }
+
+ $this->assertTrue(
+ $found,
+ $message ?: sprintf('Job %s was not queued with expected data', $jobClass),
+ );
+ }
+
+ /**
+ * Assert a job was queued to a specific queue
+ *
+ * @param string $queue Queue name
+ * @param string $jobClass Job class name
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueuedToQueue(
+ string $queue,
+ string $jobClass,
+ string $message = '',
+ ): void {
+ $jobs = TestQueueClient::getQueuedJobsByQueue($queue);
+ $found = false;
+ foreach ($jobs as $job) {
+ if ($job['jobClass'] === $jobClass) {
+ $found = true;
+ break;
+ }
+ }
+
+ $this->assertTrue(
+ $found,
+ $message ?: sprintf('Job %s was not queued to queue %s', $jobClass, $queue),
+ );
+ }
+
+ /**
+ * Assert a job was queued with delay
+ *
+ * @param string $jobClass Job class name
+ * @param int $delay Expected delay in seconds
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueuedWithDelay(
+ string $jobClass,
+ int $delay,
+ string $message = '',
+ ): void {
+ $jobs = TestQueueClient::getQueuedJobsByClass($jobClass);
+ $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass));
+
+ $found = false;
+ foreach ($jobs as $job) {
+ if (($job['options']['delay'] ?? null) === $delay) {
+ $found = true;
+ break;
+ }
+ }
+
+ $this->assertTrue(
+ $found,
+ $message ?: sprintf('Job %s was not queued with delay %d', $jobClass, $delay),
+ );
+ }
+
+ /**
+ * Assert a job was queued with priority
+ *
+ * @param string $jobClass Job class name
+ * @param string|int $priority Expected priority
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueuedWithPriority(
+ string $jobClass,
+ int|string $priority,
+ string $message = '',
+ ): void {
+ $jobs = TestQueueClient::getQueuedJobsByClass($jobClass);
+ $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass));
+
+ $found = false;
+ foreach ($jobs as $job) {
+ if (($job['options']['priority'] ?? null) === $priority) {
+ $found = true;
+ break;
+ }
+ }
+
+ $this->assertTrue(
+ $found,
+ $message ?: sprintf('Job %s was not queued with priority %s', $jobClass, $priority),
+ );
+ }
+
+ /**
+ * Assert a job was queued a specific number of times
+ *
+ * @param string $jobClass Job class name
+ * @param int $times Expected number of times
+ * @param string $message Optional assertion message
+ * @return void
+ */
+ public function assertJobQueuedTimes(string $jobClass, int $times, string $message = ''): void
+ {
+ $this->assertThat($jobClass, new JobQueuedTimes($times), $message);
+ }
+
+ /**
+ * Get all queued jobs
+ *
+ * @return array>
+ */
+ public function getQueuedJobs(): array
+ {
+ return TestQueueClient::getQueuedJobs();
+ }
+
+ /**
+ * Get queued jobs by class
+ *
+ * @param string $jobClass Job class name
+ * @return array>
+ */
+ public function getQueuedJobsByClass(string $jobClass): array
+ {
+ return TestQueueClient::getQueuedJobsByClass($jobClass);
+ }
+
+ /**
+ * Get queued jobs by queue name
+ *
+ * @param string $queue Queue name
+ * @return array>
+ */
+ public function getQueuedJobsByQueue(string $queue): array
+ {
+ return TestQueueClient::getQueuedJobsByQueue($queue);
+ }
+
+ /**
+ * Get queued jobs by config name
+ *
+ * @param string $config Config name
+ * @return array>
+ */
+ public function getQueuedJobsByConfig(string $config): array
+ {
+ return TestQueueClient::getQueuedJobsByConfig($config);
+ }
+}
diff --git a/src/TestSuite/TestQueueClient.php b/src/TestSuite/TestQueueClient.php
new file mode 100644
index 0000000..584a9dc
--- /dev/null
+++ b/src/TestSuite/TestQueueClient.php
@@ -0,0 +1,250 @@
+ 'value']);
+ *
+ * // Make assertions
+ * $jobs = TestQueueClient::getQueuedJobs();
+ * ```
+ */
+class TestQueueClient
+{
+ /**
+ * Captured queued jobs
+ *
+ * @var array>
+ */
+ protected static array $queuedJobs = [];
+
+ /**
+ * Transport registration flag
+ */
+ protected static bool $registered = false;
+
+ /**
+ * Replace all queue clients with test transport
+ *
+ * Similar to TestEmailTransport::replaceAllTransports()
+ *
+ * @return void
+ */
+ public static function replaceAllClients(): void
+ {
+ if (!static::$registered) {
+ Resources::addConnection(
+ Transport\TestConnectionFactory::class,
+ ['test'],
+ [],
+ 'cakephp/queue-testsuite',
+ );
+ ClientResources::addDriver(
+ Transport\TestDriver::class,
+ ['test'],
+ [],
+ ['cakephp/queue-testsuite'],
+ );
+ static::$registered = true;
+ }
+
+ $configured = QueueManager::configured();
+
+ foreach ($configured as $configName) {
+ $config = QueueManager::getConfig($configName);
+ if ($config === null) {
+ continue;
+ }
+
+ $config['url'] = 'test:';
+ QueueManager::drop($configName);
+ QueueManager::setConfig($configName, $config);
+ }
+ }
+
+ /**
+ * Capture message from transport
+ *
+ * Called by TestProducer when a message is sent.
+ *
+ * @param \Interop\Queue\Destination $destination Destination
+ * @param \Interop\Queue\Message $message Message
+ * @param int|null $deliveryDelay Delivery delay from producer (in milliseconds)
+ * @param int|null $timeToLive Time to live from producer (in milliseconds)
+ * @param int|null $producerPriority Priority from producer
+ * @return void
+ */
+ public static function captureMessage(
+ Destination $destination,
+ Message $message,
+ ?int $deliveryDelay = null,
+ ?int $timeToLive = null,
+ ?int $producerPriority = null,
+ ): void {
+ $body = static::extractMessageBody($message);
+ $classData = $body['class'] ?? null;
+ $jobClass = is_array($classData) ? $classData[0] : null;
+ $method = is_array($classData) && isset($classData[1]) ? $classData[1] : 'execute';
+ $data = $body['data'] ?? ($body['args'][0] ?? []);
+
+ $requeueOptions = $body['requeueOptions'] ?? [];
+ $configName = $requeueOptions['config'] ?? 'default';
+
+ $queueName = 'default';
+ if ($destination instanceof Queue) {
+ $queueName = $destination->getQueueName();
+ } elseif ($destination instanceof Topic) {
+ $queueName = $destination->getTopicName();
+ }
+
+ $queueName = $requeueOptions['queue'] ?? $queueName;
+
+ $properties = $message->getProperties();
+ $delay = $properties['enqueue.delay'] ?? null;
+ $expires = $properties['enqueue.expire'] ?? null;
+ $priority = $properties['enqueue.priority'] ??
+ $producerPriority ??
+ $requeueOptions['priority'] ??
+ null;
+
+ if ($delay !== null) {
+ $delay = (int)$delay;
+ } elseif ($deliveryDelay !== null) {
+ $delay = (int)($deliveryDelay / 1000);
+ }
+
+ if ($expires !== null) {
+ $expires = (int)$expires;
+ } elseif ($timeToLive !== null) {
+ $expires = (int)($timeToLive / 1000);
+ }
+
+ static::$queuedJobs[] = [
+ 'jobClass' => $jobClass,
+ 'method' => $method,
+ 'data' => $data,
+ 'options' => [
+ 'config' => $configName,
+ 'queue' => $queueName,
+ 'delay' => $delay,
+ 'expires' => $expires,
+ 'priority' => $priority,
+ ],
+ 'timestamp' => time(),
+ ];
+ }
+
+ /**
+ * Extract message body
+ *
+ * @param \Interop\Queue\Message $message Message
+ * @return array
+ */
+ protected static function extractMessageBody(Message $message): array
+ {
+ $body = $message->getBody();
+ $decoded = json_decode($body, true);
+ if (is_array($decoded)) {
+ return $decoded;
+ }
+
+ return [];
+ }
+
+ /**
+ * Get all queued jobs
+ *
+ * @return array>
+ */
+ public static function getQueuedJobs(): array
+ {
+ return static::$queuedJobs;
+ }
+
+ /**
+ * Get queued jobs by job class
+ *
+ * @param string $jobClass Job class name
+ * @return array>
+ */
+ public static function getQueuedJobsByClass(string $jobClass): array
+ {
+ $filtered = array_filter(static::$queuedJobs, function ($job) use ($jobClass) {
+ return $job['jobClass'] === $jobClass;
+ });
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get queued jobs by queue name
+ *
+ * @param string $queue Queue name
+ * @return array>
+ */
+ public static function getQueuedJobsByQueue(string $queue): array
+ {
+ $filtered = array_filter(static::$queuedJobs, function ($job) use ($queue) {
+ return ($job['options']['queue'] ?? 'default') === $queue;
+ });
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get queued jobs by config name
+ *
+ * @param string $config Config name
+ * @return array>
+ */
+ public static function getQueuedJobsByConfig(string $config): array
+ {
+ $filtered = array_filter(static::$queuedJobs, function ($job) use ($config) {
+ return ($job['options']['config'] ?? 'default') === $config;
+ });
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Clear all queued jobs
+ *
+ * @return void
+ */
+ public static function clearQueuedJobs(): void
+ {
+ static::$queuedJobs = [];
+ }
+
+ /**
+ * Get count of queued jobs
+ *
+ * @return int
+ */
+ public static function getQueuedJobCount(): int
+ {
+ return count(static::$queuedJobs);
+ }
+}
diff --git a/src/TestSuite/Transport/TestConnectionFactory.php b/src/TestSuite/Transport/TestConnectionFactory.php
new file mode 100644
index 0000000..847623f
--- /dev/null
+++ b/src/TestSuite/Transport/TestConnectionFactory.php
@@ -0,0 +1,34 @@
+queue = $queue;
+ }
+
+ /**
+ * Get queue
+ *
+ * @return \Interop\Queue\Queue
+ */
+ public function getQueue(): Queue
+ {
+ return $this->queue;
+ }
+
+ /**
+ * Receive message
+ *
+ * @param int|null $timeout Timeout in milliseconds
+ * @return \Interop\Queue\Message|null
+ */
+ public function receive(?int $timeout = null): ?Message
+ {
+ return null;
+ }
+
+ /**
+ * Receive no wait
+ *
+ * @return \Interop\Queue\Message|null
+ */
+ public function receiveNoWait(): ?Message
+ {
+ return null;
+ }
+
+ /**
+ * Acknowledge message
+ *
+ * @param \Interop\Queue\Message $message Message
+ * @return void
+ */
+ public function acknowledge(Message $message): void
+ {
+ }
+
+ /**
+ * Reject message
+ *
+ * @param \Interop\Queue\Message $message Message
+ * @param bool $requeue Requeue flag
+ * @return void
+ */
+ public function reject(Message $message, bool $requeue = false): void
+ {
+ }
+}
diff --git a/src/TestSuite/Transport/TestContext.php b/src/TestSuite/Transport/TestContext.php
new file mode 100644
index 0000000..e29b9d9
--- /dev/null
+++ b/src/TestSuite/Transport/TestContext.php
@@ -0,0 +1,133 @@
+ $properties Message properties
+ * @param array $headers Message headers
+ * @return \Interop\Queue\Message
+ */
+ public function createMessage(string $body = '', array $properties = [], array $headers = []): Message
+ {
+ return new TestMessage($body, $properties, $headers);
+ }
+
+ /**
+ * Create queue
+ *
+ * @param string $name Queue name
+ * @return \Interop\Queue\Queue
+ */
+ public function createQueue(string $name): Queue
+ {
+ return new TestDestination($name);
+ }
+
+ /**
+ * Create topic
+ *
+ * @param string $name Topic name
+ * @return \Interop\Queue\Topic
+ */
+ public function createTopic(string $name): Topic
+ {
+ return new TestDestination($name);
+ }
+
+ /**
+ * Create producer
+ *
+ * @return \Interop\Queue\Producer
+ */
+ public function createProducer(): Producer
+ {
+ if (!$this->producer instanceof Producer) {
+ $this->producer = new TestProducer();
+ }
+
+ return $this->producer;
+ }
+
+ /**
+ * Create consumer
+ *
+ * @param \Interop\Queue\Destination $destination Destination
+ * @return \Interop\Queue\Consumer
+ */
+ public function createConsumer(Destination $destination): Consumer
+ {
+ if ($destination instanceof Queue) {
+ return new TestConsumer($destination);
+ }
+
+ $queueName = $destination instanceof Topic
+ ? $destination->getTopicName()
+ : 'default';
+
+ return new TestConsumer(new TestDestination($queueName));
+ }
+
+ /**
+ * Create subscription consumer
+ *
+ * @return \Interop\Queue\SubscriptionConsumer
+ */
+ public function createSubscriptionConsumer(): SubscriptionConsumer
+ {
+ return new TestSubscriptionConsumer();
+ }
+
+ /**
+ * Create temporary queue
+ *
+ * @return \Interop\Queue\Queue
+ */
+ public function createTemporaryQueue(): Queue
+ {
+ return new TestDestination('temp_' . uniqid());
+ }
+
+ /**
+ * Purge queue
+ *
+ * @param \Interop\Queue\Queue $queue Queue to purge
+ * @return void
+ */
+ public function purgeQueue(Queue $queue): void
+ {
+ }
+
+ /**
+ * Close context
+ *
+ * @return void
+ */
+ public function close(): void
+ {
+ }
+}
diff --git a/src/TestSuite/Transport/TestDestination.php b/src/TestSuite/Transport/TestDestination.php
new file mode 100644
index 0000000..0389618
--- /dev/null
+++ b/src/TestSuite/Transport/TestDestination.php
@@ -0,0 +1,60 @@
+name = $name;
+ }
+
+ /**
+ * Get queue name
+ *
+ * @return string
+ */
+ public function getQueueName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get topic name
+ *
+ * @return string
+ */
+ public function getTopicName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Get destination name (for compatibility)
+ *
+ * @return string
+ */
+ public function getDestinationName(): string
+ {
+ return $this->name;
+ }
+}
diff --git a/src/TestSuite/Transport/TestDriver.php b/src/TestSuite/Transport/TestDriver.php
new file mode 100644
index 0000000..f6ced19
--- /dev/null
+++ b/src/TestSuite/Transport/TestDriver.php
@@ -0,0 +1,16 @@
+
+ */
+ protected array $properties;
+
+ /**
+ * Message headers
+ *
+ * @var array
+ */
+ protected array $headers;
+
+ /**
+ * Constructor
+ *
+ * @param string $body Message body
+ * @param array $properties Properties
+ * @param array $headers Headers
+ */
+ public function __construct(string $body = '', array $properties = [], array $headers = [])
+ {
+ $this->body = $body;
+ $this->properties = $properties;
+ $this->headers = $headers;
+ }
+
+ /**
+ * Get body
+ *
+ * @return string
+ */
+ public function getBody(): string
+ {
+ return $this->body;
+ }
+
+ /**
+ * Set body
+ *
+ * @param string $body Body
+ * @return void
+ */
+ public function setBody(string $body): void
+ {
+ $this->body = $body;
+ }
+
+ /**
+ * Set property
+ *
+ * @param string $name Property name
+ * @param mixed $value Property value
+ * @return void
+ */
+ public function setProperty(string $name, mixed $value): void
+ {
+ $this->properties[$name] = $value;
+ }
+
+ /**
+ * Get property
+ *
+ * @param string $name Property name
+ * @param mixed $default Default value
+ * @return mixed
+ */
+ public function getProperty(string $name, mixed $default = null): mixed
+ {
+ return $this->properties[$name] ?? $default;
+ }
+
+ /**
+ * Set header
+ *
+ * @param string $name Header name
+ * @param mixed $value Header value
+ * @return void
+ */
+ public function setHeader(string $name, mixed $value): void
+ {
+ $this->headers[$name] = $value;
+ }
+
+ /**
+ * Get header
+ *
+ * @param string $name Header name
+ * @param mixed $default Default value
+ * @return mixed
+ */
+ public function getHeader(string $name, mixed $default = null): mixed
+ {
+ return $this->headers[$name] ?? $default;
+ }
+
+ /**
+ * Get properties
+ *
+ * @return array
+ */
+ public function getProperties(): array
+ {
+ return $this->properties;
+ }
+
+ /**
+ * Set properties
+ *
+ * @param array $properties Properties
+ * @return void
+ */
+ public function setProperties(array $properties): void
+ {
+ $this->properties = $properties;
+ }
+
+ /**
+ * Get headers
+ *
+ * @return array
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers;
+ }
+
+ /**
+ * Set headers
+ *
+ * @param array $headers Headers
+ * @return void
+ */
+ public function setHeaders(array $headers): void
+ {
+ $this->headers = $headers;
+ }
+
+ /**
+ * Get redelivered flag
+ *
+ * @return bool
+ */
+ public function isRedelivered(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Set redelivered flag
+ *
+ * @param bool $redelivered Redelivered
+ * @return void
+ */
+ public function setRedelivered(bool $redelivered): void
+ {
+ }
+
+ /**
+ * Get correlation ID
+ *
+ * @return string|null
+ */
+ public function getCorrelationId(): ?string
+ {
+ return $this->getHeader('correlation_id');
+ }
+
+ /**
+ * Set correlation ID
+ *
+ * @param string|null $correlationId Correlation ID
+ * @return void
+ */
+ public function setCorrelationId(?string $correlationId = null): void
+ {
+ $this->setHeader('correlation_id', $correlationId);
+ }
+
+ /**
+ * Get message ID
+ *
+ * @return string|null
+ */
+ public function getMessageId(): ?string
+ {
+ return $this->getHeader('message_id');
+ }
+
+ /**
+ * Set message ID
+ *
+ * @param string|null $messageId Message ID
+ * @return void
+ */
+ public function setMessageId(?string $messageId = null): void
+ {
+ $this->setHeader('message_id', $messageId);
+ }
+
+ /**
+ * Get timestamp
+ *
+ * @return int|null
+ */
+ public function getTimestamp(): ?int
+ {
+ return $this->getHeader('timestamp');
+ }
+
+ /**
+ * Set timestamp
+ *
+ * @param int|null $timestamp Timestamp
+ * @return void
+ */
+ public function setTimestamp(?int $timestamp = null): void
+ {
+ $this->setHeader('timestamp', $timestamp);
+ }
+
+ /**
+ * Get reply to
+ *
+ * @return string|null
+ */
+ public function getReplyTo(): ?string
+ {
+ return $this->getHeader('reply_to');
+ }
+
+ /**
+ * Set reply to
+ *
+ * @param string|null $replyTo Reply to
+ * @return void
+ */
+ public function setReplyTo(?string $replyTo = null): void
+ {
+ $this->setHeader('reply_to', $replyTo);
+ }
+}
diff --git a/src/TestSuite/Transport/TestProducer.php b/src/TestSuite/Transport/TestProducer.php
new file mode 100644
index 0000000..75b433f
--- /dev/null
+++ b/src/TestSuite/Transport/TestProducer.php
@@ -0,0 +1,119 @@
+deliveryDelay,
+ $this->timeToLive,
+ $this->priority,
+ );
+ }
+
+ /**
+ * Set delivery delay
+ *
+ * @param int|null $deliveryDelay Delay in milliseconds
+ * @return \Interop\Queue\Producer
+ */
+ public function setDeliveryDelay(?int $deliveryDelay = null): Producer
+ {
+ $this->deliveryDelay = $deliveryDelay;
+
+ return $this;
+ }
+
+ /**
+ * Get delivery delay
+ *
+ * @return int|null
+ */
+ public function getDeliveryDelay(): ?int
+ {
+ return $this->deliveryDelay;
+ }
+
+ /**
+ * Set priority
+ *
+ * @param int|null $priority Priority
+ * @return \Interop\Queue\Producer
+ */
+ public function setPriority(?int $priority = null): Producer
+ {
+ $this->priority = $priority;
+
+ return $this;
+ }
+
+ /**
+ * Get priority
+ *
+ * @return int|null
+ */
+ public function getPriority(): ?int
+ {
+ return $this->priority;
+ }
+
+ /**
+ * Set time to live
+ *
+ * @param int|null $timeToLive Time to live in milliseconds
+ * @return \Interop\Queue\Producer
+ */
+ public function setTimeToLive(?int $timeToLive = null): Producer
+ {
+ $this->timeToLive = $timeToLive;
+
+ return $this;
+ }
+
+ /**
+ * Get time to live
+ *
+ * @return int|null
+ */
+ public function getTimeToLive(): ?int
+ {
+ return $this->timeToLive;
+ }
+}
diff --git a/src/TestSuite/Transport/TestSubscriptionConsumer.php b/src/TestSuite/Transport/TestSubscriptionConsumer.php
new file mode 100644
index 0000000..075d93e
--- /dev/null
+++ b/src/TestSuite/Transport/TestSubscriptionConsumer.php
@@ -0,0 +1,55 @@
+records = [
+ [
+ 'id' => 1,
+ 'class' => LogToDebugJob::class,
+ 'method' => 'execute',
+ 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}',
+ 'config' => 'default',
+ 'priority' => null,
+ 'queue' => 'default',
+ 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.',
+ 'created' => '2022-10-11 18:42:29',
+ ],
+ [
+ 'id' => 2,
+ 'class' => MaxAttemptsIsThreeJob::class,
+ 'method' => 'execute',
+ 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}',
+ 'config' => 'default',
+ 'priority' => null,
+ 'queue' => 'default',
+ 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.',
+ 'created' => '2022-10-11 18:42:29',
+ ],
+ [
+ 'id' => 3,
+ 'class' => LogToDebugJob::class,
+ 'method' => 'execute',
+ 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}',
+ 'config' => 'alternate_config',
+ 'priority' => null,
+ 'queue' => 'alternate_queue',
+ 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.',
+ 'created' => '2022-10-11 18:42:29',
+ ],
+ ];
+ parent::init();
+ }
+}
diff --git a/tests/TestCase/Command/PurgeFailedCommandTest.php b/tests/TestCase/Command/PurgeFailedCommandTest.php
new file mode 100644
index 0000000..40af581
--- /dev/null
+++ b/tests/TestCase/Command/PurgeFailedCommandTest.php
@@ -0,0 +1,117 @@
+exec('queue purge_failed', ['y']);
+
+ $this->assertOutputContains('Deleting 3 jobs.');
+ $this->assertOutputContains('3 jobs deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->all();
+
+ $this->assertCount(0, $results);
+ }
+
+ public function testFailedJobsAreNotDeletedIfNotConfirmed()
+ {
+ $this->exec('queue purge_failed', ['n']);
+
+ $this->assertOutputNotContains('Deleting');
+ $this->assertOutputNotContains('deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->all();
+
+ $this->assertCount(3, $results);
+ }
+
+ public function testFailedJobsAreDeletedById()
+ {
+ $this->exec('queue purge_failed 1,2 -f');
+
+ $this->assertOutputContains('Deleting 2 jobs.');
+ $this->assertOutputContains('2 jobs deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray();
+
+ $this->assertCount(1, $results);
+ $this->assertSame(3, $results[0]->id);
+ }
+
+ public function testFailedJobsAreDeletedByClass()
+ {
+ $class = LogToDebugJob::class;
+ $this->exec(sprintf('queue purge_failed --class %s -f', $class));
+
+ $this->assertOutputContains('Deleting 2 jobs.');
+ $this->assertOutputContains('2 jobs deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray();
+
+ $this->assertCount(1, $results);
+ $this->assertSame(2, $results[0]->id);
+ }
+
+ public function testFailedJobsAreDeletedByQueue()
+ {
+ $this->exec('queue purge_failed --queue alternate_queue -f');
+
+ $this->assertOutputContains('Deleting 1 jobs.');
+ $this->assertOutputContains('1 jobs deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray();
+
+ $this->assertCount(2, $results);
+ $this->assertSame(1, $results[0]->id);
+ $this->assertSame(2, $results[1]->id);
+ }
+
+ public function testFailedJobsAreDeletedByConfig()
+ {
+ $this->exec('queue purge_failed --config alternate_config -f');
+
+ $this->assertOutputContains('Deleting 1 jobs.');
+ $this->assertOutputContains('1 jobs deleted.');
+
+ $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray();
+
+ $this->assertCount(2, $results);
+ $this->assertSame(1, $results[0]->id);
+ $this->assertSame(2, $results[1]->id);
+ }
+}
diff --git a/tests/TestCase/Command/RequeueCommandTest.php b/tests/TestCase/Command/RequeueCommandTest.php
new file mode 100644
index 0000000..5887b51
--- /dev/null
+++ b/tests/TestCase/Command/RequeueCommandTest.php
@@ -0,0 +1,200 @@
+ 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . uniqid('queue'),
+ 'receiveTimeout' => 100,
+ ];
+ Configure::write('Queue', ['default' => $config]);
+ }
+
+ public function tearDown(): void
+ {
+ parent::tearDown();
+
+ Log::reset();
+
+ QueueManager::drop('default');
+ }
+
+ public function testJobsAreRequeued()
+ {
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+ QueueManager::drop('default');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+ $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0);
+
+ $this->cleanupConsoleTrait();
+ $this->exec('queue requeue --queue default', ['y']);
+
+ $this->assertOutputContains('Requeueing 2 jobs.');
+ $this->assertOutputContains('2 jobs requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 1);
+ $this->assertDebugLogContains('MaxAttemptsIsThreeJob is requeueing');
+ }
+
+ public function testJobsAreNotRequeuedIfNotConfirmed()
+ {
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+ QueueManager::drop('default');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+ $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0);
+
+ $this->cleanupConsoleTrait();
+ $this->exec('queue requeue --queue default', ['n']);
+
+ $this->assertOutputNotContains('Requeueing');
+ $this->assertOutputNotContains('requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+ $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0);
+ }
+
+ public function testJobsAreRequeuedById()
+ {
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+ QueueManager::drop('default');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+ $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob was run', 0);
+
+ $this->cleanupConsoleTrait();
+ $this->exec('queue requeue 1,2 -f');
+
+ $this->assertOutputContains('Requeueing 2 jobs.');
+ $this->assertOutputContains('2 jobs requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 1);
+ $this->assertDebugLogContains('MaxAttemptsIsThreeJob is requeueing');
+ }
+
+ public function testJobsAreRequeuedByClass()
+ {
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+ QueueManager::drop('default');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+
+ $this->cleanupConsoleTrait();
+ $class = LogToDebugJob::class;
+ $this->exec(sprintf('queue requeue --class %s --queue default -f', $class));
+
+ $this->assertOutputContains('Requeueing 1 jobs.');
+ $this->assertOutputContains('1 jobs requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --verbose');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 1);
+ }
+
+ public function testJobsAreRequeuedByQueue()
+ {
+ $config = [
+ 'queue' => 'alternate_queue',
+ 'url' => 'file:///' . TMP . DS . 'queue' . uniqid('queue'),
+ 'receiveTimeout' => 100,
+ ];
+ Configure::write('Queue', ['alternate_config' => $config]);
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose');
+ QueueManager::drop('alternate_config');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+
+ $this->cleanupConsoleTrait();
+ $this->exec('queue requeue --queue alternate_queue -f');
+
+ $this->assertOutputContains('Requeueing 1 jobs.');
+ $this->assertOutputContains('1 jobs requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose');
+ QueueManager::drop('alternate_config');
+
+ $this->assertDebugLogContains('Debug job was run');
+ }
+
+ public function testJobsAreRequeuedByConfig()
+ {
+ $config = [
+ 'queue' => 'alternate_queue',
+ 'url' => 'file:///' . TMP . DS . 'queue' . uniqid('queue'),
+ 'receiveTimeout' => 100,
+ ];
+ Configure::write('Queue', ['alternate_config' => $config]);
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose');
+ QueueManager::drop('alternate_config');
+
+ $this->assertDebugLogContainsExactly('Debug job was run', 0);
+
+ $this->cleanupConsoleTrait();
+ $this->exec('queue requeue --config alternate_config -f');
+
+ $this->assertOutputContains('Requeueing 1 jobs.');
+ $this->assertOutputContains('1 jobs requeued.');
+
+ $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose');
+ QueueManager::drop('alternate_config');
+
+ $this->assertDebugLogContains('Debug job was run');
+ }
+}
diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php
index 0d3666c..470deaa 100644
--- a/tests/TestCase/Command/WorkerCommandTest.php
+++ b/tests/TestCase/Command/WorkerCommandTest.php
@@ -14,14 +14,19 @@
* @since 0.1.0
* @license https://opensource.org/licenses/MIT MIT License
*/
+
namespace Cake\Queue\Test\TestCase\Command;
+use Cake\Console\TestSuite\ConsoleIntegrationTestTrait;
use Cake\Core\Configure;
use Cake\Log\Log;
use Cake\Queue\QueueManager;
-use Cake\Queue\Test\TestCase\DebugLogTrait;
-use Cake\TestSuite\ConsoleIntegrationTestTrait;
+use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob;
+use Cake\Queue\Test\test_app\src\Queue\TestCustomProcessor;
+use Cake\Queue\Test\TestCase\QueueTestTrait;
use Cake\TestSuite\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use TestApp\Job\LogToDebugJob;
use TestApp\Job\RequeueJob;
use TestApp\WelcomeMailerListener;
@@ -34,13 +39,7 @@
class WorkerCommandTest extends TestCase
{
use ConsoleIntegrationTestTrait;
- use DebugLogTrait;
-
- public function setUp(): void
- {
- parent::setUp();
- $this->useCommandRunner();
- }
+ use QueueTestTrait;
/**
* Test that command description prints out
@@ -53,9 +52,8 @@ public function testDescriptionOutput()
/**
* Test that queue will run for one second
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesStart()
{
Configure::write('Queue', [
@@ -65,14 +63,13 @@ public function testQueueProcessesStart()
],
]);
$this->exec('queue worker --max-runtime=0');
- $this->assertEmpty($this->getActualOutput());
+ $this->assertEmpty($this->output());
}
/**
* Test that queue will run for one second with valid listener
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesWithListener()
{
Configure::write('Queue', [
@@ -83,14 +80,13 @@ public function testQueueProcessesWithListener()
],
]);
$this->exec('queue worker --max-runtime=0');
- $this->assertEmpty($this->getActualOutput());
+ $this->assertEmpty($this->output());
}
/**
* Test that queue will abort when the passed config is not present in the app configuration.
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueWillAbortWithMissingConfig()
{
Configure::write('Queue', [
@@ -107,9 +103,8 @@ public function testQueueWillAbortWithMissingConfig()
/**
* Test that queue will abort with invalid listener
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesWithInvalidListener()
{
Configure::write('Queue', [
@@ -126,9 +121,8 @@ public function testQueueProcessesWithInvalidListener()
/**
* Test that queue will write to specified logger option
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesWithLogger()
{
Configure::write('Queue', [
@@ -149,9 +143,9 @@ public function testQueueProcessesWithLogger()
/**
* Data provider for testQueueProcessesJob method
*
- * @return array
+ * @return array|string[]>>
*/
- public function dataProviderCallableTypes(): array
+ public static function dataProviderCallableTypes(): array
{
return [
'Job Class' => [LogToDebugJob::class],
@@ -161,10 +155,9 @@ public function dataProviderCallableTypes(): array
/**
* Start up the worker queue, push a job, and see that it processes
- *
- * @dataProvider dataProviderCallableTypes
- * @runInSeparateProcess
*/
+ #[DataProvider('dataProviderCallableTypes')]
+ #[RunInSeparateProcess]
public function testQueueProcessesJob($callable)
{
$config = [
@@ -190,9 +183,8 @@ public function testQueueProcessesJob($callable)
/**
* Set the processor name, Start up the worker queue, push a job, and see that it processes
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesJobWithProcessor()
{
$config = [
@@ -217,9 +209,8 @@ public function testQueueProcessesJobWithProcessor()
/**
* Test non-default queue name
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesJobWithOtherQueue()
{
$config = [
@@ -245,9 +236,8 @@ public function testQueueProcessesJobWithOtherQueue()
/**
* Test max-attempts option
- *
- * @runInSeparateProcess
*/
+ #[RunInSeparateProcess]
public function testQueueProcessesJobWithMaxAttempts()
{
$config = [
@@ -270,4 +260,182 @@ public function testQueueProcessesJobWithMaxAttempts()
$this->assertDebugLogContainsExactly('RequeueJob is requeueing', 3);
}
+
+ /**
+ * Test DI service injection works in tasks
+ */
+ #[RunInSeparateProcess]
+ public function testQueueProcessesJobWithDIService()
+ {
+ $this->skipIf(version_compare(Configure::version(), '4.2', '<'), 'DI Container is only available since CakePHP 4.2');
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ ];
+ Configure::write('Queue', ['default' => $config]);
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ QueueManager::setConfig('default', $config);
+ QueueManager::push(LogToDebugWithServiceJob::class);
+ QueueManager::drop('default');
+
+ $this->exec('queue worker --max-jobs=1 --processor=processor-name --logger=debug --verbose');
+
+ $this->assertDebugLogContains('Debug job was run with service infotext');
+ }
+
+ /**
+ * Test that queue will process when a unique cache is configured.
+ */
+ #[RunInSeparateProcess]
+ public function testQueueProcessesWithUniqueCacheConfigured()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ $this->exec('queue worker --max-jobs=1 --logger=debug --verbose');
+
+ $this->assertDebugLogContains('Consumption has started');
+ }
+
+ /**
+ * Test that queue uses default processor when no processor is specified.
+ */
+ #[RunInSeparateProcess]
+ public function testQueueUsesDefaultProcessor()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ QueueManager::setConfig('default', $config);
+ QueueManager::push(LogToDebugJob::class);
+ QueueManager::drop('default');
+
+ $this->exec('queue worker --max-jobs=1 --logger=debug --verbose');
+
+ $this->assertDebugLogContains('Debug job was run');
+ }
+
+ /**
+ * Test that queue uses custom processor when specified in configuration.
+ */
+ #[RunInSeparateProcess]
+ public function testQueueUsesCustomProcessor()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ 'processor' => TestCustomProcessor::class,
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ QueueManager::setConfig('default', $config);
+ QueueManager::push(LogToDebugJob::class);
+ QueueManager::drop('default');
+
+ $this->exec('queue worker --max-jobs=1 --logger=debug --verbose');
+
+ $this->assertDebugLogContains('Debug job was run');
+ $this->assertDebugLogContains('TestCustomProcessor processing message');
+ $this->assertDebugLogContains('Message processed successfully by TestCustomProcessor');
+ }
+
+ /**
+ * Test that queue aborts when custom processor class does not exist.
+ */
+ #[RunInSeparateProcess]
+ public function testQueueAbortsWithNonExistentProcessor()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ 'processor' => 'NonExistentProcessor',
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ $this->exec('queue worker --max-runtime=0');
+
+ $this->assertErrorContains('Processor class NonExistentProcessor not found');
+ }
+
+ /**
+ * Test that queue aborts when custom processor does not implement Interop\Queue\Processor.
+ */
+ #[RunInSeparateProcess]
+ public function testQueueAbortsWithInvalidProcessor()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ 'processor' => 'stdClass',
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ $this->exec('queue worker --max-runtime=0');
+
+ $this->assertErrorContains('Processor class stdClass must implement Interop\Queue\Processor');
+ }
+
+ /**
+ * Test that custom processor works with listener configuration.
+ */
+ #[RunInSeparateProcess]
+ public function testCustomProcessorWithListener()
+ {
+ $config = [
+ 'queue' => 'default',
+ 'url' => 'file:///' . TMP . DS . 'queue',
+ 'receiveTimeout' => 100,
+ 'processor' => TestCustomProcessor::class,
+ 'listener' => WelcomeMailerListener::class,
+ ];
+ Configure::write('Queue', ['default' => $config]);
+
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ QueueManager::setConfig('default', $config);
+ QueueManager::push(LogToDebugJob::class);
+ QueueManager::drop('default');
+
+ $this->exec('queue worker --max-jobs=1 --logger=debug --verbose');
+
+ $this->assertDebugLogContains('Debug job was run');
+ $this->assertDebugLogContains('TestCustomProcessor processing message');
+ }
}
diff --git a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php
index 41525d0..606f611 100644
--- a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php
+++ b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php
@@ -3,26 +3,34 @@
namespace Cake\Queue\Test\TestCase\Job;
+use Cake\Event\EventList;
+use Cake\Event\EventManager;
use Cake\Log\Log;
use Cake\Queue\Consumption\LimitAttemptsExtension;
use Cake\Queue\Consumption\LimitConsumedMessagesExtension;
use Cake\Queue\Queue\Processor as QueueProcessor;
use Cake\Queue\QueueManager;
-use Cake\Queue\Test\TestCase\DebugLogTrait;
+use Cake\Queue\Test\TestCase\QueueTestTrait;
use Cake\TestSuite\TestCase;
use Enqueue\Consumption\ChainExtension;
+use PHPUnit\Framework\Attributes\After;
+use PHPUnit\Framework\Attributes\BeforeClass;
use Psr\Log\NullLogger;
use TestApp\Job\MaxAttemptsIsThreeJob;
use TestApp\Job\RequeueJob;
class LimitAttemptsExtensionTest extends TestCase
{
- use DebugLogTrait;
+ use QueueTestTrait;
- /**
- * @beforeClass
- * @after
- */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ EventManager::instance()->setEventList(new EventList());
+ }
+
+ #[BeforeClass, After]
public static function dropConfigs()
{
Log::drop('debug');
@@ -31,7 +39,7 @@ public static function dropConfigs()
public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet()
{
- $consume = $this->setupQeueue();
+ $consume = $this->setupQueue();
QueueManager::push(RequeueJob::class);
@@ -41,9 +49,20 @@ public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet()
$this->assertGreaterThanOrEqual(10, $count);
}
+ public function testFailedEventIsFiredWhenMaxAttemptsIsExceeded()
+ {
+ $consume = $this->setupQueue();
+
+ QueueManager::push(MaxAttemptsIsThreeJob::class, []);
+
+ $consume();
+
+ $this->assertEventFired('Consumption.LimitAttemptsExtension.failed');
+ }
+
public function testMessageShouldBeRequeuedUntilMaxAttemptsIsReached()
{
- $consume = $this->setupQeueue();
+ $consume = $this->setupQueue();
QueueManager::push(MaxAttemptsIsThreeJob::class, []);
@@ -54,7 +73,7 @@ public function testMessageShouldBeRequeuedUntilMaxAttemptsIsReached()
public function testMessageShouldBeRequeuedIfGlobalMaxAttemptsIsNotSet()
{
- $consume = $this->setupQeueue();
+ $consume = $this->setupQueue();
QueueManager::push(RequeueJob::class);
@@ -66,7 +85,7 @@ public function testMessageShouldBeRequeuedIfGlobalMaxAttemptsIsNotSet()
public function testMessageShouldBeRequeuedUntilGlobalMaxAttemptsIsReached()
{
- $consume = $this->setupQeueue([3]);
+ $consume = $this->setupQueue([3]);
QueueManager::push(MaxAttemptsIsThreeJob::class, ['succeedAt' => 10]);
@@ -75,7 +94,7 @@ public function testMessageShouldBeRequeuedUntilGlobalMaxAttemptsIsReached()
$this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 3);
}
- protected function setupQeueue($extensionArgs = [])
+ protected function setupQueue($extensionArgs = [])
{
Log::setConfig('debug', [
'className' => 'Array',
diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php
new file mode 100644
index 0000000..05b940e
--- /dev/null
+++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php
@@ -0,0 +1,81 @@
+setupQueue();
+
+ QueueManager::push(UniqueJob::class, []);
+
+ $uniqueId = QueueManager::getUniqueId(UniqueJob::class, 'execute', []);
+ $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default'));
+
+ $consume();
+
+ $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default'));
+ }
+
+ protected function setupQueue()
+ {
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['debug'],
+ ]);
+
+ QueueManager::setConfig('default', [
+ 'url' => 'file:///' . TMP . DS . uniqid('queue'),
+ 'receiveTimeout' => 100,
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ ]);
+
+ $client = QueueManager::engine('default');
+
+ $processor = new QueueProcessor(new NullLogger());
+ $client->bindTopic('default', $processor);
+
+ $extension = new ChainExtension([
+ new LimitConsumedMessagesExtension(1),
+ new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique.default'),
+ ]);
+
+ return function () use ($client, $extension) {
+ $client->consume($extension);
+ };
+ }
+}
diff --git a/tests/TestCase/DebugLogTrait.php b/tests/TestCase/DebugLogTrait.php
deleted file mode 100644
index d479f9e..0000000
--- a/tests/TestCase/DebugLogTrait.php
+++ /dev/null
@@ -1,36 +0,0 @@
-debugLogCount($expected);
-
- $this->assertGreaterThanOrEqual(1, $found, "Did not find `{$expected}` in logs.");
- }
-
- protected function assertDebugLogContainsExactly($expected, $times): void
- {
- $found = $this->debugLogCount($expected);
-
- $this->assertSame($times, $found, "Did not find `{$expected}` exactly {$times} times in logs.");
- }
-
- protected function debugLogCount($seach)
- {
- $log = Log::engine('debug');
- $found = 0;
- foreach ($log->read() as $line) {
- if (strpos($line, $seach) !== false) {
- $found++;
- }
- }
-
- return $found;
- }
-}
diff --git a/tests/TestCase/Job/MailerJobTest.php b/tests/TestCase/Job/MailerJobTest.php
index f083153..81dd8c8 100644
--- a/tests/TestCase/Job/MailerJobTest.php
+++ b/tests/TestCase/Job/MailerJobTest.php
@@ -16,6 +16,7 @@
*/
namespace Cake\Queue\Test\TestCase\Job;
+use BadMethodCallException;
use Cake\Mailer\Exception\MissingMailerException;
use Cake\Mailer\Mailer;
use Cake\Queue\Job\MailerJob;
@@ -31,12 +32,16 @@ class MailerJobTest extends TestCase
* @var \Cake\Mailer\Mailer|\PHPUnit\Framework\MockObject\MockObject
*/
protected $mailer;
+
/**
* @var \Cake\Queue\Job\MailerJob|\PHPUnit\Framework\MockObject\MockObject
*/
protected $job;
+
protected $mailerConfig;
+
protected $headers;
+
protected $args;
/**
@@ -70,7 +75,7 @@ public function testExecute()
->with(
$this->equalTo('welcome'),
$this->equalTo($this->args),
- $this->equalTo($this->headers)
+ $this->equalTo($this->headers),
)
->willReturn(['Message sent']);
@@ -78,7 +83,7 @@ public function testExecute()
->method('getMailer')
->with(
$this->equalTo('SampleTest'),
- $this->equalTo($this->mailerConfig)
+ $this->equalTo($this->mailerConfig),
)->willReturn($this->mailer);
$message = $this->createMessage();
@@ -100,7 +105,7 @@ public function testExecuteMissingMailerException()
->method('getMailer')
->with(
$this->equalTo('SampleTest'),
- $this->equalTo($this->mailerConfig)
+ $this->equalTo($this->mailerConfig),
)->willThrowException(new MissingMailerException('Missing mailer for testExecuteMissingMailerException'));
$message = $this->createMessage();
@@ -120,15 +125,15 @@ public function testExecuteBadMethodCallException()
->with(
$this->equalTo('welcome'),
$this->equalTo($this->args),
- $this->equalTo($this->headers)
+ $this->equalTo($this->headers),
)
- ->willThrowException(new \BadMethodCallException('Welcome is not a valid method'));
+ ->willThrowException(new BadMethodCallException('Welcome is not a valid method'));
$this->job->expects($this->once())
->method('getMailer')
->with(
$this->equalTo('SampleTest'),
- $this->equalTo($this->mailerConfig)
+ $this->equalTo($this->mailerConfig),
)->willReturn($this->mailer);
$message = $this->createMessage();
@@ -138,8 +143,6 @@ public function testExecuteBadMethodCallException()
/**
* Create a simple message for testing.
- *
- * @return \Cake\Queue\Job\Message
*/
protected function createMessage(): Message
{
@@ -156,8 +159,7 @@ protected function createMessage(): Message
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
$originalMessage = new NullMessage(json_encode($messageBody));
- $message = new Message($originalMessage, $context);
- return $message;
+ return new Message($originalMessage, $context);
}
}
diff --git a/tests/TestCase/Job/MessageTest.php b/tests/TestCase/Job/MessageTest.php
index e828814..9d09471 100644
--- a/tests/TestCase/Job/MessageTest.php
+++ b/tests/TestCase/Job/MessageTest.php
@@ -23,6 +23,7 @@
use Enqueue\Null\NullMessage;
use Error;
use RuntimeException;
+use TestApp\WelcomeMailer;
class MessageTest extends TestCase
{
@@ -33,10 +34,10 @@ class MessageTest extends TestCase
*/
public function testConstructorAndGetters()
{
- $callable = ['TestApp\WelcomeMailer', 'welcome'];
+ $callable = [WelcomeMailer::class, 'welcome'];
$time = 'sample data ' . time();
$id = 7;
- $data = compact('id', 'time');
+ $data = ['id' => $id, 'time' => $time];
$parsedBody = [
'class' => $callable,
'data' => $data,
@@ -71,7 +72,7 @@ public function testConstructorAndGetters()
*/
public function testLegacyArguments()
{
- $callable = ['TestApp\WelcomeMailer', 'welcome'];
+ $callable = [WelcomeMailer::class, 'welcome'];
$args = [
'first' => 1,
'second' => 'two',
@@ -123,7 +124,7 @@ public function testGetCallableInvalidClass()
public function testGetCallableInvalidType()
{
$parsedBody = [
- 'class' => ['TestApp\WelcomeMailer', 'trash', 'oops'],
+ 'class' => [WelcomeMailer::class, 'trash', 'oops'],
'args' => [],
];
$messageBody = json_encode($parsedBody);
diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php
new file mode 100644
index 0000000..d60a219
--- /dev/null
+++ b/tests/TestCase/Job/SendMailJobTest.php
@@ -0,0 +1,169 @@
+job = new SendMailJob();
+ $this->message = (new MailerMessage())
+ ->setFrom('from@example.com')
+ ->setTo('to@example.com')
+ ->setSubject('Sample Subject');
+ }
+
+ /**
+ * Test execute method
+ *
+ * @return void
+ */
+ public function testExecute()
+ {
+ $job = $this->getMockBuilder(SendMailJob::class)
+ ->onlyMethods(['getTransport'])
+ ->getMock();
+ $message = $this->createMessage(DebugTransport::class, [], $this->message);
+ $emailMessage = new MailerMessage();
+ $data = json_decode($message->getArgument('emailMessage'), true);
+ $emailMessage->createFromArray($data);
+ $transport = $this->getMockBuilder(DebugTransport::class)->getMock();
+ $transport->expects($this->once())
+ ->method('send')
+ ->with($emailMessage)
+ ->willReturn(['message' => 'test', 'headers' => []]);
+ $job->expects($this->once())
+ ->method('getTransport')
+ ->with(DebugTransport::class, [])
+ ->willReturn($transport);
+ $actual = $job->execute($message);
+ $this->assertSame(Processor::ACK, $actual);
+ }
+
+ /**
+ * Test execute method with transport name
+ *
+ * @return void
+ */
+ public function testExecuteTransportName()
+ {
+ $job = new SendMailJob();
+ $message = $this->createMessage('foo', [], $this->message);
+ $emailMessage = new MailerMessage();
+ $data = json_decode($message->getArgument('emailMessage'), true);
+ $emailMessage->createFromArray($data);
+
+ $transport = $this->getMockBuilder(DebugTransport::class)->getMock();
+ $transport->expects($this->once())
+ ->method('send')
+ ->with($emailMessage)
+ ->willReturn(['message' => 'test', 'headers' => []]);
+ TransportFactory::getRegistry()->set('foo', $transport);
+
+ $actual = $job->execute($message);
+ $this->assertSame(Processor::ACK, $actual);
+
+ TransportFactory::drop('foo');
+ }
+
+ /**
+ * Test execute with attachments method
+ *
+ * @return void
+ */
+ public function testExecuteWithAttachments()
+ {
+ $emailMessage = clone $this->message;
+ $emailMessage->addAttachments(['test.txt' => ROOT . 'files' . DS . 'test.txt']);
+
+ $message = $this->createMessage(DebugTransport::class, [], $emailMessage);
+ $actual = $this->job->execute($message);
+ $this->assertSame(Processor::ACK, $actual);
+ }
+
+ /**
+ * Test execute method with invalid transport
+ *
+ * @return void
+ */
+ public function testExecuteInvalidTransport()
+ {
+ $message = $this->createMessage('WrongTransport', [], $this->message);
+ $actual = $this->job->execute($message);
+ $this->assertSame(Processor::REJECT, $actual);
+ }
+
+ /**
+ * Test execute method with unserializable message
+ *
+ * @return void
+ */
+ public function testExecuteUnserializableMessage()
+ {
+ $message = $this->createMessage(DebugTransport::class, [], 'unserializable');
+ $actual = $this->job->execute($message);
+ $this->assertSame(Processor::REJECT, $actual);
+ }
+
+ public function testExecuteNoAbstractTransport()
+ {
+ $message = $this->createMessage(Mailer::class, [], $this->message);
+ $actual = $this->job->execute($message);
+ $this->assertSame(Processor::REJECT, $actual);
+ }
+
+ /**
+ * Create a simple message for testing.
+ */
+ protected function createMessage($transport, $config, $emailMessage): QueueJobMessage
+ {
+ $messageBody = [
+ 'class' => ['Queue\\Job\\SendMailJob', 'execute'],
+ 'data' => [
+ 'transport' => $transport,
+ 'config' => $config,
+ 'emailMessage' => json_encode($emailMessage),
+
+ ],
+ ];
+ $connectionFactory = new NullConnectionFactory();
+ $context = $connectionFactory->createContext();
+ $originalMessage = new NullMessage(json_encode($messageBody));
+
+ return new QueueJobMessage($originalMessage, $context);
+ }
+}
diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php
new file mode 100644
index 0000000..d5a56e9
--- /dev/null
+++ b/tests/TestCase/Listener/FailedJobsListenerTest.php
@@ -0,0 +1,173 @@
+ 'null:',
+ 'storeFailedJobs' => true,
+ ]);
+ }
+
+ public function testFailedJobIsAddedWhenEventIsFired()
+ {
+ $parsedBody = [
+ 'class' => [LogToDebugJob::class, 'execute'],
+ 'data' => ['example_key' => 'example_value'],
+ 'requeueOptions' => [
+ 'config' => 'example_config',
+ 'priority' => 'example_priority',
+ 'queue' => 'example_queue',
+ ],
+ ];
+ $messageBody = json_encode($parsedBody);
+ $connectionFactory = new NullConnectionFactory();
+
+ $context = $connectionFactory->createContext();
+ $originalMessage = new NullMessage($messageBody);
+ $message = new Message($originalMessage, $context);
+
+ $event = new Event(
+ 'Consumption.LimitAttemptsExtension.failed',
+ $message,
+ ['exception' => 'some message'],
+ );
+
+ /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */
+ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs');
+ $failedJobsTable->deleteAll(['1=1']);
+
+ EventManager::instance()->on(new FailedJobsListener());
+ EventManager::instance()->dispatch($event);
+
+ $this->assertSame(1, $failedJobsTable->find()->count());
+
+ $failedJob = $failedJobsTable->find()->first();
+
+ $this->assertSame(LogToDebugJob::class, $failedJob->class);
+ $this->assertSame('execute', $failedJob->method);
+ $this->assertSame(json_encode(['example_key' => 'example_value']), $failedJob->data);
+ $this->assertSame('example_config', $failedJob->config);
+ $this->assertSame('example_priority', $failedJob->priority);
+ $this->assertSame('example_queue', $failedJob->queue);
+ $this->assertStringContainsString('some message', $failedJob->exception);
+ }
+
+ /**
+ * Data provider for testStoreFailedJobException
+ *
+ * @return array[]
+ */
+ public static function storeFailedJobExceptionDataProvider()
+ {
+ return [
+ [['exception' => 'some message'], '`logger` was not defined on Consumption.LimitAttemptsExtension.failed event'],
+ [['exception' => 'some message', 'logger' => new stdClass()], '`logger` is not an instance of `LoggerInterface` on Consumption.LimitAttemptsExtension.failed event.'],
+ ];
+ }
+
+ /**
+ * @return void
+ */
+ #[DataProvider('storeFailedJobExceptionDataProvider')]
+ public function testStoreFailedJobException($eventData, $exceptionMessage)
+ {
+ $tableLocator = $this
+ ->getMockBuilder(TableLocator::class)
+ ->onlyMethods(['get'])
+ ->getMock();
+ $failedJobsTable = $this
+ ->getMockBuilder(FailedJobsTable::class)
+ ->onlyMethods(['saveOrFail'])
+ ->getMock();
+ $failedJobsListener = $this
+ ->getMockBuilder(FailedJobsListener::class)
+ ->onlyMethods(['getTableLocator'])
+ ->getMock();
+ $failedJobsTable->expects($this->once())
+ ->method('saveOrFail')
+ ->willThrowException(new PersistenceFailedException(new Entity(), 'Persistence Failed'));
+ $tableLocator->expects($this->once())
+ ->method('get')
+ ->with('Cake/Queue.FailedJobs')
+ ->willReturn($failedJobsTable);
+ $failedJobsListener->expects($this->once())
+ ->method('getTableLocator')
+ ->willReturn($tableLocator);
+
+ $parsedBody = [
+ 'class' => [LogToDebugJob::class, 'execute'],
+ 'data' => ['example_key' => 'example_value'],
+ 'requeueOptions' => [
+ 'config' => 'example_config',
+ 'priority' => 'example_priority',
+ 'queue' => 'example_queue',
+ ],
+ ];
+ $messageBody = json_encode($parsedBody);
+ $connectionFactory = new NullConnectionFactory();
+
+ $context = $connectionFactory->createContext();
+ $originalMessage = new NullMessage($messageBody);
+ $message = new Message($originalMessage, $context);
+
+ $event = new Event(
+ 'Consumption.LimitAttemptsExtension.failed',
+ $message,
+ $eventData,
+ );
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage($exceptionMessage);
+
+ EventManager::instance()->on($failedJobsListener);
+ EventManager::instance()->dispatch($event);
+ }
+}
diff --git a/tests/TestCase/Mailer/QueueTraitTest.php b/tests/TestCase/Mailer/QueueTraitTest.php
index 93d067b..df5f241 100644
--- a/tests/TestCase/Mailer/QueueTraitTest.php
+++ b/tests/TestCase/Mailer/QueueTraitTest.php
@@ -18,11 +18,15 @@
use Cake\Mailer\Exception\MissingActionException;
use Cake\Queue\QueueManager;
+use Cake\Queue\Test\TestCase\QueueTestTrait;
use Cake\TestSuite\TestCase;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use TestApp\WelcomeMailer;
class QueueTraitTest extends TestCase
{
+ use QueueTestTrait;
+
/**
* Test that a MissingActionException is being thrown when
* the push action is not found on the object with the QueueTrait
@@ -38,10 +42,8 @@ public function testQueueTraitTestThrowsMissingActionException()
/**
* Test that QueueTrait calls push
- *
- * @runInSeparateProcess
- * @return @void
*/
+ #[RunInSeparateProcess]
public function testQueueTraitCallsPush()
{
$queue = new WelcomeMailer();
diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php
new file mode 100644
index 0000000..7da8041
--- /dev/null
+++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php
@@ -0,0 +1,157 @@
+fsQueuePath;
+ }
+
+ private function getFsQueueFile(): string
+ {
+ return $this->getFsQueueUrl() . DS . 'enqueue.app.default';
+ }
+
+ /**
+ * Test send
+ *
+ * @return void
+ */
+ public function testSend()
+ {
+ QueueManager::setConfig('default', [
+ 'queue' => 'default',
+ 'url' => $this->getFsQueueUrl(),
+ ]);
+ $message = (new Message())
+ ->setFrom('from@example.com')
+ ->setTo('to@example.com')
+ ->setSubject('Sample Subject');
+
+ $transport = new QueueTransport();
+
+ $result = $transport->send($message);
+
+ $headers = $message->getHeadersString(
+ [
+ 'from',
+ 'to',
+ 'subject',
+ 'sender',
+ 'replyTo',
+ 'readReceipt',
+ 'returnPath',
+ 'cc',
+ 'bcc',
+ ],
+ );
+
+ $expected = ['headers' => $headers, 'message' => 'Message has been enqueued'];
+ $this->assertEquals($expected, $result);
+
+ $fsQueueFile = $this->getFsQueueFile();
+ $this->assertFileExists($fsQueueFile);
+
+ $content = file_get_contents($fsQueueFile);
+ $this->assertStringContainsString('MailTransport', $content);
+
+ QueueManager::drop('default');
+ }
+
+ /**
+ * Test send custom transport
+ *
+ * @return void
+ */
+ public function testSendCustomTransport()
+ {
+ QueueManager::setConfig('default', [
+ 'queue' => 'default',
+ 'url' => $this->getFsQueueUrl(),
+ ]);
+ $message = new Message();
+
+ $transport = new QueueTransport([
+ 'transport' => SmtpTransport::class,
+ 'config' => [
+ 'host' => 'mail.example.com',
+ ],
+ ]);
+ $transport->send($message);
+
+ $fsQueueFile = $this->getFsQueueFile();
+ $this->assertFileExists($fsQueueFile);
+
+ $content = file_get_contents($fsQueueFile);
+ $this->assertStringContainsString('SmtpTransport', $content);
+ $this->assertStringContainsString('"config\":{\"host\":\"mail.example.com\"}', $content);
+
+ QueueManager::drop('default');
+ }
+
+ /**
+ * Test send backwards compatibility transport config
+ *
+ * @return void
+ */
+ public function testSendBcTransport()
+ {
+ QueueManager::setConfig('default', [
+ 'queue' => 'default',
+ 'url' => $this->getFsQueueUrl(),
+ ]);
+ $message = new Message();
+
+ $transport = new QueueTransport([
+ 'transport' => SmtpTransport::class,
+ 'host' => 'mail.example.com',
+ ]);
+ $transport->send($message);
+
+ $fsQueueFile = $this->getFsQueueFile();
+ $this->assertFileExists($fsQueueFile);
+
+ $content = file_get_contents($fsQueueFile);
+ $this->assertStringContainsString('SmtpTransport', $content);
+ $this->assertStringContainsString('"host\":\"mail.example.com\"', $content);
+
+ QueueManager::drop('default');
+ }
+
+ public function tearDown(): void
+ {
+ parent::tearDown();
+
+ $fsQueueFile = $this->getFsQueueFile();
+ if (file_exists($fsQueueFile)) {
+ unlink($fsQueueFile);
+ }
+ }
+}
diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php
new file mode 100644
index 0000000..022ad82
--- /dev/null
+++ b/tests/TestCase/PluginTest.php
@@ -0,0 +1,56 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Missing `Queue` configuration key, please check the CakePHP Queue documentation to complete the plugin setup');
+ Configure::delete('Queue');
+ $plugin = new QueuePlugin();
+ $app = $this->createStub(Application::class);
+ $plugin->bootstrap($app);
+ }
+
+ /**
+ * Test Plugin bootstrap with config
+ *
+ * @return void
+ */
+ public function testBootstrapWithConfig()
+ {
+ $queueConfig = [
+ 'url' => 'null:',
+ 'queue' => 'default',
+ 'logger' => 'stdout',
+ ];
+ Configure::write('Queue', ['default' => $queueConfig]);
+ $plugin = new QueuePlugin();
+ $app = $this->createStub(Application::class);
+ $plugin->bootstrap($app);
+ $queueConfig['url'] = [
+ 'transport' => 'null:',
+ 'client' => [
+ 'router_topic' => 'default',
+ 'router_queue' => 'default',
+ 'default_queue' => 'default',
+ ],
+ ];
+ $this->assertEquals($queueConfig, QueueManager::getConfig('default'));
+ }
+}
diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php
index e586157..d0161de 100644
--- a/tests/TestCase/Queue/ProcessorTest.php
+++ b/tests/TestCase/Queue/ProcessorTest.php
@@ -14,6 +14,7 @@
* @since 0.1.0
* @license https://opensource.org/licenses/MIT MIT License
*/
+
namespace Cake\Queue\Test\TestCase\Queue;
use Cake\Event\EventList;
@@ -21,26 +22,58 @@
use Cake\Log\Log;
use Cake\Queue\Job\Message;
use Cake\Queue\Queue\Processor;
+use Cake\Queue\Test\TestCase\QueueTestTrait;
use Cake\TestSuite\TestCase;
use Enqueue\Null\NullConnectionFactory;
use Enqueue\Null\NullMessage;
-use Exception;
use Interop\Queue\Processor as InteropProcessor;
+use PHPUnit\Framework\Attributes\DataProvider;
+use TestApp\TestProcessor;
+use TestApp\WelcomeMailer;
+use Traversable;
class ProcessorTest extends TestCase
{
- public static $lastProcessMessage;
+ use QueueTestTrait;
+
+ /**
+ * Convert EventList to array in a backwards-compatible way.
+ *
+ * In CakePHP 5.3.0+ EventList implements Traversable but array access is deprecated.
+ * In older versions, EventList only supports array access.
+ *
+ * @param \Cake\Event\EventList $events The event list to convert.
+ * @return array<\Cake\Event\EventInterface>
+ */
+ protected function eventListToArray(EventList $events): array
+ {
+ if ($events instanceof Traversable) {
+ /** @var array<\Cake\Event\EventInterface> */
+ return iterator_to_array($events);
+ }
+
+ $result = [];
+ $count = $events->count();
+ for ($i = 0; $i < $count; $i++) {
+ $event = $events[$i];
+ if ($event !== null) {
+ $result[] = $event;
+ }
+ }
+
+ return $result;
+ }
/**
* Data provider for testProcess method
*
- * @return array
+ * @return array
*/
- public function dataProviderTestProcess(): array
+ public static function dataProviderTestProcess(): array
{
return [
- 'ack' => ['processReturnAck', InteropProcessor::ACK, 'Message processed sucessfully', 'Processor.message.success'],
- 'null' => ['processReturnNull', InteropProcessor::ACK, 'Message processed sucessfully', 'Processor.message.success'],
+ 'ack' => ['processReturnAck', InteropProcessor::ACK, 'Message processed successfully', 'Processor.message.success'],
+ 'null' => ['processReturnNull', InteropProcessor::ACK, 'Message processed successfully', 'Processor.message.success'],
'reject' => ['processReturnReject', InteropProcessor::REJECT, 'Message processed with rejection', 'Processor.message.reject'],
'requeue' => ['processReturnRequeue', InteropProcessor::REQUEUE, 'Message processed with failure, requeuing', 'Processor.message.failure'],
'string' => ['processReturnString', InteropProcessor::REQUEUE, 'Message processed with failure, requeuing', 'Processor.message.failure'],
@@ -53,25 +86,27 @@ public function dataProviderTestProcess(): array
* @param string $jobMethod The method name to run
* @param string $expected The expected process result.
* @param string $logMessage The log message based on process result.
- * @param string $dispacthedEvent The dispatched event based on process result.
- * @dataProvider dataProviderTestProcess
+ * @param string $dispatchedEvent The dispatched event based on process result.
* @return void
*/
- public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent)
+ #[DataProvider('dataProviderTestProcess')]
+ public function testProcess(string $jobMethod, string $expected, string $logMessage, string $dispatchedEvent): void
{
$messageBody = [
- 'class' => [static::class, $jobMethod],
+ 'class' => [TestProcessor::class, $jobMethod],
'args' => [],
];
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
- $queueMessage = new NullMessage(json_encode($messageBody));
+ $queueMessage = new NullMessage((string)json_encode($messageBody));
$message = new Message($queueMessage, $context);
$events = new EventList();
$logger = new ArrayLog();
$processor = new Processor($logger);
- $processor->getEventManager()->setEventList($events);
+ /** @var \Cake\Event\EventManager $eventManager */
+ $eventManager = $processor->getEventManager();
+ $eventManager->setEventList($events);
$actual = $processor->process($queueMessage, $context);
$this->assertSame($expected, $actual);
@@ -82,19 +117,25 @@ public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent
$this->assertStringContainsString($logMessage, $logs[0]);
$this->assertSame(3, $events->count());
- $this->assertSame('Processor.message.seen', $events[0]->getName());
- $this->assertEquals(['queueMessage' => $queueMessage], $events[0]->getData());
+ $eventsList = $this->eventListToArray($events);
+ $this->assertSame('Processor.message.seen', $eventsList[0]->getName());
+ $this->assertEquals(['queueMessage' => $queueMessage], $eventsList[0]->getData());
// Events should contain a message with the same payload.
- $this->assertSame('Processor.message.start', $events[1]->getName());
- $data = $events[1]->getData();
+ $this->assertSame('Processor.message.start', $eventsList[1]->getName());
+ $data = $eventsList[1]->getData();
$this->assertArrayHasKey('message', $data);
$this->assertSame($message->jsonSerialize(), $data['message']->jsonSerialize());
- $this->assertSame($dispatchedEvent, $events[2]->getName());
- $data = $events[2]->getData();
+ $this->assertSame($dispatchedEvent, $eventsList[2]->getName());
+ $data = $eventsList[2]->getData();
$this->assertArrayHasKey('message', $data);
$this->assertSame($message->jsonSerialize(), $data['message']->jsonSerialize());
+
+ // Verify timing information is present in completion events
+ $this->assertArrayHasKey('duration', $data);
+ $this->assertIsInt($data['duration']);
+ $this->assertGreaterThanOrEqual(0, $data['duration']);
}
/**
@@ -110,12 +151,14 @@ public function testProcessNotValidCallable()
];
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
- $queueMessage = new NullMessage(json_encode($messageBody));
+ $queueMessage = new NullMessage((string)json_encode($messageBody));
$events = new EventList();
$logger = new ArrayLog();
$processor = new Processor($logger);
- $processor->getEventManager()->setEventList($events);
+ /** @var \Cake\Event\EventManager $eventManager */
+ $eventManager = $processor->getEventManager();
+ $eventManager->setEventList($events);
$result = $processor->process($queueMessage, $context);
$this->assertSame(InteropProcessor::REJECT, $result);
@@ -126,8 +169,9 @@ public function testProcessNotValidCallable()
$this->assertStringContainsString('Invalid callable for message. Rejecting message from queue', $logs[0]);
$this->assertSame(2, $events->count());
- $this->assertSame('Processor.message.seen', $events[0]->getName());
- $this->assertSame('Processor.message.invalid', $events[1]->getName());
+ $eventsList = $this->eventListToArray($events);
+ $this->assertSame('Processor.message.seen', $eventsList[0]->getName());
+ $this->assertSame('Processor.message.invalid', $eventsList[1]->getName());
}
/**
@@ -140,20 +184,31 @@ public function testProcessWillRequeueOnException()
{
$method = 'processAndThrowException';
$messageBody = [
- 'class' => [static::class, $method],
+ 'class' => [TestProcessor::class, $method],
'data' => ['sample_data' => 'a value', 'key' => md5($method)],
];
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
- $queueMessage = new NullMessage(json_encode($messageBody));
+ $queueMessage = new NullMessage((string)json_encode($messageBody));
$events = new EventList();
$logger = new ArrayLog();
$processor = new Processor($logger);
- $processor->getEventManager()->setEventList($events);
+ /** @var \Cake\Event\EventManager $eventManager */
+ $eventManager = $processor->getEventManager();
+ $eventManager->setEventList($events);
$result = $processor->process($queueMessage, $context);
- $this->assertSame(InteropProcessor::REQUEUE, $result);
+ $this->assertEquals(InteropProcessor::REQUEUE, $result);
+
+ // Verify timing information is present in exception event
+ $this->assertSame(3, $events->count());
+ $eventsList = $this->eventListToArray($events);
+ $this->assertSame('Processor.message.exception', $eventsList[2]->getName());
+ $data = $eventsList[2]->getData();
+ $this->assertArrayHasKey('duration', $data);
+ $this->assertIsInt($data['duration']);
+ $this->assertGreaterThanOrEqual(0, $data['duration']);
}
/**
@@ -169,16 +224,18 @@ public function testProcessJobObject()
]);
$messageBody = [
- 'class' => ['TestApp\WelcomeMailer', 'welcome'],
+ 'class' => [WelcomeMailer::class, 'welcome'],
'args' => [],
];
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
- $queueMessage = new NullMessage(json_encode($messageBody));
+ $queueMessage = new NullMessage((string)json_encode($messageBody));
$processor = new Processor();
$result = $processor->process($queueMessage, $context);
- $logs = Log::engine('debug')->read();
+ /** @var \Cake\Log\Engine\ArrayLog $debugLog */
+ $debugLog = Log::engine('debug');
+ $logs = $debugLog->read();
Log::drop('debug');
$this->assertCount(1, $logs);
@@ -195,94 +252,17 @@ public function testProcessJobObject()
public function testProcessMessage()
{
$messageBody = [
- 'class' => [static::class, 'processReturnAck'],
+ 'class' => [TestProcessor::class, 'processReturnAck'],
'args' => [],
];
$connectionFactory = new NullConnectionFactory();
$context = $connectionFactory->createContext();
- $queueMessage = new NullMessage(json_encode($messageBody));
+ $queueMessage = new NullMessage((string)json_encode($messageBody));
$message = new Message($queueMessage, $context);
$processor = new Processor();
$result = $processor->processMessage($message);
$this->assertSame(InteropProcessor::ACK, $result);
- $this->assertNotEmpty(static::$lastProcessMessage);
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param \Cake\Queue\Job\Message $message The message to process
- * @return null
- */
- public static function processReturnNull(Message $message)
- {
- static::$lastProcessMessage = $message;
-
- return null;
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param Message $message The message to process
- * @return null
- * @throws Exception
- */
- public static function processAndThrowException(Message $message)
- {
- throw new Exception('Something went wrong');
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param \Cake\Queue\Message $message The message to process
- * @return null
- */
- public static function processReturnReject(Message $message)
- {
- static::$lastProcessMessage = $message;
-
- return InteropProcessor::REJECT;
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param \Cake\Queue\Message $message The message to process
- * @return null
- */
- public static function processReturnAck(Message $message)
- {
- static::$lastProcessMessage = $message;
-
- return InteropProcessor::ACK;
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param \Cake\Queue\Message $message The message to process
- * @return null
- */
- public static function processReturnRequeue(Message $message)
- {
- static::$lastProcessMessage = $message;
-
- return InteropProcessor::REQUEUE;
- }
-
- /**
- * Job to be used in test testProcessMessageCallableIsString
- *
- * @param \Cake\Queue\Message $message The message to process
- * @return null
- */
- public static function processReturnString(Message $message)
- {
- static::$lastProcessMessage = $message;
-
- return 'invalid value';
+ $this->assertInstanceOf(Message::class, TestProcessor::$lastProcessMessage);
}
}
diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php
index c9e1a1c..c584751 100644
--- a/tests/TestCase/QueueManagerTest.php
+++ b/tests/TestCase/QueueManagerTest.php
@@ -2,61 +2,146 @@
declare(strict_types=1);
/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
- * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link http://cakephp.org CakePHP(tm) Project
+ * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link https://cakephp.org CakePHP(tm) Project
* @since 0.1.0
- * @license http://www.opensource.org/licenses/mit-license.php MIT License
+ * @license https://www.opensource.org/licenses/mit-license.php MIT License
*/
+
namespace Cake\Queue\Test\TestCase;
use BadMethodCallException;
+use Cake\Cache\Cache;
use Cake\Log\Log;
use Cake\Queue\QueueManager;
use Cake\TestSuite\TestCase;
use Enqueue\SimpleClient\SimpleClient;
use LogicException;
use TestApp\Job\LogToDebugJob;
+use TestApp\Job\UniqueJob;
+use TypeError;
/**
* QueueManager test
*/
class QueueManagerTest extends TestCase
{
- private $fsQueueUrl = 'file:///' . TMP . DS . 'queue';
+ use QueueTestTrait;
+
+ private $fsQueuePath = TMP . DS . 'queue';
+
+ private function getFsQueueUrl(): string
+ {
+ return 'file:///' . $this->fsQueuePath;
+ }
public function tearDown(): void
{
parent::tearDown();
+ $cacheKey = QueueManager::getConfig('test')['uniqueCacheKey'] ?? null;
+ if ($cacheKey) {
+ Cache::clear($cacheKey);
+ Cache::drop($cacheKey);
+ }
+
QueueManager::drop('test');
Log::drop('test');
// delete file based queues
- array_map('unlink', glob($this->fsQueueUrl . DS . '*'));
+ array_map('unlink', glob($this->fsQueuePath . DS . '*'));
+ }
+
+ public function testGetUniqueId()
+ {
+ $first = QueueManager::getUniqueId('Example', 'hello', [1, 2, 3]);
+ $second = QueueManager::getUniqueId('Example', 'hello', [3, 2, 1]);
+ $this->assertNotEquals($first, $second, 'values are sorted by key');
+
+ $second = QueueManager::getUniqueId('Human', 'hello', [3, 2, 1]);
+ $this->assertNotEquals($first, $second, 'class changes hash');
+
+ $second = QueueManager::getUniqueId('Example', 'bye', [3, 2, 1]);
+ $this->assertNotEquals($first, $second, 'method changes hash');
+
+ $first = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']);
+ $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']);
+ $this->assertSame($first, $second, 'same values and keys are the same');
+
+ $second = QueueManager::getUniqueId('Example', 'hello', ['role' => 'guest', 'user_id' => 'admin']);
+ $this->assertSame($first, $second, 'reordered keys are the same');
+
+ $first = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']);
+ $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'admin']);
+ $this->assertNotEquals($first, $second, 'different values are distinct');
+
+ $first = QueueManager::getUniqueId('Example', 'hello', ['foo' => 'admin', 'bar' => 'guest']);
+ $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']);
+ $this->assertNotEquals($first, $second, 'different keys, same values are distinct');
+
+ $first = QueueManager::getUniqueId('Example', 'hello', ['foo' => 'foo', 'bar' => 'foo']);
+ $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']);
+ $this->assertNotEquals($first, $second, 'different keys and values, are distinct');
+
+ $first = QueueManager::getUniqueId('Example', 'hello', ['arr' => ['a' => 1, 'b' => 2]]);
+ $second = QueueManager::getUniqueId('Example', 'hello', ['arr' => ['b' => 2, 'a' => 1]]);
+ $this->assertEquals($first, $second, 'nested arrays are sorted too');
}
public function testSetConfig()
{
- $result = QueueManager::setConfig('test', [
+ QueueManager::setConfig('test', [
'url' => 'null:',
]);
- $this->assertNull($result);
$config = QueueManager::getConfig('test');
$this->assertSame('null:', $config['url']);
}
- public function testSetConfigInvalidValue()
+ public function testSetMultipleConfigs()
+ {
+ QueueManager::setConfig('test', [
+ 'url' => 'null:',
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ 'logger' => 'debug',
+ ]);
+
+ QueueManager::setConfig('other', [
+ 'url' => 'null:',
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ 'logger' => 'debug',
+ ]);
+
+ $testConfig = QueueManager::getConfig('test');
+ $this->assertSame('null:', $testConfig['url']);
+
+ $otherConfig = QueueManager::getConfig('other');
+ $this->assertSame('null:', $otherConfig['url']);
+
+ QueueManager::drop('other');
+ }
+
+ public function testSetConfigWithInvalidConfigValue()
{
$this->expectException(LogicException::class);
- QueueManager::setConfig('test', null);
+ QueueManager::setConfig('test');
+ }
+
+ public function testSetConfigInvalidKeyValue()
+ {
+ $this->expectException(TypeError::class);
+ QueueManager::setConfig(['test' => []], 'default');
}
public function testSetConfigNoUrl()
@@ -81,7 +166,7 @@ public function testSetConfigOverwrite()
public function testNonDefaultQueueNameString()
{
QueueManager::setConfig('test', [
- 'url' => $this->fsQueueUrl,
+ 'url' => $this->getFsQueueUrl(),
'queue' => 'other',
]);
$engine = QueueManager::engine('test');
@@ -127,14 +212,56 @@ public function testPushInvalidClass()
public function testMessageIsPushedToQueuePassedAsOption()
{
QueueManager::setConfig('test', [
- 'url' => $this->fsQueueUrl,
+ 'url' => $this->getFsQueueUrl(),
'queue' => 'test',
]);
QueueManager::push(LogToDebugJob::class, [], ['config' => 'test', 'queue' => 'non-default-queue-name']);
- $fsQueueFile = $this->fsQueueUrl . DS . 'enqueue.app.test';
+ $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test';
$this->assertFileExists($fsQueueFile);
$this->assertStringContainsString('non-default-queue-name', file_get_contents($fsQueueFile));
}
+
+ public function testUniqueMessageIsQueuedOnlyOnce()
+ {
+ QueueManager::setConfig('test', [
+ 'url' => $this->getFsQueueUrl(),
+ 'queue' => 'test',
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ ]);
+
+ QueueManager::push(UniqueJob::class, [], ['config' => 'test']);
+ QueueManager::push(UniqueJob::class, [], ['config' => 'test']);
+
+ $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test';
+ $this->assertFileExists($fsQueueFile);
+ $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob'));
+ }
+
+ public function testDroppedJobIsLoggedForUniqueJob()
+ {
+ Log::setConfig('debug', [
+ 'className' => 'Array',
+ 'levels' => ['notice', 'info', 'debug'],
+ ]);
+
+ QueueManager::setConfig('test', [
+ 'url' => $this->getFsQueueUrl(),
+ 'queue' => 'test',
+ 'uniqueCache' => [
+ 'engine' => 'File',
+ ],
+ 'logger' => 'debug',
+ ]);
+
+ QueueManager::push(UniqueJob::class, [], ['config' => 'test']);
+ QueueManager::push(UniqueJob::class, [], ['config' => 'test']);
+
+ $this->assertDebugLogContainsExactly('An identical instance of TestApp\Job\UniqueJob already exists on the queue. This push will be ignored.', 1);
+
+ Log::drop('debug');
+ }
}
diff --git a/tests/TestCase/QueueTestTrait.php b/tests/TestCase/QueueTestTrait.php
new file mode 100644
index 0000000..f24f205
--- /dev/null
+++ b/tests/TestCase/QueueTestTrait.php
@@ -0,0 +1,109 @@
+debugLogCount($expected);
+
+ $this->assertGreaterThanOrEqual(1, $found, sprintf('Did not find `%s` in logs.', $expected));
+ }
+
+ /**
+ * Assert that a message was found exactly N times in debug logs
+ *
+ * @param string $expected The message to search for in logs
+ * @param int $times The exact number of times the message should appear
+ * @return void
+ */
+ protected function assertDebugLogContainsExactly($expected, $times): void
+ {
+ $found = $this->debugLogCount($expected);
+
+ $this->assertSame($times, $found, sprintf('Did not find `%s` exactly %d times in logs.', $expected, $times));
+ }
+
+ /**
+ * Count occurrences of a message in debug logs
+ *
+ * @param string $search The message to search for
+ * @return int The number of times the message was found
+ */
+ protected function debugLogCount($search): int
+ {
+ $log = Log::engine('debug');
+ $found = 0;
+ foreach ($log->read() as $line) {
+ if (strpos($line, $search) !== false) {
+ $found++;
+ }
+ }
+
+ return $found;
+ }
+}
diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php
index bd8c65b..ec4f8f7 100644
--- a/tests/TestCase/Task/JobTaskTest.php
+++ b/tests/TestCase/Task/JobTaskTest.php
@@ -2,23 +2,23 @@
declare(strict_types=1);
/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
- * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link http://cakephp.org CakePHP(tm) Project
+ * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link https://cakephp.org CakePHP(tm) Project
* @since 0.1.0
- * @license http://www.opensource.org/licenses/mit-license.php MIT License
+ * @license https://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace Cake\Queue\Test\TestCase\Task;
use Cake\Command\Command;
+use Cake\Console\TestSuite\ConsoleIntegrationTestTrait;
use Cake\Queue\QueueManager;
-use Cake\TestSuite\ConsoleIntegrationTestTrait;
use Cake\TestSuite\StringCompareTrait;
use Cake\TestSuite\TestCase;
@@ -35,17 +35,19 @@ class JobTaskTest extends TestCase
*/
protected $generatedFile = '';
+ /**
+ * @var string
+ */
+ protected $comparisonDir = '';
+
/**
* setup method
- *
- * @return void
*/
public function setUp(): void
{
parent::setUp();
$this->comparisonDir = dirname(dirname(__DIR__)) . DS . 'comparisons' . DS;
- $this->useCommandRunner();
QueueManager::drop('default');
}
@@ -69,7 +71,35 @@ public function testMain()
$this->assertOutputContains('Creating file ' . $this->generatedFile);
$this->assertSameAsFile(
$this->comparisonDir . 'JobTask.php',
- file_get_contents($this->generatedFile)
+ file_get_contents($this->generatedFile),
+ );
+ }
+
+ public function testMainWithUnique()
+ {
+ $this->generatedFile = APP . 'Job' . DS . 'UploadJob.php';
+
+ $this->exec('bake job upload --unique');
+ $this->assertExitCode(Command::CODE_SUCCESS);
+ $this->assertFileExists($this->generatedFile);
+ $this->assertOutputContains('Creating file ' . $this->generatedFile);
+ $this->assertSameAsFile(
+ $this->comparisonDir . 'JobTaskWithUnique.php',
+ file_get_contents($this->generatedFile),
+ );
+ }
+
+ public function testMainWithMaxAttempts()
+ {
+ $this->generatedFile = APP . 'Job' . DS . 'UploadJob.php';
+
+ $this->exec('bake job upload --max-attempts 3');
+ $this->assertExitCode(Command::CODE_SUCCESS);
+ $this->assertFileExists($this->generatedFile);
+ $this->assertOutputContains('Creating file ' . $this->generatedFile);
+ $this->assertSameAsFile(
+ $this->comparisonDir . 'JobTaskWithMaxAttempts.php',
+ file_get_contents($this->generatedFile),
);
}
}
diff --git a/tests/TestCase/TestSuite/QueueTestSuiteTest.php b/tests/TestCase/TestSuite/QueueTestSuiteTest.php
new file mode 100644
index 0000000..0747165
--- /dev/null
+++ b/tests/TestCase/TestSuite/QueueTestSuiteTest.php
@@ -0,0 +1,693 @@
+ 'null:',
+ ]);
+ }
+
+ TestQueueClient::replaceAllClients();
+
+ $config = QueueManager::getConfig('default');
+ $url = $config['url'];
+ $transport = is_array($url) ? $url['transport'] : $url;
+ $this->assertEquals('test:', $transport);
+ }
+
+ /**
+ * Test assertJobQueued
+ *
+ * @return void
+ */
+ public function testAssertJobQueued(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->assertJobQueued(LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertJobQueued fails when job not queued
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedFailsWhenJobNotQueued(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueued(LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertJobNotQueued
+ *
+ * @return void
+ */
+ public function testAssertJobNotQueued(): void
+ {
+ $this->assertJobNotQueued(LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertJobNotQueued fails when job is queued
+ *
+ * @return void
+ */
+ public function testAssertJobNotQueuedFailsWhenJobQueued(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobNotQueued(LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertNoJobsQueued
+ *
+ * @return void
+ */
+ public function testAssertNoJobsQueued(): void
+ {
+ $this->assertNoJobsQueued();
+ }
+
+ /**
+ * Test assertNoJobsQueued fails when jobs queued
+ *
+ * @return void
+ */
+ public function testAssertNoJobsQueuedFailsWhenJobsQueued(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertNoJobsQueued();
+ }
+
+ /**
+ * Test assertJobCount
+ *
+ * @return void
+ */
+ public function testAssertJobCount(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->assertJobCount(2);
+ }
+
+ /**
+ * Test assertJobCount fails when count mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobCountFailsWhenCountMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobCount(2);
+ }
+
+ /**
+ * Test assertJobQueuedWith
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWith(): void
+ {
+ QueueManager::push(LogToDebugJob::class, ['key' => 'value', 'id' => 123]);
+
+ $this->assertJobQueuedWith(LogToDebugJob::class, ['key' => 'value', 'id' => 123]);
+ }
+
+ /**
+ * Test assertJobQueuedWith fails when data mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWithFailsWhenDataMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, ['key' => 'value']);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueuedWith(LogToDebugJob::class, ['key' => 'different']);
+ }
+
+ /**
+ * Test assertJobQueuedToQueue
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedToQueue(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']);
+
+ $this->assertJobQueuedToQueue('high-priority', LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertJobQueuedToQueue fails when queue mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedToQueueFailsWhenQueueMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['queue' => 'low-priority']);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueuedToQueue('high-priority', LogToDebugJob::class);
+ }
+
+ /**
+ * Test assertJobQueuedWithDelay
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWithDelay(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['delay' => 60]);
+
+ $this->assertJobQueuedWithDelay(LogToDebugJob::class, 60);
+ }
+
+ /**
+ * Test assertJobQueuedWithDelay fails when delay mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWithDelayFailsWhenDelayMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['delay' => 30]);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueuedWithDelay(LogToDebugJob::class, 60);
+ }
+
+ /**
+ * Test assertJobQueuedWithPriority
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWithPriority(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::HIGH]);
+
+ $this->assertJobQueuedWithPriority(LogToDebugJob::class, MessagePriority::HIGH);
+ }
+
+ /**
+ * Test assertJobQueuedWithPriority fails when priority mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedWithPriorityFailsWhenPriorityMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::LOW]);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueuedWithPriority(LogToDebugJob::class, MessagePriority::HIGH);
+ }
+
+ /**
+ * Test assertJobQueuedTimes
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedTimes(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+ QueueManager::push(LogToDebugJob::class, []);
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->assertJobQueuedTimes(LogToDebugJob::class, 3);
+ }
+
+ /**
+ * Test assertJobQueuedTimes fails when count mismatch
+ *
+ * @return void
+ */
+ public function testAssertJobQueuedTimesFailsWhenCountMismatch(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->expectException(AssertionFailedError::class);
+
+ $this->assertJobQueuedTimes(LogToDebugJob::class, 2);
+ }
+
+ /**
+ * Test getQueuedJobs
+ *
+ * @return void
+ */
+ public function testGetQueuedJobs(): void
+ {
+ QueueManager::push(LogToDebugJob::class, ['data' => 'value']);
+
+ $jobs = $this->getQueuedJobs();
+
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(LogToDebugJob::class, $jobs[0]['jobClass']);
+ $this->assertEquals('execute', $jobs[0]['method']);
+ $this->assertEquals(['data' => 'value'], $jobs[0]['data']);
+ $this->assertEquals('default', $jobs[0]['options']['queue']);
+ $this->assertEquals('default', $jobs[0]['options']['config']);
+ }
+
+ /**
+ * Test getQueuedJobsByClass
+ *
+ * @return void
+ */
+ public function testGetQueuedJobsByClass(): void
+ {
+ QueueManager::push(LogToDebugJob::class, ['job' => 1]);
+ QueueManager::push(LogToDebugJob::class, ['job' => 2]);
+
+ $this->assertJobQueued(LogToDebugJob::class);
+ $this->assertJobQueuedTimes(LogToDebugJob::class, 2);
+
+ $jobs = $this->getQueuedJobsByClass(LogToDebugJob::class);
+
+ $this->assertCount(2, $jobs);
+ }
+
+ /**
+ * Test getQueuedJobsByQueue
+ *
+ * @return void
+ */
+ public function testGetQueuedJobsByQueue(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']);
+ QueueManager::push(LogToDebugJob::class, [], ['queue' => 'low-priority']);
+ QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']);
+
+ $highPriorityJobs = $this->getQueuedJobsByQueue('high-priority');
+ $lowPriorityJobs = $this->getQueuedJobsByQueue('low-priority');
+
+ $this->assertCount(2, $highPriorityJobs);
+ $this->assertCount(1, $lowPriorityJobs);
+ }
+
+ /**
+ * Test getQueuedJobsByConfig
+ *
+ * @return void
+ */
+ public function testGetQueuedJobsByConfig(): void
+ {
+ QueueManager::setConfig('test', ['url' => 'null:']);
+ TestQueueClient::replaceAllClients();
+
+ QueueManager::push(LogToDebugJob::class, [], ['config' => 'default']);
+ QueueManager::push(LogToDebugJob::class, [], ['config' => 'test']);
+ QueueManager::push(LogToDebugJob::class, [], ['config' => 'default']);
+
+ $defaultJobs = $this->getQueuedJobsByConfig('default');
+ $testJobs = $this->getQueuedJobsByConfig('test');
+
+ $this->assertCount(2, $defaultJobs);
+ $this->assertCount(1, $testJobs);
+ }
+
+ /**
+ * Test clearQueuedJobs
+ *
+ * @return void
+ */
+ public function testClearQueuedJobs(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $this->assertCount(1, $this->getQueuedJobs());
+
+ TestQueueClient::clearQueuedJobs();
+
+ $this->assertCount(0, $this->getQueuedJobs());
+ }
+
+ /**
+ * Test job captured with delay
+ *
+ * @return void
+ */
+ public function testJobCapturedWithDelay(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['delay' => 60]);
+
+ $jobs = $this->getQueuedJobs();
+
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(60, $jobs[0]['options']['delay']);
+ }
+
+ /**
+ * Test job captured with priority
+ *
+ * @return void
+ */
+ public function testJobCapturedWithPriority(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::HIGH]);
+
+ $jobs = $this->getQueuedJobs();
+
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(MessagePriority::HIGH, $jobs[0]['options']['priority']);
+ }
+
+ /**
+ * Test job captured with expires
+ *
+ * @return void
+ */
+ public function testJobCapturedWithExpires(): void
+ {
+ QueueManager::push(LogToDebugJob::class, [], ['expires' => 3600]);
+
+ $jobs = $this->getQueuedJobs();
+
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(3600, $jobs[0]['options']['expires']);
+ }
+
+ /**
+ * Test getQueuedJobCount
+ *
+ * @return void
+ */
+ public function testGetQueuedJobCount(): void
+ {
+ $this->assertEquals(0, TestQueueClient::getQueuedJobCount());
+
+ QueueManager::push(LogToDebugJob::class, []);
+ $this->assertEquals(1, TestQueueClient::getQueuedJobCount());
+
+ QueueManager::push(LogToDebugJob::class, []);
+ $this->assertEquals(2, TestQueueClient::getQueuedJobCount());
+ }
+
+ /**
+ * Test replaceAllClients with multiple configs
+ *
+ * @return void
+ */
+ public function testReplaceAllClientsWithMultipleConfigs(): void
+ {
+ QueueManager::setConfig('test1', ['url' => 'null:']);
+ QueueManager::setConfig('test2', ['url' => 'null:']);
+
+ TestQueueClient::replaceAllClients();
+
+ $config1 = QueueManager::getConfig('test1');
+ $config2 = QueueManager::getConfig('test2');
+
+ $this->assertNotNull($config1);
+ $this->assertNotNull($config2);
+
+ $url1 = $config1['url'];
+ $url2 = $config2['url'];
+ $transport1 = is_array($url1) ? $url1['transport'] : $url1;
+ $transport2 = is_array($url2) ? $url2['transport'] : $url2;
+
+ $this->assertEquals('test:', $transport1);
+ $this->assertEquals('test:', $transport2);
+ }
+
+ /**
+ * Test replaceAllClients handles null config gracefully
+ *
+ * @return void
+ */
+ public function testReplaceAllClientsHandlesNullConfig(): void
+ {
+ QueueManager::setConfig('test-null', ['url' => 'null:']);
+ QueueManager::drop('test-null');
+
+ TestQueueClient::replaceAllClients();
+
+ $this->assertNull(QueueManager::getConfig('test-null'));
+ }
+
+ /**
+ * Test job captured with custom method
+ *
+ * @return void
+ */
+ public function testJobCapturedWithCustomMethod(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class, 'customMethod'],
+ 'data' => ['test' => 'value'],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage($body);
+
+ TestQueueClient::captureMessage($destination, $message);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(LogToDebugJob::class, $jobs[0]['jobClass']);
+ $this->assertEquals('customMethod', $jobs[0]['method']);
+ }
+
+ /**
+ * Test job captured with topic destination
+ *
+ * @return void
+ */
+ public function testJobCapturedWithTopicDestination(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class],
+ 'data' => [],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createTopic('test-topic');
+ $message = $context->createMessage($body);
+
+ TestQueueClient::captureMessage($destination, $message);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertCount(1, $jobs);
+ $this->assertEquals('test-topic', $jobs[0]['options']['queue']);
+ }
+
+ /**
+ * Test job captured with requeue options
+ *
+ * @return void
+ */
+ public function testJobCapturedWithRequeueOptions(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class],
+ 'data' => [],
+ 'requeueOptions' => [
+ 'config' => 'custom-config',
+ 'queue' => 'custom-queue',
+ 'priority' => 'high',
+ ],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage($body);
+
+ TestQueueClient::captureMessage($destination, $message);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertCount(1, $jobs);
+ $this->assertEquals('custom-config', $jobs[0]['options']['config']);
+ $this->assertEquals('custom-queue', $jobs[0]['options']['queue']);
+ $this->assertEquals('high', $jobs[0]['options']['priority']);
+ }
+
+ /**
+ * Test job captured with message properties for delay and expires
+ *
+ * @return void
+ */
+ public function testJobCapturedWithMessageProperties(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class],
+ 'data' => [],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage($body, [
+ 'enqueue.delay' => '5',
+ 'enqueue.expire' => '10',
+ 'enqueue.priority' => '5',
+ ]);
+
+ TestQueueClient::captureMessage($destination, $message, 3000, 6000, 3);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(5, $jobs[0]['options']['delay']);
+ $this->assertEquals(10, $jobs[0]['options']['expires']);
+ $this->assertEquals(5, $jobs[0]['options']['priority']);
+ }
+
+ /**
+ * Test job captured with delivery delay and time to live from producer
+ *
+ * @return void
+ */
+ public function testJobCapturedWithProducerDelayAndTtl(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class],
+ 'data' => [],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage($body);
+
+ TestQueueClient::captureMessage($destination, $message, 5000, 10000, 3);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertCount(1, $jobs);
+ $this->assertEquals(5, $jobs[0]['options']['delay']);
+ $this->assertEquals(10, $jobs[0]['options']['expires']);
+ $this->assertEquals(3, $jobs[0]['options']['priority']);
+ }
+
+ /**
+ * Test extractMessageBody with args fallback
+ *
+ * @return void
+ */
+ public function testExtractMessageBodyWithArgs(): void
+ {
+ $body = json_encode([
+ 'class' => [LogToDebugJob::class],
+ 'args' => [['test' => 'value']],
+ ]);
+
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage($body);
+
+ TestQueueClient::captureMessage($destination, $message);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertEquals(['test' => 'value'], $jobs[0]['data']);
+ }
+
+ /**
+ * Test extractMessageBody with invalid JSON
+ *
+ * @return void
+ */
+ public function testExtractMessageBodyWithInvalidJson(): void
+ {
+ $context = new TestContext();
+ $destination = $context->createQueue('default');
+ $message = $context->createMessage('invalid json');
+
+ TestQueueClient::captureMessage($destination, $message);
+
+ $jobs = $this->getQueuedJobs();
+ $this->assertNull($jobs[0]['jobClass']);
+ }
+
+ /**
+ * Test createConsumer with other destination
+ *
+ * @return void
+ */
+ public function testCreateConsumerWithOtherDestination(): void
+ {
+ $context = new TestContext();
+ $destination = new TestDestination('test');
+
+ $consumer = $context->createConsumer($destination);
+
+ $this->assertInstanceOf(TestConsumer::class, $consumer);
+ }
+
+ /**
+ * Test createConsumer with Topic destination (not Queue)
+ *
+ * @return void
+ */
+ public function testCreateConsumerWithTopicOnlyDestination(): void
+ {
+ $context = new TestContext();
+ $topic = $this->createMock(Topic::class);
+ $topic->method('getTopicName')->willReturn('test-topic');
+
+ $consumer = $context->createConsumer($topic);
+
+ $this->assertInstanceOf(TestConsumer::class, $consumer);
+ }
+
+ /**
+ * Test QueueConstraintBase with at parameter
+ *
+ * @return void
+ */
+ public function testQueueConstraintBaseWithAt(): void
+ {
+ QueueManager::push(LogToDebugJob::class, []);
+ QueueManager::push(LogToDebugJob::class, []);
+
+ $constraint = new JobQueued(0);
+ $this->assertTrue($constraint->matches(LogToDebugJob::class));
+ $this->assertEquals('job #0 was queued', $constraint->toString());
+
+ $constraint = new JobQueued(99);
+ $this->assertFalse($constraint->matches(LogToDebugJob::class));
+ }
+}
diff --git a/tests/TestCase/TestSuite/Transport/TransportTest.php b/tests/TestCase/TestSuite/Transport/TransportTest.php
new file mode 100644
index 0000000..1a750ff
--- /dev/null
+++ b/tests/TestCase/TestSuite/Transport/TransportTest.php
@@ -0,0 +1,147 @@
+createContext();
+ $this->assertInstanceOf(TestContext::class, $context);
+ $factory->close();
+ }
+
+ public function testTestContext(): void
+ {
+ $context = new TestContext();
+
+ $message = $context->createMessage('body', ['prop' => 'value'], ['header' => 'value']);
+ $this->assertInstanceOf(TestMessage::class, $message);
+ $this->assertEquals('body', $message->getBody());
+
+ $queue = $context->createQueue('test-queue');
+ $this->assertInstanceOf(TestDestination::class, $queue);
+ $this->assertEquals('test-queue', $queue->getQueueName());
+
+ $topic = $context->createTopic('test-topic');
+ $this->assertInstanceOf(TestDestination::class, $topic);
+ $this->assertEquals('test-topic', $topic->getTopicName());
+
+ $producer = $context->createProducer();
+ $this->assertInstanceOf(TestProducer::class, $producer);
+
+ $consumer = $context->createConsumer($queue);
+ $this->assertInstanceOf(TestConsumer::class, $consumer);
+
+ $topicConsumer = $context->createConsumer($topic);
+ $this->assertInstanceOf(TestConsumer::class, $topicConsumer);
+
+ $subscriptionConsumer = $context->createSubscriptionConsumer();
+ $this->assertInstanceOf(TestSubscriptionConsumer::class, $subscriptionConsumer);
+
+ $tempQueue = $context->createTemporaryQueue();
+ $this->assertInstanceOf(TestDestination::class, $tempQueue);
+ $this->assertStringStartsWith('temp_', $tempQueue->getQueueName());
+
+ $context->purgeQueue($queue);
+ $context->close();
+ }
+
+ public function testTestMessage(): void
+ {
+ $message = new TestMessage('body', ['prop' => 'value'], ['header' => 'value']);
+
+ $this->assertEquals('body', $message->getBody());
+ $message->setBody('new-body');
+ $this->assertEquals('new-body', $message->getBody());
+
+ $this->assertEquals('value', $message->getProperty('prop'));
+ $this->assertEquals('default', $message->getProperty('missing', 'default'));
+ $message->setProperty('new-prop', 'new-value');
+ $this->assertEquals('new-value', $message->getProperty('new-prop'));
+
+ $this->assertEquals('value', $message->getHeader('header'));
+ $this->assertEquals('default', $message->getHeader('missing', 'default'));
+ $message->setHeader('new-header', 'new-value');
+ $this->assertEquals('new-value', $message->getHeader('new-header'));
+
+ $message->setProperties(['a' => 'b']);
+ $this->assertEquals(['a' => 'b'], $message->getProperties());
+
+ $message->setHeaders(['x' => 'y']);
+ $this->assertEquals(['x' => 'y'], $message->getHeaders());
+
+ $this->assertFalse($message->isRedelivered());
+
+ $message->setCorrelationId('corr-123');
+ $this->assertEquals('corr-123', $message->getCorrelationId());
+
+ $message->setMessageId('msg-123');
+ $this->assertEquals('msg-123', $message->getMessageId());
+
+ $message->setTimestamp(1234567890);
+ $this->assertEquals(1234567890, $message->getTimestamp());
+
+ $message->setReplyTo('reply-to');
+ $this->assertEquals('reply-to', $message->getReplyTo());
+ }
+
+ public function testTestProducer(): void
+ {
+ $producer = new TestProducer();
+ $destination = new TestDestination('test-queue');
+ $message = new TestMessage('body');
+
+ $producer->setDeliveryDelay(1000);
+ $this->assertEquals(1000, $producer->getDeliveryDelay());
+
+ $producer->setPriority(5);
+ $this->assertEquals(5, $producer->getPriority());
+
+ $producer->setTimeToLive(2000);
+ $this->assertEquals(2000, $producer->getTimeToLive());
+
+ $producer->send($destination, $message);
+ }
+
+ public function testTestConsumer(): void
+ {
+ $subscriptionConsumer = new TestSubscriptionConsumer();
+ $queue = new TestDestination('test-queue');
+ $consumer = new TestConsumer($queue);
+
+ $this->assertEquals($queue, $consumer->getQueue());
+ $this->assertNull($consumer->receive());
+ $this->assertNull($consumer->receiveNoWait());
+
+ $message = new TestMessage('body');
+ $consumer->acknowledge($message);
+ $consumer->reject($message, true);
+
+ $subscriptionConsumer->consume(1000);
+ $subscriptionConsumer->subscribe($consumer, function () {
+ });
+ $subscriptionConsumer->unsubscribe($consumer);
+ $subscriptionConsumer->unsubscribeAll();
+ }
+
+ public function testTestDestination(): void
+ {
+ $destination = new TestDestination('test-name');
+
+ $this->assertEquals('test-name', $destination->getQueueName());
+ $this->assertEquals('test-name', $destination->getTopicName());
+ $this->assertEquals('test-name', $destination->getDestinationName());
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 4eae28d..e2f7ee2 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -2,21 +2,25 @@
declare(strict_types=1);
/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
- * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link http://cakephp.org CakePHP(tm) Project
+ * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link https://cakephp.org CakePHP(tm) Project
* @since 0.1.0
- * @license http://www.opensource.org/licenses/mit-license.php MIT License
+ * @license https://www.opensource.org/licenses/mit-license.php MIT License
*/
+use Cake\Cache\Cache;
use Cake\Core\Configure;
+use Cake\Datasource\ConnectionManager;
use Cake\Routing\Router;
+use Cake\TestSuite\Fixture\SchemaLoader;
+use function Cake\Core\env;
$findRoot = function ($root) {
do {
@@ -26,14 +30,14 @@
return $root;
}
} while ($root !== $lastRoot);
+
throw new Exception('Cannot find the root of the application, unable to run tests');
};
$root = $findRoot(__FILE__);
unset($findRoot);
chdir($root);
-require_once 'vendor/cakephp/cakephp/src/basics.php';
-require_once 'vendor/autoload.php';
+require_once $root . '/vendor/autoload.php';
define('CORE_PATH', $root . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp' . DS);
define('ROOT', $root . DS . 'tests' . DS . 'test_app' . DS);
@@ -45,6 +49,30 @@
define('CONFIG', ROOT . DS . 'config' . DS);
}
+// phpcs:disable
+@mkdir(CACHE);
+@mkdir(CACHE . 'views');
+@mkdir(CACHE . 'models');
+// phpcs:enable
+
+$cache_key = '_cake_translations_';
+if (Configure::version() <= '5.1.0') {
+ $cache_key = '_cake_core_';
+}
+
+Cache::setConfig([
+ $cache_key => [
+ 'engine' => 'File',
+ 'prefix' => '_cake_translations_',
+ 'serialize' => true,
+ ],
+ '_cake_model_' => [
+ 'engine' => 'File',
+ 'prefix' => 'cake_model_',
+ 'serialize' => true,
+ ],
+]);
+
Configure::write('debug', true);
Configure::write('App', [
'namespace' => 'TestApp',
@@ -73,9 +101,15 @@
putenv('db_dsn=sqlite:///:memory:');
}
-Cake\Datasource\ConnectionManager::setConfig('test', [
+ConnectionManager::setConfig('test', [
'url' => getenv('db_dsn'),
'timezone' => 'UTC',
]);
Router::reload();
+
+// Create test database schema
+if (env('FIXTURE_SCHEMA_METADATA')) {
+ $loader = new SchemaLoader();
+ $loader->loadInternalFile(env('FIXTURE_SCHEMA_METADATA'), 'test');
+}
diff --git a/tests/comparisons/JobTask.php b/tests/comparisons/JobTask.php
index 99c4f4e..822a677 100644
--- a/tests/comparisons/JobTask.php
+++ b/tests/comparisons/JobTask.php
@@ -12,13 +12,6 @@
*/
class UploadJob implements JobInterface
{
- /**
- * The maximum number of times the job may be attempted.
- *
- * @var int|null
- */
- public static $maxAttempts = 3;
-
/**
* Executes logic for UploadJob
*
diff --git a/tests/comparisons/JobTaskWithMaxAttempts.php b/tests/comparisons/JobTaskWithMaxAttempts.php
new file mode 100644
index 0000000..d8f4c91
--- /dev/null
+++ b/tests/comparisons/JobTaskWithMaxAttempts.php
@@ -0,0 +1,32 @@
+ 'queue_failed_jobs',
+ 'columns' => [
+ 'id' => ['type' => 'integer', 'length' => null, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null],
+ 'class' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null],
+ 'method' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null],
+ 'data' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null],
+ 'config' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
+ 'priority' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
+ 'queue' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
+ 'exception' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null],
+ 'created' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => ''],
+ ],
+ 'constraints' => [
+ 'primary' => [
+ 'type' => 'primary',
+ 'columns' => ['id'],
+ ],
+ ],
+ ],
+];
diff --git a/tests/test_app/files/test.txt b/tests/test_app/files/test.txt
new file mode 100644
index 0000000..49c224b
--- /dev/null
+++ b/tests/test_app/files/test.txt
@@ -0,0 +1 @@
+TEST CONTENT
diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php
index 0f94dc2..e8119b2 100644
--- a/tests/test_app/src/Application.php
+++ b/tests/test_app/src/Application.php
@@ -3,8 +3,10 @@
namespace TestApp;
+use Cake\Core\ContainerInterface;
use Cake\Http\BaseApplication;
use Cake\Http\MiddlewareQueue;
+use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob;
use Cake\Routing\RouteBuilder;
class Application extends BaseApplication
@@ -23,4 +25,10 @@ public function bootstrap(): void
$this->addPlugin('Cake/Queue');
$this->addPlugin('Bake');
}
+
+ public function services(ContainerInterface $container): void
+ {
+ $container->add(TestService::class);
+ $container->add(LogToDebugWithServiceJob::class)->addArgument(TestService::class);
+ }
}
diff --git a/tests/test_app/src/Job/LogToDebugWithServiceJob.php b/tests/test_app/src/Job/LogToDebugWithServiceJob.php
new file mode 100644
index 0000000..a71b324
--- /dev/null
+++ b/tests/test_app/src/Job/LogToDebugWithServiceJob.php
@@ -0,0 +1,30 @@
+testService = $testService;
+ }
+
+ public function execute(Message $message): ?string
+ {
+ Log::debug('Debug job was run ' . $this->testService->info());
+
+ return Processor::ACK;
+ }
+}
diff --git a/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php b/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php
index 7aa60fc..030dea8 100644
--- a/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php
+++ b/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php
@@ -8,6 +8,7 @@
use Cake\Queue\Job\JobInterface;
use Cake\Queue\Job\Message;
use Interop\Queue\Processor;
+use RuntimeException;
class MaxAttemptsIsThreeJob implements JobInterface
{
@@ -23,7 +24,7 @@ public function execute(Message $message): ?string
if (!$succeedAt || $succeedAt > $attemptNumber) {
Log::debug('MaxAttemptsIsThreeJob is requeueing');
- return Processor::REQUEUE;
+ throw new RuntimeException('example MaxAttemptsIsThreeJob exception message');
}
Log::debug('MaxAttemptsIsThreeJob was run');
diff --git a/tests/test_app/src/Job/UniqueJob.php b/tests/test_app/src/Job/UniqueJob.php
new file mode 100644
index 0000000..830f277
--- /dev/null
+++ b/tests/test_app/src/Job/UniqueJob.php
@@ -0,0 +1,21 @@
+logger = $logger ?: new NullLogger();
+ $this->container = $container;
+ }
+
+ /**
+ * Process a message from the queue
+ *
+ * @param \Interop\Queue\Message $message The queue message
+ * @param \Interop\Queue\Context $context The queue context
+ * @return string|object Processing result
+ */
+ public function process(QueueMessage $message, Context $context): string|object
+ {
+ $this->logger->debug('TestCustomProcessor processing message');
+
+ $jobMessage = new Message($message, $context, $this->container);
+ try {
+ $jobMessage->getCallable();
+ } catch (RuntimeException | Error $e) {
+ $this->logger->debug('Invalid callable for message. Rejecting message from queue.');
+ $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]);
+
+ return InteropProcessor::REJECT;
+ }
+
+ $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]);
+
+ try {
+ $response = $this->processMessage($jobMessage);
+ } catch (Throwable $throwable) {
+ $message->setProperty('jobException', $throwable);
+
+ $this->logger->debug(sprintf('Message encountered exception: %s', $throwable->getMessage()));
+ $this->dispatchEvent('Processor.message.exception', [
+ 'message' => $jobMessage,
+ 'exception' => $throwable,
+ ]);
+
+ return Result::requeue('Exception occurred while processing message');
+ }
+
+ if ($response === InteropProcessor::ACK) {
+ $this->logger->debug('Message processed successfully by TestCustomProcessor');
+ $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]);
+
+ return InteropProcessor::ACK;
+ }
+
+ if ($response === InteropProcessor::REJECT) {
+ $this->logger->debug('Message processed with rejection by TestCustomProcessor');
+ $this->dispatchEvent('Processor.message.reject', ['message' => $jobMessage]);
+
+ return InteropProcessor::REJECT;
+ }
+
+ $this->logger->debug('Message processed with failure, requeuing by TestCustomProcessor');
+ $this->dispatchEvent('Processor.message.failure', ['message' => $jobMessage]);
+
+ return InteropProcessor::REQUEUE;
+ }
+
+ /**
+ * Process the job message and return the result
+ *
+ * @param \Cake\Queue\Job\Message $message The job message
+ * @return string|object Processing result
+ */
+ public function processMessage(Message $message): string|object
+ {
+ $callable = $message->getCallable();
+ $response = $callable($message);
+ if ($response === null) {
+ $response = InteropProcessor::ACK;
+ }
+
+ return $response;
+ }
+}
diff --git a/tests/test_app/src/TestProcessor.php b/tests/test_app/src/TestProcessor.php
new file mode 100644
index 0000000..39a9cbf
--- /dev/null
+++ b/tests/test_app/src/TestProcessor.php
@@ -0,0 +1,90 @@
+