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 -![Build Status](https://github.com/cakephp/queue/actions/workflows/ci.yml/badge.svg?branch=master) +[![CI](https://github.com/cakephp/queue/actions/workflows/ci.yml/badge.svg)](https://github.com/cakephp/queue/actions/workflows/ci.yml) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.txt) [![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/queue/master.svg?style=flat-square)](https://codecov.io/github/cakephp/queue?branch=master) +[![PHPStan Level 8](https://img.shields.io/badge/PHPStan-level%208-brightgreen)](https://github.com/cakephp/queue) [![Total Downloads](https://img.shields.io/packagist/dt/cakephp/queue.svg?style=flat-square)](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 @@ +