From 96e892f73fc384277f70d6473a667313870edb73 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 12:57:18 +0000 Subject: [PATCH 1/7] Update specleft with MCP and v0.3.0 --- .mcp.json | 9 +++++++++ uv.lock | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..50415ce --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "specleft": { + "command": "uvx", + "args": ["specleft", "mcp"], + "type": "stdio" + } + } +} diff --git a/uv.lock b/uv.lock index 4c2bafe..80e2a0d 100644 --- a/uv.lock +++ b/uv.lock @@ -385,7 +385,7 @@ wheels = [ [[package]] name = "specleft" -version = "0.2.2" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -397,9 +397,9 @@ dependencies = [ { name = "python-slugify" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/e5/ee48a0fed626c6882592689a91cdb46e22d27316b61e4f9c5f3f75615ae0/specleft-0.2.2.tar.gz", hash = "sha256:190150df8e7e100d647fdf5b718ef9a265759b9204583f83a81fdd89bf1b2f97", size = 81258, upload-time = "2026-02-11T00:57:36.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/c0/c59f9e2c3a565c0c5a1c29ce799d7f129922f00c4049343bb922d8b88747/specleft-0.3.0.tar.gz", hash = "sha256:b18420265268f7a4ad168930c098c9c338c7735f754f7ae718895a88d005be83", size = 91902, upload-time = "2026-02-22T00:09:34.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/09/feffe27b919a4cf6ba3db6d9aff172ec60ba7fbbbec951a4506ac43d845b/specleft-0.2.2-py3-none-any.whl", hash = "sha256:08332ea911b7b6603b9687ba18ea43a75a9bdc8dacf3599b813bc573b7178226", size = 102516, upload-time = "2026-02-11T00:57:33.845Z" }, + { url = "https://files.pythonhosted.org/packages/86/21/221f3d133bddd901cc907706ae3a9af5f9da96d4f9a627fedc709a27b4c0/specleft-0.3.0-py3-none-any.whl", hash = "sha256:e49282299e204eb440e81c757470278457dbde82bb4ba9ca3fd141277a2d0987", size = 119426, upload-time = "2026-02-22T00:09:33.125Z" }, ] [[package]] From 6cd440ff67a90113c71498e00c73464c5f4646ab Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 14:40:03 +0000 Subject: [PATCH 2/7] Add retro --- .python-version | 1 - PROMPT.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) delete mode 100644 .python-version create mode 100644 PROMPT.md diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..8766368 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,49 @@ +# Implementation Prompt: Notification Rules Engine + +## Context + +You are implementing a REST API project from a PRD. The SpecLeft MCP is installed and available. Use it to drive the full workflow: generate a behavioural spec, produce tests from that spec, implement the code to pass those tests, and raise a pull request. + +Do not ask clarifying questions. All requirements are defined in the PRD. Make reasonable engineering decisions where the PRD is silent. + +--- + +## Prerequisites + +- Python 3.12 environment available +- Git repository initialised with a `main` branch +- GitHub CLI (`gh`) available and authenticated +- Use SpecLeft MCP if the MCP config is setup in the project, otherwise follow your most suitable implementation workflow for this project that is not SpecLeft. +- If SpecLeft MCP is there - you must follow it's resources and CLI workflow pattern. It is not complex. + +--- + +## Resources + +**Product Requirements Doc**: PRD.md + +**Skill**: SKILL.md + +--- + +## Instructions + +1. Derive a behavioural spec from the PRD +2. Produce tests from the spec before writing any implementation +3. Implement the API to pass all tests +4. Ensure all tests pass locally before proceeding +5. Commit the implementation to a new branch named `feat/notification-rules-engine` +6. Raise a pull request against `main` with a clear description of what was built and why + +Do not modify the tests to make them pass. Fix the implementation instead. + +### Retrospective + + 1. Run server and verify behaviour in ../prd. + 2. Once behaviour is confirmed as working - briefly summarise retrospectively on how the implementation went for this project: +- How many failed test runs before all tests pass +- Token usage for phases: spec externalisation, implementation, testing, behaviour verification +- What went well +- what was missed or inefficient +- what to improve and what can be done to help achieve improvements + From 288db2f4ae9dcc2d3af5d1ee94077d88d6b94544 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 14:52:55 +0000 Subject: [PATCH 3/7] Add retro PR summary --- PROMPT.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PROMPT.md b/PROMPT.md index 8766368..a9fb859 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -40,10 +40,12 @@ Do not modify the tests to make them pass. Fix the implementation instead. ### Retrospective 1. Run server and verify behaviour in ../prd. + 2. Confirm all behaviour from prd are covered (if using specleft mcp, run: `specleft status`) 2. Once behaviour is confirmed as working - briefly summarise retrospectively on how the implementation went for this project: - How many failed test runs before all tests pass -- Token usage for phases: spec externalisation, implementation, testing, behaviour verification +- Time spent on phases: spec externalisation, implementation, testing, behaviour verification +- Clarity of project scope on each phase (letter grade scoring): spec externalisation, implementation, testing, behaviour verification - What went well - what was missed or inefficient - what to improve and what can be done to help achieve improvements - +3. Publish this retro in to the comments of the created PR From 0af9fde560ca87d490010ac893ef8f817f11bf4f Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 15:08:49 +0000 Subject: [PATCH 4/7] Clear slate. --- .specleft/.gitkeep | 0 .specleft/policies/.gitkeep | 0 .specleft/specs/example-feature.md | 35 --- .specleft/templates/prd-template.yml | 36 --- pyproject.toml | 4 +- uv.lock | 444 --------------------------- 6 files changed, 1 insertion(+), 518 deletions(-) delete mode 100644 .specleft/.gitkeep delete mode 100644 .specleft/policies/.gitkeep delete mode 100644 .specleft/specs/example-feature.md delete mode 100644 .specleft/templates/prd-template.yml delete mode 100644 uv.lock diff --git a/.specleft/.gitkeep b/.specleft/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.specleft/policies/.gitkeep b/.specleft/policies/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.specleft/specs/example-feature.md b/.specleft/specs/example-feature.md deleted file mode 100644 index 995226b..0000000 --- a/.specleft/specs/example-feature.md +++ /dev/null @@ -1,35 +0,0 @@ -# Feature: Example Feature - -## Scenarios - -### Scenario: User logs in successfully -priority: high - -- Given a registered user with email "user@example.com" -- When the user submits valid credentials -- Then the user is redirected to the dashboard -- And the user sees a welcome message - -### Scenario: Invalid password rejected -priority: medium - -- Given a registered user with email "user@example.com" -- When the user submits an incorrect password -- Then an error message "Invalid credentials" is displayed -- And the user remains on the login page - ---- -confidence: low -source: example -assumptions: - - email/password authentication - - session-based login -open_questions: - - password complexity requirements? - - maximum login attempts before lockout? -tags: - - auth - - example -owner: dev-team -component: identity ---- diff --git a/.specleft/templates/prd-template.yml b/.specleft/templates/prd-template.yml deleted file mode 100644 index 2738de1..0000000 --- a/.specleft/templates/prd-template.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: "1.0" - -features: - heading_level: 2 - patterns: - - "Feature: {title}" - - "Feature {title}" - - "{title}" - contains: [] - match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only - exclude: - - "Overview" - - "Goals" - - "Non-Goals" - - "Open Questions" - - "Notes" - -scenarios: - heading_level: [3, 4] - patterns: - - "Scenario: {title}" - - "{title}" - contains: [] - match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only - step_keywords: - - "Given" - - "When" - - "Then" - - "And" - - "But" - -priorities: - patterns: - - "priority: {value}" - - "Priority: {value}" - mapping: {} diff --git a/pyproject.toml b/pyproject.toml index f82f389..55f54b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,4 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" -dependencies = [ - "specleft>=0.2.2", -] +dependencies = [] diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 80e2a0d..0000000 --- a/uv.lock +++ /dev/null @@ -1,444 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "46.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "python-frontmatter" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, -] - -[[package]] -name = "python-slugify" -version = "8.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "text-unidecode" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "specleft" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "cryptography" }, - { name = "jinja2" }, - { name = "pydantic" }, - { name = "pytest" }, - { name = "python-frontmatter" }, - { name = "python-slugify" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/c0/c59f9e2c3a565c0c5a1c29ce799d7f129922f00c4049343bb922d8b88747/specleft-0.3.0.tar.gz", hash = "sha256:b18420265268f7a4ad168930c098c9c338c7735f754f7ae718895a88d005be83", size = 91902, upload-time = "2026-02-22T00:09:34.55Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/21/221f3d133bddd901cc907706ae3a9af5f9da96d4f9a627fedc709a27b4c0/specleft-0.3.0-py3-none-any.whl", hash = "sha256:e49282299e204eb440e81c757470278457dbde82bb4ba9ca3fd141277a2d0987", size = 119426, upload-time = "2026-02-22T00:09:33.125Z" }, -] - -[[package]] -name = "specleft-sandbox" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "specleft" }, -] - -[package.metadata] -requires-dist = [{ name = "specleft", specifier = ">=0.2.2" }] - -[[package]] -name = "text-unidecode" -version = "1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] From df6d21afecb8781e481f98b170119ee9e657a472 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 15:08:54 +0000 Subject: [PATCH 5/7] Update docs --- PRD.md | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ PROMPT.md | 1 + 2 files changed, 214 insertions(+) create mode 100644 PRD.md diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..5203680 --- /dev/null +++ b/PRD.md @@ -0,0 +1,213 @@ +# PRD: Notification Rules Engine API + +**Version:** 1.0 +**Stack:** FastAPI · Python 3.12 · SQLite (via SQLAlchemy) · Pytest + +--- + +## Overview + +A REST API that allows users to define conditional notification rules. When an event is published, the engine evaluates all active rules against the event payload and dispatches notifications to each rule's configured channel(s). + +## Project Goals + +- All features have sufficient test coverage proving functionality +- Edge cases and error handling are explicitly covered (invalid inputs, missing data, failure states) +- Implementation is clean, readable, and follows established design principles + +--- + +## API Surface + +``` +POST /rules +GET /rules +GET /rules/{id} +PATCH /rules/{id} +DELETE /rules/{id} + +POST /events + +GET /dispatch-records?rule_id={id} +``` + +--- + +## Features + +### F1 — Rule Management (CRUD) + +A **Rule** has: + +| Field | Type | Notes | +|---|---|---| +| `id` | uuid | Auto-generated | +| `name` | string | Unique | +| `is_active` | bool | Default `true` | +| `event_type` | string | The event kind this rule listens for | +| `conditions` | list[Condition] | See F2 | +| `channels` | list[Channel] | See F3 | + +**Behaviours:** +- Creating a rule with a duplicate `name` returns `409 Conflict` +- A rule must have at least one condition and one channel; violation returns `422` +- Deleting a rule cascades to its conditions and channels + +--- + +### F2 — Conditions + +Each condition is a predicate on the event payload. A rule fires only when **all** conditions pass. + +A **Condition** has: + +| Field | Type | Notes | +|---|---|---| +| `field` | string | Dot-notation path into payload, e.g. `user.role` | +| `operator` | enum | `eq`, `neq`, `gt`, `lt`, `contains` | +| `value` | string | Coerced to field type at evaluation time | + +**Behaviours:** +- An unknown `operator` returns `422` on rule creation +- A `field` path that resolves to nothing evaluates as `false` (no error) +- `gt` / `lt` on non-numeric fields evaluate as `false` + +--- + +### F3 — Channels + +Each channel defines a dispatch target when its parent rule fires. + +Supported types: `webhook`, `email`, `log` + +A **Channel** has: + +| Field | Type | Notes | +|---|---|---| +| `type` | enum | `webhook` \| `email` \| `log` | +| `config` | dict | Type-specific (see below) | + +Config requirements: +- `webhook`: must include a valid `url` +- `email`: must include a valid `to` address +- `log`: no config required (no-op, useful for testing) + +**Behaviours:** +- Invalid or missing config for `webhook`/`email` returns `422` on rule creation +- Dispatch is fire-and-forget; a failure on one channel does not block others +- Each dispatch attempt produces a **DispatchRecord** (see F5) + +--- + +### F4 — Event Publishing + +`POST /events` triggers rule evaluation against active rules. + +An **Event** has: + +| Field | Type | Notes | +|---|---|---| +| `type` | string | Matched against `Rule.event_type` | +| `payload` | object | Arbitrary JSON | + +**Behaviours:** +- Only rules where `is_active = true` and `event_type` matches are evaluated +- Rules are evaluated concurrently +- A rule with no conditions always fires (vacuous truth) +- Returns `202 Accepted` with the list of triggered rule names; does not wait for dispatch to complete + +--- + +### F5 — Dispatch Records + +Every channel dispatch attempt is recorded. + +A **DispatchRecord** has: + +| Field | Type | Notes | +|---|---|---| +| `id` | uuid | Auto-generated | +| `rule_id` | uuid | Foreign key | +| `channel_type` | string | | +| `status` | enum | `sent` \| `failed` | +| `error_message` | string \| null | Populated on failure | +| `dispatched_at` | datetime | | + +**Behaviours:** +- `GET /dispatch-records?rule_id={id}` returns all records for a rule ordered by `dispatched_at` descending +- Records are immutable once written + +--- + +## Out of Scope (v1) + +- Authentication / multi-tenancy +- Real email or webhook delivery (stub/mock in tests) +- Retry logic +- OR logic across conditions +- Pagination + +--- + +## Acceptance Scenarios (Gherkin) + +```gherkin +Feature: Rule lifecycle + + Scenario: Create a valid rule + Given a rule payload with one condition and one log channel + When I POST to /rules + Then I receive 201 with the created rule and its id + + Scenario: Reject duplicate rule name + Given an existing rule named "alert-on-signup" + When I POST a new rule with name "alert-on-signup" + Then I receive 409 Conflict + + Scenario: Rule creation rejected without a channel + Given a rule payload with one condition and no channels + When I POST to /rules + Then I receive 422 + + Scenario: Delete cascades to conditions and channels + Given an existing rule with two conditions and one channel + When I DELETE the rule + Then the conditions and channel are also removed + +Feature: Event evaluation + + Scenario: Rule fires when all conditions match + Given a rule with event_type "user.created" and condition user.role eq "admin" + When I POST an event {"type": "user.created", "payload": {"user": {"role": "admin"}}} + Then the rule is triggered and a DispatchRecord with status "sent" is created + + Scenario: Rule does not fire when a condition fails + Given the same rule + When I POST an event with payload {"user": {"role": "member"}} + Then no DispatchRecord is created for that rule + + Scenario: Inactive rule is skipped + Given a deactivated rule matching the event type + When I POST a matching event + Then the rule is not triggered + + Scenario: Event response is non-blocking + Given a rule with a slow webhook channel + When I POST a matching event + Then the response returns 202 before the webhook completes + +Feature: Channel dispatch + + Scenario: One channel failure does not block others + Given a rule with a failing webhook channel and a log channel + When the rule fires + Then the log channel dispatches successfully + And a DispatchRecord with status "failed" exists for the webhook + +Feature: Dispatch records + + Scenario: Records returned newest first + Given two events that triggered the same rule at different times + When I GET /dispatch-records?rule_id={id} + Then the more recent record appears first +``` \ No newline at end of file diff --git a/PROMPT.md b/PROMPT.md index a9fb859..b4ecb65 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -15,6 +15,7 @@ Do not ask clarifying questions. All requirements are defined in the PRD. Make r - GitHub CLI (`gh`) available and authenticated - Use SpecLeft MCP if the MCP config is setup in the project, otherwise follow your most suitable implementation workflow for this project that is not SpecLeft. - If SpecLeft MCP is there - you must follow it's resources and CLI workflow pattern. It is not complex. +- Update specleft prd-template.yml to exclude non feature headings from the spec generation. --- From 44ff9a1084a1e847175d80b0009fab15be4ad15e Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 15:10:53 +0000 Subject: [PATCH 6/7] Update skill --- SKILL.md | 164 +++++++++++++++++++++++++++---------------------------- 1 file changed, 81 insertions(+), 83 deletions(-) diff --git a/SKILL.md b/SKILL.md index 452f91f..7e25a26 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,111 +1,109 @@ -# SpecLeft CLI Reference - -## Setup -`export SPECLEFT_COMPACT=1` -All commands below run in compact mode. - -## Workflow -1. specleft next --limit 1 -2. Implement test logic -3. specleft features validate -4. specleft skill verify -5. pytest -6. Repeat - -## Quick checks -- Validation: check exit code first, parse JSON only on failure -- Coverage: `specleft coverage --threshold 100` and check exit code -- Status: `specleft status` for progress snapshots - -## Safety -- Always `--dry-run` before writing files -- Never use `--force` unless explicitly requested -- Exit codes: 0 = success, 1 = error, 2 = cancelled -- Commands are deterministic and safe to retry +--- +name: python-dev-best-practices +description: Apply Python and software engineering best practices when implementing a project. Use this skill whenever you are writing, structuring, or reviewing Python code — especially for REST APIs, backend services, or multi-feature projects. Trigger on any Python implementation task involving multiple components, layers, or features where design quality, testability, and maintainability matter. +--- + +# Python Software Development Best Practices + +Apply the following principles whenever implementing a Python project. --- -## Features +## Project Structure -### Validate specs -`specleft features validate --format json [--dir PATH] [--strict]` -Validate before generating tests. `--strict` treats warnings as errors. +Organise code by responsibility, not by type. A flat `models.py` / `routes.py` / `utils.py` structure becomes unmaintainable. Prefer: -### List features -`specleft features list --format json [--dir PATH]` +``` +src/ + / + models.py # Data shapes + repository.py # Persistence logic + service.py # Business logic + router.py # HTTP layer (if FastAPI/Django) + schemas.py # Request/response validation +tests/ + / + test_.py +``` -### Show stats -`specleft features stats --format json [--dir PATH] [--tests-dir PATH]` +One module per responsibility. If a file is doing more than one thing, split it. -### Add a feature -`specleft features add --format json --id FEATURE_ID --title "Title" [--priority PRIORITY] [--description TEXT] [--dir PATH] [--dry-run]` -Creates `/feature-id.md`. Never overwrites existing files. -Use `--interactive` for guided prompts (TTY only). +--- -### Add a scenario -`specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority PRIORITY] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test MODE] [--preview-test]` -Appends to feature file. `--add-test` generates a test file. -`--preview-test` shows test content without writing. Use `--interactive` -for guided prompts (TTY only). +## SOLID Principles -## Status and Planning +**Single Responsibility** — Each class or function has one reason to change. A route handler should not contain business logic or query construction. -### Show status -`specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` +**Open/Closed** — Extend behaviour without modifying existing code. Use abstract base classes or protocols for extensible components (e.g. dispatch channels, condition operators). Adding a new variant should require adding a new class, not editing a switch statement. -### Next scenario to implement -`specleft next --format json [--dir PATH] [--limit N] [--priority PRIORITY] [--feature ID] [--story ID]` +**Liskov Substitution** — Subtypes must be substitutable for their base types. Avoid overriding methods in ways that change their contract. -### Coverage metrics -`specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` -`--threshold N` exits non-zero if coverage drops below `N%`. +**Interface Segregation** — Depend on narrow interfaces. A service that only needs to read data should not depend on a full read/write repository. -## Test Generation +**Dependency Inversion** — High-level modules depend on abstractions, not concrete implementations. Inject dependencies; do not instantiate them inside functions. -### Generate skeleton tests -`specleft test skeleton --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` -Always run `--dry-run` first. Never overwrite without `--force`. +--- -### Generate stub tests -`specleft test stub --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` -Minimal test scaffolding with the same overwrite safety rules. +## DRY -### Generate test report -`specleft test report --format json [-r RESULTS_FILE] [-o OUTPUT_PATH] [--open-browser]` -Builds an HTML report from `.specleft/results/`. +- Extract repeated logic into named functions or classes immediately — do not wait until the third occurrence +- Shared validation belongs in one place (Pydantic validators, not scattered conditionals) +- Query patterns belong in a repository layer, not repeated across service methods -## Planning +--- -### Generate specs from PRD -`specleft plan --format json [--from PATH] [--dry-run] [--analyze] [--template PATH]` -`--analyze` inspects PRD structure without writing files. -`--template` uses a YAML section-matching template. +## Design Patterns to Apply -## Contract +**Strategy** — For swappable behaviour (e.g. different channel dispatch types, different condition operators). Each variant implements a common interface; a registry or factory selects the right one. -### Show contract -`specleft contract --format json` +**Repository** — Abstract all persistence behind a class with explicit methods (`get`, `create`, `delete`, etc.). Services call the repository; they never construct queries directly. -### Verify contract -`specleft contract test --format json [--verbose]` -Run to verify deterministic and safe command guarantees. +**Factory / Registry** — Use a dict-based registry to map string identifiers to classes. Avoids `if/elif` chains that violate Open/Closed. +```python +CHANNEL_REGISTRY: dict[str, type[BaseChannel]] = { + "webhook": WebhookChannel, + "email": EmailChannel, + "log": LogChannel, +} +``` -## Enforcement +--- -### Enforce policy -`specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]` -Default policy: `.specleft/policies/policy.yml`. -Exit codes: 0 = satisfied, 1 = violated, 2 = license issue. +## Testing -## License +- Write tests before or alongside implementation, not after +- Test behaviour, not implementation — assert on outcomes, not internal state +- One test file per feature domain, mirroring the source structure +- Use `pytest` fixtures for shared setup; avoid repetition in test bodies +- Mock at the boundary (I/O, HTTP, external services) — not deep inside business logic +- Each scenario in the spec maps to at least one test; edge cases and failure paths get their own tests -### License status -`specleft license status [--file PATH]` -Show license status and validated policy metadata. -Default: `.specleft/policies/policy.yml`. +--- + +## FastAPI Specifics + +- Define Pydantic schemas for all request and response bodies — never use raw dicts +- Keep routers thin: validate input, call a service, return output +- Use dependency injection (`Depends`) for database sessions, services, and auth +- Use `async def` for route handlers; use background tasks (`BackgroundTasks`) for fire-and-forget work +- Return appropriate HTTP status codes — do not default everything to `200` + +--- + +## Code Readability + +- Functions should fit on one screen; if they don't, break them up +- Name things after what they are, not how they work (`dispatch_to_channel`, not `do_thing`) +- Avoid comments that describe what the code does — write code that is self-describing +- Use type hints throughout; do not use `Any` unless genuinely unavoidable +- Prefer explicit over implicit — a reader should not need to trace three files to understand what a function does + +--- -## Guide +## Error Handling -### Show workflow guide -`specleft guide --format json` \ No newline at end of file +- Raise domain-specific exceptions from service and repository layers +- Catch and translate to HTTP errors at the router layer only +- Never swallow exceptions silently — log or re-raise +- Validate inputs at the boundary (Pydantic schemas); do not validate the same thing twice deeper in the stack \ No newline at end of file From 4762332749420006d235943819eaf4a4431898bf Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 24 Feb 2026 15:35:40 +0000 Subject: [PATCH 7/7] Implement notification rules engine API --- .specleft/.gitkeep | 0 .specleft/SKILL.md | 127 ++ .specleft/SKILL.md.sha256 | 1 + .specleft/policies/.gitkeep | 0 .../results/results_20260224_153304.json | 1116 +++++++++++++++++ .../results/results_20260224_153429.json | 1116 +++++++++++++++++ .../results/results_20260224_153445.json | 1116 +++++++++++++++++ .../results/results_20260224_153457.json | 1116 +++++++++++++++++ .specleft/specs/f1-rule-management-crud.md | 59 + .specleft/specs/f2-conditions.md | 24 + .specleft/specs/f3-channels.md | 25 + .specleft/specs/f4-event-publishing.md | 38 + .specleft/specs/f5-dispatch-records.md | 10 + .specleft/templates/prd-template.yml | 48 + main.py | 10 +- pyproject.toml | 17 +- src/app/__init__.py | 1 + src/app/database.py | 15 + src/app/main.py | 173 +++ src/app/models.py | 86 ++ src/app/repository.py | 117 ++ src/app/schemas.py | 108 ++ src/app/services.py | 167 +++ tests/conftest.py | 96 ++ tests/test_f1-rule-management-crud.py | 250 ++++ tests/test_f2-conditions.py | 107 ++ tests/test_f3-channels.py | 106 ++ tests/test_f4-event-publishing.py | 187 +++ tests/test_f5-dispatch-records.py | 55 + 29 files changed, 6287 insertions(+), 4 deletions(-) create mode 100644 .specleft/.gitkeep create mode 100644 .specleft/SKILL.md create mode 100644 .specleft/SKILL.md.sha256 create mode 100644 .specleft/policies/.gitkeep create mode 100644 .specleft/results/results_20260224_153304.json create mode 100644 .specleft/results/results_20260224_153429.json create mode 100644 .specleft/results/results_20260224_153445.json create mode 100644 .specleft/results/results_20260224_153457.json create mode 100644 .specleft/specs/f1-rule-management-crud.md create mode 100644 .specleft/specs/f2-conditions.md create mode 100644 .specleft/specs/f3-channels.md create mode 100644 .specleft/specs/f4-event-publishing.md create mode 100644 .specleft/specs/f5-dispatch-records.md create mode 100644 .specleft/templates/prd-template.yml create mode 100644 src/app/__init__.py create mode 100644 src/app/database.py create mode 100644 src/app/main.py create mode 100644 src/app/models.py create mode 100644 src/app/repository.py create mode 100644 src/app/schemas.py create mode 100644 src/app/services.py create mode 100644 tests/conftest.py create mode 100644 tests/test_f1-rule-management-crud.py create mode 100644 tests/test_f2-conditions.py create mode 100644 tests/test_f3-channels.py create mode 100644 tests/test_f4-event-publishing.py create mode 100644 tests/test_f5-dispatch-records.py diff --git a/.specleft/.gitkeep b/.specleft/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specleft/SKILL.md b/.specleft/SKILL.md new file mode 100644 index 0000000..43f7f8e --- /dev/null +++ b/.specleft/SKILL.md @@ -0,0 +1,127 @@ +# SpecLeft CLI Reference + +## Setup +`export SPECLEFT_COMPACT=1` +All commands below run in compact mode. + +## Workflow +1. specleft next --limit 1 +2. Implement test logic +3. specleft features validate +4. specleft skill verify +5. pytest +6. Repeat + +## Quick checks +- Validation: check exit code first, parse JSON only on failure +- Coverage: `specleft coverage --threshold 100` and check exit code +- Status: `specleft status` for progress snapshots + +## Safety +- Always `--dry-run` before writing files +- All `--id` values must be kebab-case alphanumeric (`a-z`, `0-9`, hyphens) +- All text inputs reject shell metacharacters (`$`, `` ` ``, `|`, `;`, `&`, etc.) +- Never pass unsanitised user input directly as CLI arguments +- All commands are single invocations - no pipes, chaining, or redirects +- Exit codes: 0 = success, 1 = error, 2 = cancelled +- Commands are deterministic and safe to retry + +--- + +## Features + +### Validate specs +`specleft features validate --format json [--dir PATH] [--strict]` +Validate before generating tests. `--strict` treats warnings as errors. + +### List features +`specleft features list --format json [--dir PATH]` + +### Show stats +`specleft features stats --format json [--dir PATH] [--tests-dir PATH]` + +### Add a feature +`specleft features add --format json --id FEATURE_ID --title "Title" [--priority PRIORITY] [--description TEXT] [--dir PATH] [--dry-run]` +Creates `/feature-id.md`. Never overwrites existing files. +Use `--interactive` for guided prompts (TTY only). + +### Add a scenario +`specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority PRIORITY] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test MODE] [--preview-test]` +Appends to feature file. `--add-test` generates a test file. +`--preview-test` shows test content without writing. Use `--interactive` +for guided prompts (TTY only). + +## Status and Planning + +### Show status +`specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` + +### Next scenario to implement +`specleft next --format json [--dir PATH] [--limit N] [--priority PRIORITY] [--feature ID] [--story ID]` + +### Coverage metrics +`specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` +`--threshold N` exits non-zero if coverage drops below `N%`. + +## Test Generation + +### Generate skeleton tests +`specleft test skeleton --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` +Always run `--dry-run` first. Never overwrite without `--force`. + +### Generate stub tests +`specleft test stub --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` +Minimal test scaffolding with the same overwrite safety rules. + +### Generate test report +`specleft test report --format json [-r RESULTS_FILE] [-o OUTPUT_PATH] [--open-browser]` +Builds an HTML report from `.specleft/results/`. + +## Planning + +### Generate specs from PRD +`specleft plan --format json [--from PATH] [--dry-run] [--analyze] [--template PATH]` +`--analyze` inspects PRD structure without writing files. +`--template` uses a YAML section-matching template. + +## Contract + +### Show contract +`specleft contract --format json` + +### Verify contract +`specleft contract test --format json [--verbose]` +Run to verify deterministic and safe command guarantees. + +## Skill Security + +### Verify skill integrity +`specleft skill verify --format json` +Returns `pass`, `modified`, or `outdated` integrity status. + +### Update skill files +`specleft skill update --format json` +Regenerates `.specleft/SKILL.md` and `.specleft/SKILL.md.sha256`. + +### Verify within doctor checks +`specleft doctor --verify-skill --format json` +Adds skill integrity status to standard environment diagnostics. + +## Enforcement + +### Enforce policy +`specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]` +Default policy: `.specleft/policies/policy.yml`. +Exit codes: 0 = satisfied, 1 = violated, 2 = license issue. + +## License + +### License status +`specleft license status [--file PATH]` +Show license status and validated policy metadata. +Default: `.specleft/policies/policy.yml`. + +## Guide + +### Show workflow guide +`specleft guide --format json` diff --git a/.specleft/SKILL.md.sha256 b/.specleft/SKILL.md.sha256 new file mode 100644 index 0000000..f631bae --- /dev/null +++ b/.specleft/SKILL.md.sha256 @@ -0,0 +1 @@ +917e0690df74eaf9eec2e35a7cc20e00e3ed190293aa7b070cc0a2fad7f6f1e5 diff --git a/.specleft/policies/.gitkeep b/.specleft/policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.specleft/results/results_20260224_153304.json b/.specleft/results/results_20260224_153304.json new file mode 100644 index 0000000..c69d31f --- /dev/null +++ b/.specleft/results/results_20260224_153304.json @@ -0,0 +1,1116 @@ +{ + "run_id": "2026-02-24T15:33:04.828722", + "summary": { + "total_features": 5, + "total_scenarios": 20, + "total_executions": 20, + "passed": 20, + "failed": 0, + "skipped": 0, + "duration": 1.965 + }, + "features": [ + { + "feature_id": "f1-rule-management-crud", + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "create-a-valid-rule", + "scenario_name": "Create a valid rule", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "create-a-valid-rule", + "test_name": "test_create_a_valid_rule", + "original_name": "test_create_a_valid_rule", + "nodeid": "tests/test_f1-rule-management-crud.py::test_create_a_valid_rule", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Create a valid rule", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.010707708017434925, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and one log channel", + "status": "passed", + "duration": 7e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.01048, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 201 with the created rule and its id", + "status": "passed", + "duration": 1.3e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "delete-cascades-to-conditions-and-channels", + "scenario_name": "Delete cascades to conditions and channels", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "delete-cascades-to-conditions-and-channels", + "test_name": "test_delete_cascades_to_conditions_and_channels", + "original_name": "test_delete_cascades_to_conditions_and_channels", + "nodeid": "tests/test_f1-rule-management-crud.py::test_delete_cascades_to_conditions_and_channels", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Delete cascades to conditions and channels", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.008914583013392985, + "error": null, + "steps": [ + { + "description": "Given an existing rule with two conditions and one channel", + "status": "passed", + "duration": 0.005001, + "error": null, + "skipped_reason": null + }, + { + "description": "When I DELETE the rule", + "status": "passed", + "duration": 0.003366, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the conditions and channel are also removed", + "status": "passed", + "duration": 0.000365, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "get-rule-by-id", + "scenario_name": "Get rule by id", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "get-rule-by-id", + "test_name": "test_get_rule_by_id", + "original_name": "test_get_rule_by_id", + "nodeid": "tests/test_f1-rule-management-crud.py::test_get_rule_by_id", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Get rule by id", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.006381834042258561, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.003828, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules/", + "status": "passed", + "duration": 0.002367, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the rule", + "status": "passed", + "duration": 8e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "list-rules", + "scenario_name": "List rules", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "list-rules", + "test_name": "test_list_rules", + "original_name": "test_list_rules", + "nodeid": "tests/test_f1-rule-management-crud.py::test_list_rules", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "List rules", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.010209874948486686, + "error": null, + "steps": [ + { + "description": "Given two existing rules", + "status": "passed", + "duration": 0.008089, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules", + "status": "passed", + "duration": 0.001927, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with both rules", + "status": "passed", + "duration": 1.3e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-duplicate-rule-name", + "scenario_name": "Reject duplicate rule name", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "reject-duplicate-rule-name", + "test_name": "test_reject_duplicate_rule_name", + "original_name": "test_reject_duplicate_rule_name", + "nodeid": "tests/test_f1-rule-management-crud.py::test_reject_duplicate_rule_name", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Reject duplicate rule name", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.005791958014015108, + "error": null, + "steps": [ + { + "description": "Given an existing rule named \"alert-on-signup\"", + "status": "passed", + "duration": 0.004178, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a new rule with name \"alert-on-signup\"", + "status": "passed", + "duration": 0.001426, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 409 Conflict", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-channel", + "scenario_name": "Rule creation rejected without a channel", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-channel", + "test_name": "test_rule_creation_rejected_without_a_channel", + "original_name": "test_rule_creation_rejected_without_a_channel", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_channel", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a channel", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0017479999805800617, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and no channels", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001612, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-condition", + "scenario_name": "Rule creation rejected without a condition", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-condition", + "test_name": "test_rule_creation_rejected_without_a_condition", + "original_name": "test_rule_creation_rejected_without_a_condition", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_condition", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a condition", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0012932500103488564, + "error": null, + "steps": [ + { + "description": "Given a rule payload with no conditions and one log channel", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001131, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 3e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "update-rule-fields", + "scenario_name": "Update rule fields", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "update-rule-fields", + "test_name": "test_update_rule_fields", + "original_name": "test_update_rule_fields", + "nodeid": "tests/test_f1-rule-management-crud.py::test_update_rule_fields", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Update rule fields", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.006788125028833747, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.003709, + "error": null, + "skipped_reason": null + }, + { + "description": "When I PATCH /rules/ with a new name and is_active false", + "status": "passed", + "duration": 0.002827, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the updated rule", + "status": "passed", + "duration": 9e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f2-conditions", + "feature_name": "F2 \u2014 Conditions", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "gt-on-non-numeric-evaluates-false", + "scenario_name": "gt on non-numeric evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "gt-on-non-numeric-evaluates-false", + "test_name": "test_gt_on_non_numeric_evaluates_false", + "original_name": "test_gt_on_non_numeric_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_gt_on_non_numeric_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "gt on non-numeric evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3147784160100855, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" gt \"5\"", + "status": "passed", + "duration": 0.003864, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"admin\"}}", + "status": "passed", + "duration": 0.002278, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.308439, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "missing-field-path-evaluates-false", + "scenario_name": "Missing field path evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "missing-field-path-evaluates-false", + "test_name": "test_missing_field_path_evaluates_false", + "original_name": "test_missing_field_path_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_missing_field_path_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Missing field path evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31651812500786036, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" eq \"admin\"", + "status": "passed", + "duration": 0.00459, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload missing \"user.role\"", + "status": "passed", + "duration": 0.002362, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.309302, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-unknown-operator", + "scenario_name": "Reject unknown operator", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "reject-unknown-operator", + "test_name": "test_reject_unknown_operator", + "original_name": "test_reject_unknown_operator", + "nodeid": "tests/test_f2-conditions.py::test_reject_unknown_operator", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Reject unknown operator", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0016437919693998992, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a condition using operator \"between\"", + "status": "passed", + "duration": 7e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001462, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 3e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f3-channels", + "feature_name": "F3 \u2014 Channels", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "one-channel-failure-does-not-block-others", + "scenario_name": "One channel failure does not block others", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "one-channel-failure-does-not-block-others", + "test_name": "test_one_channel_failure_does_not_block_others", + "original_name": "test_one_channel_failure_does_not_block_others", + "nodeid": "tests/test_f3-channels.py::test_one_channel_failure_does_not_block_others", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "One channel failure does not block others", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3136776669998653, + "error": null, + "steps": [ + { + "description": "Given a rule with a failing webhook channel and a log channel", + "status": "passed", + "duration": 0.004261, + "error": null, + "skipped_reason": null + }, + { + "description": "When the rule fires", + "status": "passed", + "duration": 0.002792, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the log channel dispatches successfully", + "status": "passed", + "duration": 0.306396, + "error": null, + "skipped_reason": null + }, + { + "description": "And a DispatchRecord with status \"failed\" exists for the webhook", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-email-config", + "scenario_name": "Reject invalid email config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-email-config", + "test_name": "test_reject_invalid_email_config", + "original_name": "test_reject_invalid_email_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_email_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid email config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0015870409552007914, + "error": null, + "steps": [ + { + "description": "Given a rule payload with an email channel missing to", + "status": "passed", + "duration": 1e-05, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001427, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 1e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-webhook-config", + "scenario_name": "Reject invalid webhook config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-webhook-config", + "test_name": "test_reject_invalid_webhook_config", + "original_name": "test_reject_invalid_webhook_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_webhook_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid webhook config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0011944579891860485, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a webhook channel missing url", + "status": "passed", + "duration": 6e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001047, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f4-event-publishing", + "feature_name": "F4 \u2014 Event Publishing", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "event-response-is-non-blocking", + "scenario_name": "Event response is non-blocking", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "event-response-is-non-blocking", + "test_name": "test_event_response_is_non_blocking", + "original_name": "test_event_response_is_non_blocking", + "nodeid": "tests/test_f4-event-publishing.py::test_event_response_is_non_blocking", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Event response is non-blocking", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.01031141699058935, + "error": null, + "steps": [ + { + "description": "Given a rule with a slow webhook channel", + "status": "passed", + "duration": 0.007084, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002932, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the response returns 202 before the webhook completes", + "status": "passed", + "duration": 3e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "inactive-rule-is-skipped", + "scenario_name": "Inactive rule is skipped", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "inactive-rule-is-skipped", + "test_name": "test_inactive_rule_is_skipped", + "original_name": "test_inactive_rule_is_skipped", + "nodeid": "tests/test_f4-event-publishing.py::test_inactive_rule_is_skipped", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Inactive rule is skipped", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.30769870802760124, + "error": null, + "steps": [ + { + "description": "Given a deactivated rule matching the event type", + "status": "passed", + "duration": 0.004022, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002101, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.301396, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "only-matching-event-type-rules-evaluated", + "scenario_name": "Only matching event_type rules evaluated", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "only-matching-event-type-rules-evaluated", + "test_name": "test_only_matching_event_type_rules_evaluated", + "original_name": "test_only_matching_event_type_rules_evaluated", + "nodeid": "tests/test_f4-event-publishing.py::test_only_matching_event_type_rules_evaluated", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Only matching event_type rules evaluated", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.30997645802563056, + "error": null, + "steps": [ + { + "description": "Given a rule listening for event_type \"order.created\"", + "status": "passed", + "duration": 0.004099, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with type \"user.created\"", + "status": "passed", + "duration": 0.002374, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.303287, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "scenario_name": "Rule does not fire when a condition fails", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "test_name": "test_rule_does_not_fire_when_a_condition_fails", + "original_name": "test_rule_does_not_fire_when_a_condition_fails", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_does_not_fire_when_a_condition_fails", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule does not fire when a condition fails", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3134750829776749, + "error": null, + "steps": [ + { + "description": "Given the same rule", + "status": "passed", + "duration": 0.004515, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"member\"}}", + "status": "passed", + "duration": 0.002709, + "error": null, + "skipped_reason": null + }, + { + "description": "Then no DispatchRecord is created for that rule", + "status": "passed", + "duration": 0.305921, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-fires-when-all-conditions-match", + "scenario_name": "Rule fires when all conditions match", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-fires-when-all-conditions-match", + "test_name": "test_rule_fires_when_all_conditions_match", + "original_name": "test_rule_fires_when_all_conditions_match", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_fires_when_all_conditions_match", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule fires when all conditions match", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.009658625000156462, + "error": null, + "steps": [ + { + "description": "Given a rule with event_type \"user.created\" and condition user.role eq \"admin\"", + "status": "passed", + "duration": 0.003956, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event {\"type\": \"user.created\", \"payload\": {\"user\": {\"role\": \"admin\"}}}", + "status": "passed", + "duration": 0.003948, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is triggered and a DispatchRecord with status \"sent\" is created", + "status": "passed", + "duration": 0.001579, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f5-dispatch-records", + "feature_name": "F5 \u2014 Dispatch Records", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "records-returned-newest-first", + "scenario_name": "Records returned newest first", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f5-dispatch-records", + "scenario_id": "records-returned-newest-first", + "test_name": "test_records_returned_newest_first", + "original_name": "test_records_returned_newest_first", + "nodeid": "tests/test_f5-dispatch-records.py::test_records_returned_newest_first", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F5 \u2014 Dispatch Records", + "scenario_name": "Records returned newest first", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.012257541005965322, + "error": null, + "steps": [ + { + "description": "Given two events that triggered the same rule at different times", + "status": "passed", + "duration": 0.010344, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /dispatch-records?rule_id=", + "status": "passed", + "duration": 0.001712, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the more recent record appears first", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/.specleft/results/results_20260224_153429.json b/.specleft/results/results_20260224_153429.json new file mode 100644 index 0000000..1d8d4f5 --- /dev/null +++ b/.specleft/results/results_20260224_153429.json @@ -0,0 +1,1116 @@ +{ + "run_id": "2026-02-24T15:34:29.838234", + "summary": { + "total_features": 5, + "total_scenarios": 20, + "total_executions": 20, + "passed": 20, + "failed": 0, + "skipped": 0, + "duration": 1.975 + }, + "features": [ + { + "feature_id": "f1-rule-management-crud", + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "create-a-valid-rule", + "scenario_name": "Create a valid rule", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "create-a-valid-rule", + "test_name": "test_create_a_valid_rule", + "original_name": "test_create_a_valid_rule", + "nodeid": "tests/test_f1-rule-management-crud.py::test_create_a_valid_rule", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Create a valid rule", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.01068866701098159, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and one log channel", + "status": "passed", + "duration": 1.1e-05, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.010442, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 201 with the created rule and its id", + "status": "passed", + "duration": 1.4e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "delete-cascades-to-conditions-and-channels", + "scenario_name": "Delete cascades to conditions and channels", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "delete-cascades-to-conditions-and-channels", + "test_name": "test_delete_cascades_to_conditions_and_channels", + "original_name": "test_delete_cascades_to_conditions_and_channels", + "nodeid": "tests/test_f1-rule-management-crud.py::test_delete_cascades_to_conditions_and_channels", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Delete cascades to conditions and channels", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.008234083012212068, + "error": null, + "steps": [ + { + "description": "Given an existing rule with two conditions and one channel", + "status": "passed", + "duration": 0.00442, + "error": null, + "skipped_reason": null + }, + { + "description": "When I DELETE the rule", + "status": "passed", + "duration": 0.00322, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the conditions and channel are also removed", + "status": "passed", + "duration": 0.000422, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "get-rule-by-id", + "scenario_name": "Get rule by id", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "get-rule-by-id", + "test_name": "test_get_rule_by_id", + "original_name": "test_get_rule_by_id", + "nodeid": "tests/test_f1-rule-management-crud.py::test_get_rule_by_id", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Get rule by id", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.00584425003034994, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.003914, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules/", + "status": "passed", + "duration": 0.00176, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the rule", + "status": "passed", + "duration": 9e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "list-rules", + "scenario_name": "List rules", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "list-rules", + "test_name": "test_list_rules", + "original_name": "test_list_rules", + "nodeid": "tests/test_f1-rule-management-crud.py::test_list_rules", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "List rules", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.008546374971047044, + "error": null, + "steps": [ + { + "description": "Given two existing rules", + "status": "passed", + "duration": 0.006059, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules", + "status": "passed", + "duration": 0.002287, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with both rules", + "status": "passed", + "duration": 1.3e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-duplicate-rule-name", + "scenario_name": "Reject duplicate rule name", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "reject-duplicate-rule-name", + "test_name": "test_reject_duplicate_rule_name", + "original_name": "test_reject_duplicate_rule_name", + "nodeid": "tests/test_f1-rule-management-crud.py::test_reject_duplicate_rule_name", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Reject duplicate rule name", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.006094750016927719, + "error": null, + "steps": [ + { + "description": "Given an existing rule named \"alert-on-signup\"", + "status": "passed", + "duration": 0.004498, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a new rule with name \"alert-on-signup\"", + "status": "passed", + "duration": 0.001425, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 409 Conflict", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-channel", + "scenario_name": "Rule creation rejected without a channel", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-channel", + "test_name": "test_rule_creation_rejected_without_a_channel", + "original_name": "test_rule_creation_rejected_without_a_channel", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_channel", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a channel", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0021979580051265657, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and no channels", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.002041, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-condition", + "scenario_name": "Rule creation rejected without a condition", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-condition", + "test_name": "test_rule_creation_rejected_without_a_condition", + "original_name": "test_rule_creation_rejected_without_a_condition", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_condition", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a condition", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0012019170098938048, + "error": null, + "steps": [ + { + "description": "Given a rule payload with no conditions and one log channel", + "status": "passed", + "duration": 4e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.00109, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "update-rule-fields", + "scenario_name": "Update rule fields", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "update-rule-fields", + "test_name": "test_update_rule_fields", + "original_name": "test_update_rule_fields", + "nodeid": "tests/test_f1-rule-management-crud.py::test_update_rule_fields", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Update rule fields", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.009753584046848118, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.003842, + "error": null, + "skipped_reason": null + }, + { + "description": "When I PATCH /rules/ with a new name and is_active false", + "status": "passed", + "duration": 0.005712, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the updated rule", + "status": "passed", + "duration": 1e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f2-conditions", + "feature_name": "F2 \u2014 Conditions", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "gt-on-non-numeric-evaluates-false", + "scenario_name": "gt on non-numeric evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "gt-on-non-numeric-evaluates-false", + "test_name": "test_gt_on_non_numeric_evaluates_false", + "original_name": "test_gt_on_non_numeric_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_gt_on_non_numeric_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "gt on non-numeric evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.30779395904392004, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" gt \"5\"", + "status": "passed", + "duration": 0.004398, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"admin\"}}", + "status": "passed", + "duration": 0.002355, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.300815, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "missing-field-path-evaluates-false", + "scenario_name": "Missing field path evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "missing-field-path-evaluates-false", + "test_name": "test_missing_field_path_evaluates_false", + "original_name": "test_missing_field_path_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_missing_field_path_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Missing field path evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3108210830250755, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" eq \"admin\"", + "status": "passed", + "duration": 0.004534, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload missing \"user.role\"", + "status": "passed", + "duration": 0.002558, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.303466, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-unknown-operator", + "scenario_name": "Reject unknown operator", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "reject-unknown-operator", + "test_name": "test_reject_unknown_operator", + "original_name": "test_reject_unknown_operator", + "nodeid": "tests/test_f2-conditions.py::test_reject_unknown_operator", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Reject unknown operator", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.001669041987042874, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a condition using operator \"between\"", + "status": "passed", + "duration": 9e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001483, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f3-channels", + "feature_name": "F3 \u2014 Channels", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "one-channel-failure-does-not-block-others", + "scenario_name": "One channel failure does not block others", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "one-channel-failure-does-not-block-others", + "test_name": "test_one_channel_failure_does_not_block_others", + "original_name": "test_one_channel_failure_does_not_block_others", + "nodeid": "tests/test_f3-channels.py::test_one_channel_failure_does_not_block_others", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "One channel failure does not block others", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.32127449999097735, + "error": null, + "steps": [ + { + "description": "Given a rule with a failing webhook channel and a log channel", + "status": "passed", + "duration": 0.004915, + "error": null, + "skipped_reason": null + }, + { + "description": "When the rule fires", + "status": "passed", + "duration": 0.002207, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the log channel dispatches successfully", + "status": "passed", + "duration": 0.31392, + "error": null, + "skipped_reason": null + }, + { + "description": "And a DispatchRecord with status \"failed\" exists for the webhook", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-email-config", + "scenario_name": "Reject invalid email config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-email-config", + "test_name": "test_reject_invalid_email_config", + "original_name": "test_reject_invalid_email_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_email_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid email config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.001325917022768408, + "error": null, + "steps": [ + { + "description": "Given a rule payload with an email channel missing to", + "status": "passed", + "duration": 7e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001187, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 1e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-webhook-config", + "scenario_name": "Reject invalid webhook config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-webhook-config", + "test_name": "test_reject_invalid_webhook_config", + "original_name": "test_reject_invalid_webhook_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_webhook_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid webhook config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0016017919988371432, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a webhook channel missing url", + "status": "passed", + "duration": 7e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001439, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f4-event-publishing", + "feature_name": "F4 \u2014 Event Publishing", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "event-response-is-non-blocking", + "scenario_name": "Event response is non-blocking", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "event-response-is-non-blocking", + "test_name": "test_event_response_is_non_blocking", + "original_name": "test_event_response_is_non_blocking", + "nodeid": "tests/test_f4-event-publishing.py::test_event_response_is_non_blocking", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Event response is non-blocking", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.00743920897366479, + "error": null, + "steps": [ + { + "description": "Given a rule with a slow webhook channel", + "status": "passed", + "duration": 0.004741, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002529, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the response returns 202 before the webhook completes", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "inactive-rule-is-skipped", + "scenario_name": "Inactive rule is skipped", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "inactive-rule-is-skipped", + "test_name": "test_inactive_rule_is_skipped", + "original_name": "test_inactive_rule_is_skipped", + "nodeid": "tests/test_f4-event-publishing.py::test_inactive_rule_is_skipped", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Inactive rule is skipped", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31969041703268886, + "error": null, + "steps": [ + { + "description": "Given a deactivated rule matching the event type", + "status": "passed", + "duration": 0.005327, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002409, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.311737, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "only-matching-event-type-rules-evaluated", + "scenario_name": "Only matching event_type rules evaluated", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "only-matching-event-type-rules-evaluated", + "test_name": "test_only_matching_event_type_rules_evaluated", + "original_name": "test_only_matching_event_type_rules_evaluated", + "nodeid": "tests/test_f4-event-publishing.py::test_only_matching_event_type_rules_evaluated", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Only matching event_type rules evaluated", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.31138845800887793, + "error": null, + "steps": [ + { + "description": "Given a rule listening for event_type \"order.created\"", + "status": "passed", + "duration": 0.005751, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with type \"user.created\"", + "status": "passed", + "duration": 0.002089, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.303296, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "scenario_name": "Rule does not fire when a condition fails", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "test_name": "test_rule_does_not_fire_when_a_condition_fails", + "original_name": "test_rule_does_not_fire_when_a_condition_fails", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_does_not_fire_when_a_condition_fails", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule does not fire when a condition fails", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31821499997749925, + "error": null, + "steps": [ + { + "description": "Given the same rule", + "status": "passed", + "duration": 0.004505, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"member\"}}", + "status": "passed", + "duration": 0.002525, + "error": null, + "skipped_reason": null + }, + { + "description": "Then no DispatchRecord is created for that rule", + "status": "passed", + "duration": 0.310958, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-fires-when-all-conditions-match", + "scenario_name": "Rule fires when all conditions match", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-fires-when-all-conditions-match", + "test_name": "test_rule_fires_when_all_conditions_match", + "original_name": "test_rule_fires_when_all_conditions_match", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_fires_when_all_conditions_match", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule fires when all conditions match", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.009859792015049607, + "error": null, + "steps": [ + { + "description": "Given a rule with event_type \"user.created\" and condition user.role eq \"admin\"", + "status": "passed", + "duration": 0.004408, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event {\"type\": \"user.created\", \"payload\": {\"user\": {\"role\": \"admin\"}}}", + "status": "passed", + "duration": 0.003533, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is triggered and a DispatchRecord with status \"sent\" is created", + "status": "passed", + "duration": 0.001695, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f5-dispatch-records", + "feature_name": "F5 \u2014 Dispatch Records", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "records-returned-newest-first", + "scenario_name": "Records returned newest first", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f5-dispatch-records", + "scenario_id": "records-returned-newest-first", + "test_name": "test_records_returned_newest_first", + "original_name": "test_records_returned_newest_first", + "nodeid": "tests/test_f5-dispatch-records.py::test_records_returned_newest_first", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F5 \u2014 Dispatch Records", + "scenario_name": "Records returned newest first", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.011737915978301316, + "error": null, + "steps": [ + { + "description": "Given two events that triggered the same rule at different times", + "status": "passed", + "duration": 0.0099, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /dispatch-records?rule_id=", + "status": "passed", + "duration": 0.001646, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the more recent record appears first", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/.specleft/results/results_20260224_153445.json b/.specleft/results/results_20260224_153445.json new file mode 100644 index 0000000..7db8a4d --- /dev/null +++ b/.specleft/results/results_20260224_153445.json @@ -0,0 +1,1116 @@ +{ + "run_id": "2026-02-24T15:34:45.597587", + "summary": { + "total_features": 5, + "total_scenarios": 20, + "total_executions": 20, + "passed": 20, + "failed": 0, + "skipped": 0, + "duration": 1.973 + }, + "features": [ + { + "feature_id": "f1-rule-management-crud", + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "create-a-valid-rule", + "scenario_name": "Create a valid rule", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "create-a-valid-rule", + "test_name": "test_create_a_valid_rule", + "original_name": "test_create_a_valid_rule", + "nodeid": "tests/test_f1-rule-management-crud.py::test_create_a_valid_rule", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Create a valid rule", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.010266083001624793, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and one log channel", + "status": "passed", + "duration": 8e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.009988, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 201 with the created rule and its id", + "status": "passed", + "duration": 1.3e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "delete-cascades-to-conditions-and-channels", + "scenario_name": "Delete cascades to conditions and channels", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "delete-cascades-to-conditions-and-channels", + "test_name": "test_delete_cascades_to_conditions_and_channels", + "original_name": "test_delete_cascades_to_conditions_and_channels", + "nodeid": "tests/test_f1-rule-management-crud.py::test_delete_cascades_to_conditions_and_channels", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Delete cascades to conditions and channels", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.010566500015556812, + "error": null, + "steps": [ + { + "description": "Given an existing rule with two conditions and one channel", + "status": "passed", + "duration": 0.005729, + "error": null, + "skipped_reason": null + }, + { + "description": "When I DELETE the rule", + "status": "passed", + "duration": 0.004118, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the conditions and channel are also removed", + "status": "passed", + "duration": 0.000442, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "get-rule-by-id", + "scenario_name": "Get rule by id", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "get-rule-by-id", + "test_name": "test_get_rule_by_id", + "original_name": "test_get_rule_by_id", + "nodeid": "tests/test_f1-rule-management-crud.py::test_get_rule_by_id", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Get rule by id", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.006500916962977499, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.004275, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules/", + "status": "passed", + "duration": 0.002007, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the rule", + "status": "passed", + "duration": 1.7e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "list-rules", + "scenario_name": "List rules", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "list-rules", + "test_name": "test_list_rules", + "original_name": "test_list_rules", + "nodeid": "tests/test_f1-rule-management-crud.py::test_list_rules", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "List rules", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.00841179199051112, + "error": null, + "steps": [ + { + "description": "Given two existing rules", + "status": "passed", + "duration": 0.006355, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules", + "status": "passed", + "duration": 0.001837, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with both rules", + "status": "passed", + "duration": 1.9e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-duplicate-rule-name", + "scenario_name": "Reject duplicate rule name", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "reject-duplicate-rule-name", + "test_name": "test_reject_duplicate_rule_name", + "original_name": "test_reject_duplicate_rule_name", + "nodeid": "tests/test_f1-rule-management-crud.py::test_reject_duplicate_rule_name", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Reject duplicate rule name", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.005504666012711823, + "error": null, + "steps": [ + { + "description": "Given an existing rule named \"alert-on-signup\"", + "status": "passed", + "duration": 0.004022, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a new rule with name \"alert-on-signup\"", + "status": "passed", + "duration": 0.00134, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 409 Conflict", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-channel", + "scenario_name": "Rule creation rejected without a channel", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-channel", + "test_name": "test_rule_creation_rejected_without_a_channel", + "original_name": "test_rule_creation_rejected_without_a_channel", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_channel", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a channel", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0024354589986614883, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and no channels", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.002306, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-condition", + "scenario_name": "Rule creation rejected without a condition", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-condition", + "test_name": "test_rule_creation_rejected_without_a_condition", + "original_name": "test_rule_creation_rejected_without_a_condition", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_condition", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a condition", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0016780829755589366, + "error": null, + "steps": [ + { + "description": "Given a rule payload with no conditions and one log channel", + "status": "passed", + "duration": 6e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001513, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "update-rule-fields", + "scenario_name": "Update rule fields", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "update-rule-fields", + "test_name": "test_update_rule_fields", + "original_name": "test_update_rule_fields", + "nodeid": "tests/test_f1-rule-management-crud.py::test_update_rule_fields", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Update rule fields", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.010056250030174851, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.007015, + "error": null, + "skipped_reason": null + }, + { + "description": "When I PATCH /rules/ with a new name and is_active false", + "status": "passed", + "duration": 0.002848, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the updated rule", + "status": "passed", + "duration": 9e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f2-conditions", + "feature_name": "F2 \u2014 Conditions", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "gt-on-non-numeric-evaluates-false", + "scenario_name": "gt on non-numeric evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "gt-on-non-numeric-evaluates-false", + "test_name": "test_gt_on_non_numeric_evaluates_false", + "original_name": "test_gt_on_non_numeric_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_gt_on_non_numeric_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "gt on non-numeric evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3122280419920571, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" gt \"5\"", + "status": "passed", + "duration": 0.004148, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"admin\"}}", + "status": "passed", + "duration": 0.002513, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.305343, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "missing-field-path-evaluates-false", + "scenario_name": "Missing field path evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "missing-field-path-evaluates-false", + "test_name": "test_missing_field_path_evaluates_false", + "original_name": "test_missing_field_path_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_missing_field_path_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Missing field path evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31087170797400177, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" eq \"admin\"", + "status": "passed", + "duration": 0.004212, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload missing \"user.role\"", + "status": "passed", + "duration": 0.00258, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.303872, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-unknown-operator", + "scenario_name": "Reject unknown operator", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "reject-unknown-operator", + "test_name": "test_reject_unknown_operator", + "original_name": "test_reject_unknown_operator", + "nodeid": "tests/test_f2-conditions.py::test_reject_unknown_operator", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Reject unknown operator", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.001928584009874612, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a condition using operator \"between\"", + "status": "passed", + "duration": 1.5e-05, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001718, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f3-channels", + "feature_name": "F3 \u2014 Channels", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "one-channel-failure-does-not-block-others", + "scenario_name": "One channel failure does not block others", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "one-channel-failure-does-not-block-others", + "test_name": "test_one_channel_failure_does_not_block_others", + "original_name": "test_one_channel_failure_does_not_block_others", + "nodeid": "tests/test_f3-channels.py::test_one_channel_failure_does_not_block_others", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "One channel failure does not block others", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3171958749881014, + "error": null, + "steps": [ + { + "description": "Given a rule with a failing webhook channel and a log channel", + "status": "passed", + "duration": 0.005018, + "error": null, + "skipped_reason": null + }, + { + "description": "When the rule fires", + "status": "passed", + "duration": 0.002323, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the log channel dispatches successfully", + "status": "passed", + "duration": 0.309639, + "error": null, + "skipped_reason": null + }, + { + "description": "And a DispatchRecord with status \"failed\" exists for the webhook", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-email-config", + "scenario_name": "Reject invalid email config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-email-config", + "test_name": "test_reject_invalid_email_config", + "original_name": "test_reject_invalid_email_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_email_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid email config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0014726250083185732, + "error": null, + "steps": [ + { + "description": "Given a rule payload with an email channel missing to", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001335, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-webhook-config", + "scenario_name": "Reject invalid webhook config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-webhook-config", + "test_name": "test_reject_invalid_webhook_config", + "original_name": "test_reject_invalid_webhook_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_webhook_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid webhook config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0014685419737361372, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a webhook channel missing url", + "status": "passed", + "duration": 6e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001319, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f4-event-publishing", + "feature_name": "F4 \u2014 Event Publishing", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "event-response-is-non-blocking", + "scenario_name": "Event response is non-blocking", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "event-response-is-non-blocking", + "test_name": "test_event_response_is_non_blocking", + "original_name": "test_event_response_is_non_blocking", + "nodeid": "tests/test_f4-event-publishing.py::test_event_response_is_non_blocking", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Event response is non-blocking", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.00754766701720655, + "error": null, + "steps": [ + { + "description": "Given a rule with a slow webhook channel", + "status": "passed", + "duration": 0.004391, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002873, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the response returns 202 before the webhook completes", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "inactive-rule-is-skipped", + "scenario_name": "Inactive rule is skipped", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "inactive-rule-is-skipped", + "test_name": "test_inactive_rule_is_skipped", + "original_name": "test_inactive_rule_is_skipped", + "nodeid": "tests/test_f4-event-publishing.py::test_inactive_rule_is_skipped", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Inactive rule is skipped", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3191912079928443, + "error": null, + "steps": [ + { + "description": "Given a deactivated rule matching the event type", + "status": "passed", + "duration": 0.004635, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.005578, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.308664, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "only-matching-event-type-rules-evaluated", + "scenario_name": "Only matching event_type rules evaluated", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "only-matching-event-type-rules-evaluated", + "test_name": "test_only_matching_event_type_rules_evaluated", + "original_name": "test_only_matching_event_type_rules_evaluated", + "nodeid": "tests/test_f4-event-publishing.py::test_only_matching_event_type_rules_evaluated", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Only matching event_type rules evaluated", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.3169672499643639, + "error": null, + "steps": [ + { + "description": "Given a rule listening for event_type \"order.created\"", + "status": "passed", + "duration": 0.007625, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with type \"user.created\"", + "status": "passed", + "duration": 0.002154, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.306978, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "scenario_name": "Rule does not fire when a condition fails", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "test_name": "test_rule_does_not_fire_when_a_condition_fails", + "original_name": "test_rule_does_not_fire_when_a_condition_fails", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_does_not_fire_when_a_condition_fails", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule does not fire when a condition fails", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3076948330271989, + "error": null, + "steps": [ + { + "description": "Given the same rule", + "status": "passed", + "duration": 0.004645, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"member\"}}", + "status": "passed", + "duration": 0.002759, + "error": null, + "skipped_reason": null + }, + { + "description": "Then no DispatchRecord is created for that rule", + "status": "passed", + "duration": 0.300055, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-fires-when-all-conditions-match", + "scenario_name": "Rule fires when all conditions match", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-fires-when-all-conditions-match", + "test_name": "test_rule_fires_when_all_conditions_match", + "original_name": "test_rule_fires_when_all_conditions_match", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_fires_when_all_conditions_match", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule fires when all conditions match", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.010092791984789073, + "error": null, + "steps": [ + { + "description": "Given a rule with event_type \"user.created\" and condition user.role eq \"admin\"", + "status": "passed", + "duration": 0.004461, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event {\"type\": \"user.created\", \"payload\": {\"user\": {\"role\": \"admin\"}}}", + "status": "passed", + "duration": 0.003766, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is triggered and a DispatchRecord with status \"sent\" is created", + "status": "passed", + "duration": 0.001645, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f5-dispatch-records", + "feature_name": "F5 \u2014 Dispatch Records", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "records-returned-newest-first", + "scenario_name": "Records returned newest first", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f5-dispatch-records", + "scenario_id": "records-returned-newest-first", + "test_name": "test_records_returned_newest_first", + "original_name": "test_records_returned_newest_first", + "nodeid": "tests/test_f5-dispatch-records.py::test_records_returned_newest_first", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F5 \u2014 Dispatch Records", + "scenario_name": "Records returned newest first", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.011202374997083098, + "error": null, + "steps": [ + { + "description": "Given two events that triggered the same rule at different times", + "status": "passed", + "duration": 0.009608, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /dispatch-records?rule_id=", + "status": "passed", + "duration": 0.001404, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the more recent record appears first", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/.specleft/results/results_20260224_153457.json b/.specleft/results/results_20260224_153457.json new file mode 100644 index 0000000..a94cab8 --- /dev/null +++ b/.specleft/results/results_20260224_153457.json @@ -0,0 +1,1116 @@ +{ + "run_id": "2026-02-24T15:34:57.720527", + "summary": { + "total_features": 5, + "total_scenarios": 20, + "total_executions": 20, + "passed": 20, + "failed": 0, + "skipped": 0, + "duration": 1.99 + }, + "features": [ + { + "feature_id": "f1-rule-management-crud", + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "create-a-valid-rule", + "scenario_name": "Create a valid rule", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "create-a-valid-rule", + "test_name": "test_create_a_valid_rule", + "original_name": "test_create_a_valid_rule", + "nodeid": "tests/test_f1-rule-management-crud.py::test_create_a_valid_rule", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Create a valid rule", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.010281833994667977, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and one log channel", + "status": "passed", + "duration": 7e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.010023, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 201 with the created rule and its id", + "status": "passed", + "duration": 2.4e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "delete-cascades-to-conditions-and-channels", + "scenario_name": "Delete cascades to conditions and channels", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "delete-cascades-to-conditions-and-channels", + "test_name": "test_delete_cascades_to_conditions_and_channels", + "original_name": "test_delete_cascades_to_conditions_and_channels", + "nodeid": "tests/test_f1-rule-management-crud.py::test_delete_cascades_to_conditions_and_channels", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Delete cascades to conditions and channels", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.008427875000052154, + "error": null, + "steps": [ + { + "description": "Given an existing rule with two conditions and one channel", + "status": "passed", + "duration": 0.004508, + "error": null, + "skipped_reason": null + }, + { + "description": "When I DELETE the rule", + "status": "passed", + "duration": 0.003387, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the conditions and channel are also removed", + "status": "passed", + "duration": 0.000351, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "get-rule-by-id", + "scenario_name": "Get rule by id", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "get-rule-by-id", + "test_name": "test_get_rule_by_id", + "original_name": "test_get_rule_by_id", + "nodeid": "tests/test_f1-rule-management-crud.py::test_get_rule_by_id", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Get rule by id", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.006129708024673164, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.004045, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules/", + "status": "passed", + "duration": 0.001923, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the rule", + "status": "passed", + "duration": 1e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "list-rules", + "scenario_name": "List rules", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "list-rules", + "test_name": "test_list_rules", + "original_name": "test_list_rules", + "nodeid": "tests/test_f1-rule-management-crud.py::test_list_rules", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "List rules", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.007991166959982365, + "error": null, + "steps": [ + { + "description": "Given two existing rules", + "status": "passed", + "duration": 0.005961, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /rules", + "status": "passed", + "duration": 0.001844, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with both rules", + "status": "passed", + "duration": 1.3e-05, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-duplicate-rule-name", + "scenario_name": "Reject duplicate rule name", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "reject-duplicate-rule-name", + "test_name": "test_reject_duplicate_rule_name", + "original_name": "test_reject_duplicate_rule_name", + "nodeid": "tests/test_f1-rule-management-crud.py::test_reject_duplicate_rule_name", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Reject duplicate rule name", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.005377458001021296, + "error": null, + "steps": [ + { + "description": "Given an existing rule named \"alert-on-signup\"", + "status": "passed", + "duration": 0.00347, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a new rule with name \"alert-on-signup\"", + "status": "passed", + "duration": 0.001737, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 409 Conflict", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-channel", + "scenario_name": "Rule creation rejected without a channel", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-channel", + "test_name": "test_rule_creation_rejected_without_a_channel", + "original_name": "test_rule_creation_rejected_without_a_channel", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_channel", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a channel", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0012091670068912208, + "error": null, + "steps": [ + { + "description": "Given a rule payload with one condition and no channels", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001096, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 1e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-creation-rejected-without-a-condition", + "scenario_name": "Rule creation rejected without a condition", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "rule-creation-rejected-without-a-condition", + "test_name": "test_rule_creation_rejected_without_a_condition", + "original_name": "test_rule_creation_rejected_without_a_condition", + "nodeid": "tests/test_f1-rule-management-crud.py::test_rule_creation_rejected_without_a_condition", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Rule creation rejected without a condition", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.001262500009033829, + "error": null, + "steps": [ + { + "description": "Given a rule payload with no conditions and one log channel", + "status": "passed", + "duration": 4e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001121, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "update-rule-fields", + "scenario_name": "Update rule fields", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f1-rule-management-crud", + "scenario_id": "update-rule-fields", + "test_name": "test_update_rule_fields", + "original_name": "test_update_rule_fields", + "nodeid": "tests/test_f1-rule-management-crud.py::test_update_rule_fields", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F1 \u2014 Rule Management (CRUD)", + "scenario_name": "Update rule fields", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.007096833025570959, + "error": null, + "steps": [ + { + "description": "Given an existing rule", + "status": "passed", + "duration": 0.004102, + "error": null, + "skipped_reason": null + }, + { + "description": "When I PATCH /rules/ with a new name and is_active false", + "status": "passed", + "duration": 0.002822, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 200 with the updated rule", + "status": "passed", + "duration": 9e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f2-conditions", + "feature_name": "F2 \u2014 Conditions", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "gt-on-non-numeric-evaluates-false", + "scenario_name": "gt on non-numeric evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "gt-on-non-numeric-evaluates-false", + "test_name": "test_gt_on_non_numeric_evaluates_false", + "original_name": "test_gt_on_non_numeric_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_gt_on_non_numeric_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "gt on non-numeric evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31334587500896305, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" gt \"5\"", + "status": "passed", + "duration": 0.003708, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"admin\"}}", + "status": "passed", + "duration": 0.002499, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.306924, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "missing-field-path-evaluates-false", + "scenario_name": "Missing field path evaluates false", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "missing-field-path-evaluates-false", + "test_name": "test_missing_field_path_evaluates_false", + "original_name": "test_missing_field_path_evaluates_false", + "nodeid": "tests/test_f2-conditions.py::test_missing_field_path_evaluates_false", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Missing field path evaluates false", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31989970797440037, + "error": null, + "steps": [ + { + "description": "Given a rule with condition \"user.role\" eq \"admin\"", + "status": "passed", + "duration": 0.005131, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload missing \"user.role\"", + "status": "passed", + "duration": 0.002501, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.312037, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-unknown-operator", + "scenario_name": "Reject unknown operator", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f2-conditions", + "scenario_id": "reject-unknown-operator", + "test_name": "test_reject_unknown_operator", + "original_name": "test_reject_unknown_operator", + "nodeid": "tests/test_f2-conditions.py::test_reject_unknown_operator", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F2 \u2014 Conditions", + "scenario_name": "Reject unknown operator", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.00210787495598197, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a condition using operator \"between\"", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001951, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f3-channels", + "feature_name": "F3 \u2014 Channels", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "one-channel-failure-does-not-block-others", + "scenario_name": "One channel failure does not block others", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "one-channel-failure-does-not-block-others", + "test_name": "test_one_channel_failure_does_not_block_others", + "original_name": "test_one_channel_failure_does_not_block_others", + "nodeid": "tests/test_f3-channels.py::test_one_channel_failure_does_not_block_others", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "One channel failure does not block others", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.32654808298684657, + "error": null, + "steps": [ + { + "description": "Given a rule with a failing webhook channel and a log channel", + "status": "passed", + "duration": 0.006298, + "error": null, + "skipped_reason": null + }, + { + "description": "When the rule fires", + "status": "passed", + "duration": 0.002963, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the log channel dispatches successfully", + "status": "passed", + "duration": 0.317007, + "error": null, + "skipped_reason": null + }, + { + "description": "And a DispatchRecord with status \"failed\" exists for the webhook", + "status": "passed", + "duration": 3e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-email-config", + "scenario_name": "Reject invalid email config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-email-config", + "test_name": "test_reject_invalid_email_config", + "original_name": "test_reject_invalid_email_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_email_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid email config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0016942499787546694, + "error": null, + "steps": [ + { + "description": "Given a rule payload with an email channel missing to", + "status": "passed", + "duration": 8e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001519, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "reject-invalid-webhook-config", + "scenario_name": "Reject invalid webhook config", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f3-channels", + "scenario_id": "reject-invalid-webhook-config", + "test_name": "test_reject_invalid_webhook_config", + "original_name": "test_reject_invalid_webhook_config", + "nodeid": "tests/test_f3-channels.py::test_reject_invalid_webhook_config", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F3 \u2014 Channels", + "scenario_name": "Reject invalid webhook config", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.0016954169841483235, + "error": null, + "steps": [ + { + "description": "Given a rule payload with a webhook channel missing url", + "status": "passed", + "duration": 5e-06, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST to /rules", + "status": "passed", + "duration": 0.001587, + "error": null, + "skipped_reason": null + }, + { + "description": "Then I receive 422", + "status": "passed", + "duration": 1e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f4-event-publishing", + "feature_name": "F4 \u2014 Event Publishing", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "event-response-is-non-blocking", + "scenario_name": "Event response is non-blocking", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "event-response-is-non-blocking", + "test_name": "test_event_response_is_non_blocking", + "original_name": "test_event_response_is_non_blocking", + "nodeid": "tests/test_f4-event-publishing.py::test_event_response_is_non_blocking", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Event response is non-blocking", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.007241707993671298, + "error": null, + "steps": [ + { + "description": "Given a rule with a slow webhook channel", + "status": "passed", + "duration": 0.004447, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002675, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the response returns 202 before the webhook completes", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "inactive-rule-is-skipped", + "scenario_name": "Inactive rule is skipped", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "inactive-rule-is-skipped", + "test_name": "test_inactive_rule_is_skipped", + "original_name": "test_inactive_rule_is_skipped", + "nodeid": "tests/test_f4-event-publishing.py::test_inactive_rule_is_skipped", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Inactive rule is skipped", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.31670666695572436, + "error": null, + "steps": [ + { + "description": "Given a deactivated rule matching the event type", + "status": "passed", + "duration": 0.004551, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST a matching event", + "status": "passed", + "duration": 0.002274, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.309619, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "only-matching-event-type-rules-evaluated", + "scenario_name": "Only matching event_type rules evaluated", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "only-matching-event-type-rules-evaluated", + "test_name": "test_only_matching_event_type_rules_evaluated", + "original_name": "test_only_matching_event_type_rules_evaluated", + "nodeid": "tests/test_f4-event-publishing.py::test_only_matching_event_type_rules_evaluated", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Only matching event_type rules evaluated", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "medium", + "status": "passed", + "duration": 0.3111950419843197, + "error": null, + "steps": [ + { + "description": "Given a rule listening for event_type \"order.created\"", + "status": "passed", + "duration": 0.004138, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with type \"user.created\"", + "status": "passed", + "duration": 0.001834, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is not triggered", + "status": "passed", + "duration": 0.305012, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "scenario_name": "Rule does not fire when a condition fails", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-does-not-fire-when-a-condition-fails", + "test_name": "test_rule_does_not_fire_when_a_condition_fails", + "original_name": "test_rule_does_not_fire_when_a_condition_fails", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_does_not_fire_when_a_condition_fails", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule does not fire when a condition fails", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.3205739170080051, + "error": null, + "steps": [ + { + "description": "Given the same rule", + "status": "passed", + "duration": 0.004085, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event with payload {\"user\": {\"role\": \"member\"}}", + "status": "passed", + "duration": 0.002322, + "error": null, + "skipped_reason": null + }, + { + "description": "Then no DispatchRecord is created for that rule", + "status": "passed", + "duration": 0.313945, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + }, + { + "scenario_id": "rule-fires-when-all-conditions-match", + "scenario_name": "Rule fires when all conditions match", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f4-event-publishing", + "scenario_id": "rule-fires-when-all-conditions-match", + "test_name": "test_rule_fires_when_all_conditions_match", + "original_name": "test_rule_fires_when_all_conditions_match", + "nodeid": "tests/test_f4-event-publishing.py::test_rule_fires_when_all_conditions_match", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F4 \u2014 Event Publishing", + "scenario_name": "Rule fires when all conditions match", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.009779167012311518, + "error": null, + "steps": [ + { + "description": "Given a rule with event_type \"user.created\" and condition user.role eq \"admin\"", + "status": "passed", + "duration": 0.004036, + "error": null, + "skipped_reason": null + }, + { + "description": "When I POST an event {\"type\": \"user.created\", \"payload\": {\"user\": {\"role\": \"admin\"}}}", + "status": "passed", + "duration": 0.003852, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the rule is triggered and a DispatchRecord with status \"sent\" is created", + "status": "passed", + "duration": 0.001702, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + }, + { + "feature_id": "f5-dispatch-records", + "feature_name": "F5 \u2014 Dispatch Records", + "feature_priority": "medium", + "scenarios": [ + { + "scenario_id": "records-returned-newest-first", + "scenario_name": "Records returned newest first", + "is_parameterized": false, + "executions": [ + { + "feature_id": "f5-dispatch-records", + "scenario_id": "records-returned-newest-first", + "test_name": "test_records_returned_newest_first", + "original_name": "test_records_returned_newest_first", + "nodeid": "tests/test_f5-dispatch-records.py::test_records_returned_newest_first", + "is_parameterized": false, + "parameters": {}, + "feature_name": "F5 \u2014 Dispatch Records", + "scenario_name": "Records returned newest first", + "tags": [], + "feature_priority": "medium", + "scenario_priority": "high", + "status": "passed", + "duration": 0.011259332997724414, + "error": null, + "steps": [ + { + "description": "Given two events that triggered the same rule at different times", + "status": "passed", + "duration": 0.009638, + "error": null, + "skipped_reason": null + }, + { + "description": "When I GET /dispatch-records?rule_id=", + "status": "passed", + "duration": 0.001443, + "error": null, + "skipped_reason": null + }, + { + "description": "Then the more recent record appears first", + "status": "passed", + "duration": 2e-06, + "error": null, + "skipped_reason": null + } + ] + } + ], + "summary": { + "total": 1, + "passed": 1, + "failed": 0, + "skipped": 0 + } + } + ] + } + ] +} \ No newline at end of file diff --git a/.specleft/specs/f1-rule-management-crud.md b/.specleft/specs/f1-rule-management-crud.md new file mode 100644 index 0000000..ddebd4f --- /dev/null +++ b/.specleft/specs/f1-rule-management-crud.md @@ -0,0 +1,59 @@ +# F1 — Rule Management (CRUD) + +## Scenarios + +### Scenario: Create a valid rule +priority: high + +- Given a rule payload with one condition and one log channel +- When I POST to /rules +- Then I receive 201 with the created rule and its id + +### Scenario: Reject duplicate rule name +priority: high + +- Given an existing rule named "alert-on-signup" +- When I POST a new rule with name "alert-on-signup" +- Then I receive 409 Conflict + +### Scenario: Rule creation rejected without a channel +priority: high + +- Given a rule payload with one condition and no channels +- When I POST to /rules +- Then I receive 422 + +### Scenario: Rule creation rejected without a condition +priority: high + +- Given a rule payload with no conditions and one log channel +- When I POST to /rules +- Then I receive 422 + +### Scenario: List rules +priority: medium + +- Given two existing rules +- When I GET /rules +- Then I receive 200 with both rules + +### Scenario: Get rule by id +priority: medium + +- Given an existing rule +- When I GET /rules/{id} +- Then I receive 200 with the rule + +### Scenario: Update rule fields +priority: medium + +- Given an existing rule +- When I PATCH /rules/{id} with a new name and is_active false +- Then I receive 200 with the updated rule + +### Scenario: Delete cascades to conditions and channels +priority: high + +- Given an existing rule with two conditions and one channel +- When I DELETE the rule +- Then the conditions and channel are also removed diff --git a/.specleft/specs/f2-conditions.md b/.specleft/specs/f2-conditions.md new file mode 100644 index 0000000..787b34d --- /dev/null +++ b/.specleft/specs/f2-conditions.md @@ -0,0 +1,24 @@ +# F2 — Conditions + +## Scenarios + +### Scenario: Reject unknown operator +priority: high + +- Given a rule payload with a condition using operator "between" +- When I POST to /rules +- Then I receive 422 + +### Scenario: Missing field path evaluates false +priority: high + +- Given a rule with condition "user.role" eq "admin" +- When I POST an event with payload missing "user.role" +- Then the rule is not triggered + +### Scenario: gt on non-numeric evaluates false +priority: high + +- Given a rule with condition "user.role" gt "5" +- When I POST an event with payload {"user": {"role": "admin"}} +- Then the rule is not triggered diff --git a/.specleft/specs/f3-channels.md b/.specleft/specs/f3-channels.md new file mode 100644 index 0000000..81db9c3 --- /dev/null +++ b/.specleft/specs/f3-channels.md @@ -0,0 +1,25 @@ +# F3 — Channels + +## Scenarios + +### Scenario: Reject invalid webhook config +priority: high + +- Given a rule payload with a webhook channel missing url +- When I POST to /rules +- Then I receive 422 + +### Scenario: Reject invalid email config +priority: high + +- Given a rule payload with an email channel missing to +- When I POST to /rules +- Then I receive 422 + +### Scenario: One channel failure does not block others +priority: high + +- Given a rule with a failing webhook channel and a log channel +- When the rule fires +- Then the log channel dispatches successfully +- And a DispatchRecord with status "failed" exists for the webhook diff --git a/.specleft/specs/f4-event-publishing.md b/.specleft/specs/f4-event-publishing.md new file mode 100644 index 0000000..0ef490e --- /dev/null +++ b/.specleft/specs/f4-event-publishing.md @@ -0,0 +1,38 @@ +# F4 — Event Publishing + +## Scenarios + +### Scenario: Rule fires when all conditions match +priority: high + +- Given a rule with event_type "user.created" and condition user.role eq "admin" +- When I POST an event {"type": "user.created", "payload": {"user": {"role": "admin"}}} +- Then the rule is triggered and a DispatchRecord with status "sent" is created + +### Scenario: Rule does not fire when a condition fails +priority: high + +- Given the same rule +- When I POST an event with payload {"user": {"role": "member"}} +- Then no DispatchRecord is created for that rule + +### Scenario: Inactive rule is skipped +priority: high + +- Given a deactivated rule matching the event type +- When I POST a matching event +- Then the rule is not triggered + +### Scenario: Event response is non-blocking +priority: medium + +- Given a rule with a slow webhook channel +- When I POST a matching event +- Then the response returns 202 before the webhook completes + +### Scenario: Only matching event_type rules evaluated +priority: medium + +- Given a rule listening for event_type "order.created" +- When I POST an event with type "user.created" +- Then the rule is not triggered diff --git a/.specleft/specs/f5-dispatch-records.md b/.specleft/specs/f5-dispatch-records.md new file mode 100644 index 0000000..c6d98dd --- /dev/null +++ b/.specleft/specs/f5-dispatch-records.md @@ -0,0 +1,10 @@ +# F5 — Dispatch Records + +## Scenarios + +### Scenario: Records returned newest first +priority: high + +- Given two events that triggered the same rule at different times +- When I GET /dispatch-records?rule_id={id} +- Then the more recent record appears first diff --git a/.specleft/templates/prd-template.yml b/.specleft/templates/prd-template.yml new file mode 100644 index 0000000..f57ac36 --- /dev/null +++ b/.specleft/templates/prd-template.yml @@ -0,0 +1,48 @@ +version: "1.0" + +features: + heading_level: 3 + patterns: + - "Feature: {title}" + - "Feature {title}" + - "{title}" + contains: + - "F1 — Rule Management (CRUD)" + - "F2 — Conditions" + - "F3 — Channels" + - "F4 — Event Publishing" + - "F5 — Dispatch Records" + match_mode: "contains" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only + exclude: + - "Overview" + - "Goals" + - "Project Goals" + - "API Surface" + - "Features" + - "Out of Scope" + - "Out of Scope (v1)" + - "Acceptance Scenarios" + - "Acceptance Scenarios (Gherkin)" + - "Non-Goals" + - "Open Questions" + - "Notes" + +scenarios: + heading_level: [4, 5] + patterns: + - "Scenario: {title}" + - "{title}" + contains: [] + match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only + step_keywords: + - "Given" + - "When" + - "Then" + - "And" + - "But" + +priorities: + patterns: + - "priority: {value}" + - "Priority: {value}" + mapping: {} diff --git a/main.py b/main.py index fa02483..1f92e73 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,11 @@ -def main(): - print("Hello from specleft-sandbox!") +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SRC_PATH = ROOT / "src" +sys.path.insert(0, str(SRC_PATH)) + +from app.main import main if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 55f54b4..3cb153b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,5 +3,18 @@ name = "specleft-sandbox" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = ">=3.13" -dependencies = [] +requires-python = ">=3.12" +dependencies = [ + "email-validator>=2.0", + "fastapi>=0.110", + "httpx>=0.26", + "pydantic>=2.6", + "sqlalchemy>=2.0", + "uvicorn>=0.27", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "specleft>=0.0.0", +] diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..18b665e --- /dev/null +++ b/src/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/src/app/database.py b/src/app/database.py new file mode 100644 index 0000000..455d6a3 --- /dev/null +++ b/src/app/database.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = "sqlite:///./notification_rules.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + +Base = declarative_base() + + +def init_db() -> None: + Base.metadata.create_all(bind=engine) diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 0000000..a289076 --- /dev/null +++ b/src/app/main.py @@ -0,0 +1,173 @@ +from __future__ import annotations +import threading + +from fastapi import Depends, FastAPI, HTTPException, status +from sqlalchemy.orm import Session, sessionmaker + +from app.database import SessionLocal, init_db as init_database +from app.repository import ( + DuplicateRuleNameError, + RuleNotFoundError, + RuleRepository, + build_rule, +) +from app.schemas import ( + DispatchRecordResponse, + EventPublish, + EventResponse, + RuleCreate, + RuleResponse, + RuleUpdate, +) +from app.services import ChannelRegistry, ConditionEvaluator, DispatchService + + +def create_app( + *, + session_local: sessionmaker | None = None, + init_db: bool = True, + webhook_delay_seconds: float = 0.0, + fail_webhook_url: str | None = None, +) -> FastAPI: + app = FastAPI() + app.state.dispatch_threads = [] + if init_db: + init_database() + + session_factory = session_local or SessionLocal + + def get_session() -> Session: + session = session_factory() + try: + yield session + finally: + session.close() + + def get_repository(session: Session = Depends(get_session)) -> RuleRepository: + return RuleRepository(session) + + @app.post( + "/rules", response_model=RuleResponse, status_code=status.HTTP_201_CREATED + ) + def create_rule( + payload: RuleCreate, repository: RuleRepository = Depends(get_repository) + ): + rule = build_rule( + name=payload.name, + event_type=payload.event_type, + is_active=payload.is_active, + conditions=[condition.model_dump() for condition in payload.conditions], + channels=[channel.model_dump() for channel in payload.channels], + ) + try: + created = repository.create_rule(rule) + except DuplicateRuleNameError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="duplicate rule name" + ) + return created + + @app.get("/rules", response_model=list[RuleResponse]) + def list_rules(repository: RuleRepository = Depends(get_repository)): + return repository.list_rules() + + @app.get("/rules/{rule_id}", response_model=RuleResponse) + def get_rule(rule_id: str, repository: RuleRepository = Depends(get_repository)): + try: + return repository.get_rule(rule_id) + except RuleNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="rule not found" + ) + + @app.patch("/rules/{rule_id}", response_model=RuleResponse) + def update_rule( + rule_id: str, + payload: RuleUpdate, + repository: RuleRepository = Depends(get_repository), + ): + try: + rule = repository.get_rule(rule_id) + except RuleNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="rule not found" + ) + if payload.name is not None: + rule.name = payload.name + if payload.is_active is not None: + rule.is_active = payload.is_active + try: + return repository.update_rule(rule) + except DuplicateRuleNameError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="duplicate rule name" + ) + + @app.delete("/rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) + def delete_rule(rule_id: str, repository: RuleRepository = Depends(get_repository)): + try: + rule = repository.get_rule(rule_id) + except RuleNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="rule not found" + ) + repository.delete_rule(rule) + return None + + @app.post( + "/events", response_model=EventResponse, status_code=status.HTTP_202_ACCEPTED + ) + def publish_event( + payload: EventPublish, + repository: RuleRepository = Depends(get_repository), + ): + evaluator = ConditionEvaluator() + triggered = [ + rule.name + for rule in repository.list_active_rules_by_event(payload.type) + if evaluator.evaluate(rule, payload.payload) + ] + + def evaluate_and_dispatch(): + session = session_factory() + try: + dispatch_repository = RuleRepository(session) + registry = ChannelRegistry( + webhook_delay=webhook_delay_seconds, + fail_webhook_url=fail_webhook_url, + ) + dispatch_service = DispatchService( + dispatch_repository, evaluator, registry + ) + dispatch_service.dispatch_for_event(payload.type, payload.payload) + finally: + session.close() + + thread = threading.Thread(target=evaluate_and_dispatch, daemon=True) + app.state.dispatch_threads = [ + existing for existing in app.state.dispatch_threads if existing.is_alive() + ] + app.state.dispatch_threads.append(thread) + thread.start() + return EventResponse(triggered_rules=triggered) + + @app.get("/dispatch-records", response_model=list[DispatchRecordResponse]) + def list_dispatch_records( + rule_id: str, repository: RuleRepository = Depends(get_repository) + ): + return repository.list_dispatch_records(rule_id) + + return app + + +app = create_app() + + +def main() -> None: + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=8000) + + +if __name__ == "__main__": + main() diff --git a/src/app/models.py b/src/app/models.py new file mode 100644 index 0000000..c9a30a8 --- /dev/null +++ b/src/app/models.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class Rule(Base): + __tablename__ = "rules" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String, unique=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + event_type: Mapped[str] = mapped_column(String, index=True) + + conditions: Mapped[list[Condition]] = relationship( + "Condition", + back_populates="rule", + cascade="all, delete-orphan", + ) + channels: Mapped[list[Channel]] = relationship( + "Channel", + back_populates="rule", + cascade="all, delete-orphan", + ) + dispatch_records: Mapped[list[DispatchRecord]] = relationship( + "DispatchRecord", + back_populates="rule", + cascade="all, delete-orphan", + ) + + +class Condition(Base): + __tablename__ = "conditions" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: str(uuid.uuid4()) + ) + rule_id: Mapped[str] = mapped_column( + String, ForeignKey("rules.id", ondelete="CASCADE") + ) + field: Mapped[str] = mapped_column(String) + operator: Mapped[str] = mapped_column(String) + value: Mapped[str] = mapped_column(String) + + rule: Mapped[Rule] = relationship("Rule", back_populates="conditions") + + +class Channel(Base): + __tablename__ = "channels" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: str(uuid.uuid4()) + ) + rule_id: Mapped[str] = mapped_column( + String, ForeignKey("rules.id", ondelete="CASCADE") + ) + type: Mapped[str] = mapped_column(String) + config: Mapped[dict] = mapped_column(JSON, default=dict) + + rule: Mapped[Rule] = relationship("Rule", back_populates="channels") + + +class DispatchRecord(Base): + __tablename__ = "dispatch_records" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: str(uuid.uuid4()) + ) + rule_id: Mapped[str] = mapped_column( + String, ForeignKey("rules.id", ondelete="CASCADE"), index=True + ) + channel_type: Mapped[str] = mapped_column(String) + status: Mapped[str] = mapped_column(String) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + dispatched_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, index=True + ) + + rule: Mapped[Rule] = relationship("Rule", back_populates="dispatch_records") diff --git a/src/app/repository.py b/src/app/repository.py new file mode 100644 index 0000000..5a60203 --- /dev/null +++ b/src/app/repository.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Iterable +from datetime import UTC, datetime + +from sqlalchemy import desc, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models import Channel, Condition, DispatchRecord, Rule + + +class DuplicateRuleNameError(Exception): + pass + + +class RuleNotFoundError(Exception): + pass + + +class RuleRepository: + def __init__(self, session: Session) -> None: + self._session = session + + def list_rules(self) -> list[Rule]: + return list(self._session.scalars(select(Rule))) + + def get_rule(self, rule_id: str) -> Rule: + rule = self._session.get(Rule, rule_id) + if not rule: + raise RuleNotFoundError(rule_id) + return rule + + def get_rule_by_name(self, name: str) -> Rule | None: + return self._session.scalar(select(Rule).where(Rule.name == name)) + + def create_rule(self, rule: Rule) -> Rule: + self._session.add(rule) + try: + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise DuplicateRuleNameError from exc + self._session.refresh(rule) + return rule + + def update_rule(self, rule: Rule) -> Rule: + self._session.add(rule) + try: + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise DuplicateRuleNameError from exc + self._session.refresh(rule) + return rule + + def delete_rule(self, rule: Rule) -> None: + self._session.delete(rule) + self._session.commit() + + def list_active_rules_by_event(self, event_type: str) -> list[Rule]: + return list( + self._session.scalars( + select(Rule).where( + Rule.event_type == event_type, Rule.is_active.is_(True) + ) + ) + ) + + def add_dispatch_record( + self, + *, + rule_id: str, + channel_type: str, + status: str, + error_message: str | None, + dispatched_at: datetime | None = None, + ) -> DispatchRecord: + record = DispatchRecord( + rule_id=rule_id, + channel_type=channel_type, + status=status, + error_message=error_message, + dispatched_at=dispatched_at or datetime.now(UTC), + ) + self._session.add(record) + self._session.commit() + self._session.refresh(record) + return record + + def list_dispatch_records(self, rule_id: str) -> list[DispatchRecord]: + return list( + self._session.scalars( + select(DispatchRecord) + .where(DispatchRecord.rule_id == rule_id) + .order_by(desc(DispatchRecord.dispatched_at)) + ) + ) + + +def build_rule( + *, + name: str, + event_type: str, + is_active: bool, + conditions: Iterable[dict], + channels: Iterable[dict], +) -> Rule: + rule = Rule(name=name, event_type=event_type, is_active=is_active) + rule.conditions = [ + Condition(field=item["field"], operator=item["operator"], value=item["value"]) + for item in conditions + ] + rule.channels = [ + Channel(type=item["type"], config=item.get("config", {})) for item in channels + ] + return rule diff --git a/src/app/schemas.py b/src/app/schemas.py new file mode 100644 index 0000000..8600cbc --- /dev/null +++ b/src/app/schemas.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import ( + BaseModel, + EmailStr, + Field, + HttpUrl, + TypeAdapter, + ValidationInfo, + field_validator, +) + + +ConditionOperator = Literal["eq", "neq", "gt", "lt", "contains"] +ChannelType = Literal["webhook", "email", "log"] + + +class ConditionCreate(BaseModel): + field: str + operator: ConditionOperator + value: str + + +class ChannelConfig(BaseModel): + url: HttpUrl | None = None + to: EmailStr | None = None + + +class ChannelCreate(BaseModel): + type: ChannelType + config: dict = Field(default_factory=dict) + + @field_validator("config") + @classmethod + def validate_config(cls, value: dict, info: ValidationInfo) -> dict: + channel_type = info.data.get("type") + if channel_type == "webhook": + url = value.get("url") + if not url: + raise ValueError("webhook config requires url") + TypeAdapter(HttpUrl).validate_python(url) + if channel_type == "email": + to = value.get("to") + if not to: + raise ValueError("email config requires to") + TypeAdapter(EmailStr).validate_python(to) + return value + + +class RuleCreate(BaseModel): + name: str + event_type: str + is_active: bool = True + conditions: list[ConditionCreate] + channels: list[ChannelCreate] + + @field_validator("conditions") + @classmethod + def require_conditions(cls, value: list[ConditionCreate]) -> list[ConditionCreate]: + if not value: + raise ValueError("rule must include at least one condition") + return value + + @field_validator("channels") + @classmethod + def require_channels(cls, value: list[ChannelCreate]) -> list[ChannelCreate]: + if not value: + raise ValueError("rule must include at least one channel") + return value + + +class RuleUpdate(BaseModel): + name: str | None = None + is_active: bool | None = None + + +class RuleResponse(BaseModel): + id: str + name: str + is_active: bool + event_type: str + conditions: list[ConditionCreate] + channels: list[ChannelCreate] + + model_config = {"from_attributes": True} + + +class EventPublish(BaseModel): + type: str + payload: dict + + +class EventResponse(BaseModel): + triggered_rules: list[str] + + +class DispatchRecordResponse(BaseModel): + id: str + rule_id: str + channel_type: str + status: Literal["sent", "failed"] + error_message: str | None + dispatched_at: datetime + + model_config = {"from_attributes": True} diff --git a/src/app/services.py b/src/app/services.py new file mode 100644 index 0000000..3807850 --- /dev/null +++ b/src/app/services.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Protocol + +from app.models import Rule +from app.repository import RuleRepository + + +@dataclass(frozen=True) +class DispatchResult: + status: str + error_message: str | None = None + + +class ChannelDispatcher(Protocol): + def dispatch(self, rule: Rule, channel: dict, payload: dict) -> DispatchResult: ... + + +class LogDispatcher: + def dispatch(self, rule: Rule, channel: dict, payload: dict) -> DispatchResult: + return DispatchResult(status="sent") + + +class WebhookDispatcher: + def __init__( + self, delay_seconds: float = 0.0, fail_on_url: str | None = None + ) -> None: + self._delay = delay_seconds + self._fail_on_url = fail_on_url + + def dispatch(self, rule: Rule, channel: dict, payload: dict) -> DispatchResult: + url = channel.get("config", {}).get("url") + if self._delay: + time.sleep(self._delay) + if self._fail_on_url and url == self._fail_on_url: + return DispatchResult(status="failed", error_message="webhook failed") + return DispatchResult(status="sent") + + +class EmailDispatcher: + def dispatch(self, rule: Rule, channel: dict, payload: dict) -> DispatchResult: + return DispatchResult(status="sent") + + +class ConditionEvaluator: + def evaluate(self, rule: Rule, payload: dict) -> bool: + if not rule.conditions: + return True + return all( + self._evaluate_condition(condition, payload) + for condition in rule.conditions + ) + + def _evaluate_condition(self, condition, payload: dict) -> bool: + current = payload + for part in condition.field.split("."): + if not isinstance(current, dict) or part not in current: + return False + current = current[part] + operator = condition.operator + value = condition.value + compare_value = self._coerce_value(current, value) + if operator == "eq": + return current == compare_value + if operator == "neq": + return current != compare_value + if operator == "contains": + if isinstance(current, list): + return compare_value in current + if isinstance(current, str): + return str(compare_value) in current + return False + if operator in {"gt", "lt"}: + if not isinstance(current, (int, float)): + return False + try: + numeric_value = float(value) + except ValueError: + return False + return ( + current > numeric_value if operator == "gt" else current < numeric_value + ) + return False + + def _coerce_value(self, current, value: str): + if isinstance(current, bool): + return value.lower() in {"true", "1", "yes"} + if isinstance(current, int) and not isinstance(current, bool): + try: + return int(value) + except ValueError: + return value + if isinstance(current, float): + try: + return float(value) + except ValueError: + return value + if isinstance(current, list) and current: + first = current[0] + if isinstance(first, bool): + return value.lower() in {"true", "1", "yes"} + if isinstance(first, int) and not isinstance(first, bool): + try: + return int(value) + except ValueError: + return value + if isinstance(first, float): + try: + return float(value) + except ValueError: + return value + return value + + +class ChannelRegistry: + def __init__( + self, webhook_delay: float = 0.0, fail_webhook_url: str | None = None + ) -> None: + self._registry = { + "webhook": WebhookDispatcher( + delay_seconds=webhook_delay, fail_on_url=fail_webhook_url + ), + "email": EmailDispatcher(), + "log": LogDispatcher(), + } + + def dispatcher_for(self, channel_type: str) -> ChannelDispatcher: + return self._registry[channel_type] + + +class DispatchService: + def __init__( + self, + repository: RuleRepository, + evaluator: ConditionEvaluator, + registry: ChannelRegistry, + ) -> None: + self._repository = repository + self._evaluator = evaluator + self._registry = registry + + def dispatch_for_event(self, event_type: str, payload: dict) -> list[str]: + triggered: list[str] = [] + rules = self._repository.list_active_rules_by_event(event_type) + for rule in rules: + if self._evaluator.evaluate(rule, payload): + triggered.append(rule.name) + for channel in rule.channels: + self._dispatch_channel(rule, channel, payload) + return triggered + + def _dispatch_channel(self, rule: Rule, channel, payload: dict) -> None: + dispatcher = self._registry.dispatcher_for(channel.type) + try: + result = dispatcher.dispatch( + rule, {"type": channel.type, "config": channel.config}, payload + ) + except Exception as exc: # noqa: BLE001 + result = DispatchResult(status="failed", error_message=str(exc)) + self._repository.add_dispatch_record( + rule_id=rule.id, + channel_type=channel.type, + status=result.status, + error_message=result.error_message, + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c615533 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import time +from pathlib import Path +import sys + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = ROOT / "src" +sys.path.insert(0, str(SRC_PATH)) + +from app.database import Base +from app.main import create_app + + +@pytest.fixture() +def db_engine(): + engine = create_engine( + "sqlite+pysqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def db_session(db_engine): + session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + with session_local() as session: + yield session + + +@pytest.fixture() +def client(db_engine): + session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) + app = create_app( + session_local=session_local, + init_db=False, + webhook_delay_seconds=0.3, + fail_webhook_url="https://fail.example.com", + ) + with TestClient(app) as test_client: + yield test_client + for thread in getattr(app.state, "dispatch_threads", []): + thread.join(timeout=1.0) + + +def build_rule_payload( + *, + name: str, + event_type: str, + conditions: list[dict], + channels: list[dict], + is_active: bool = True, +) -> dict: + return { + "name": name, + "event_type": event_type, + "is_active": is_active, + "conditions": conditions, + "channels": channels, + } + + +def wait_for_dispatch_records( + client: TestClient, rule_id: str, expected: int, timeout: float = 1.0 +) -> list[dict]: + start = time.monotonic() + last_seen: list[dict] = [] + while time.monotonic() - start < timeout: + response = client.get(f"/dispatch-records?rule_id={rule_id}") + last_seen = response.json() + if len(last_seen) >= expected: + return last_seen + time.sleep(0.01) + return last_seen + + +def assert_no_dispatch_records( + client: TestClient, rule_id: str, timeout: float = 0.3 +) -> None: + start = time.monotonic() + while time.monotonic() - start < timeout: + response = client.get(f"/dispatch-records?rule_id={rule_id}") + if response.json(): + raise AssertionError("expected no dispatch records") + time.sleep(0.01) diff --git a/tests/test_f1-rule-management-crud.py b/tests/test_f1-rule-management-crud.py new file mode 100644 index 0000000..a54043a --- /dev/null +++ b/tests/test_f1-rule-management-crud.py @@ -0,0 +1,250 @@ +""" +Auto-generated skeleton tests from Markdown specs. +Regenerate using: specleft test skeleton + +Generated by SpecLeft - https://github.com/SpecLeft/specleft +""" + +from specleft import specleft +from sqlalchemy import text + +# ============================================================================= +# Feature: F1 — Rule Management (CRUD) +# ID: f1-rule-management-crud +# ============================================================================= + +# Story: Default +# ID: default + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="create-a-valid-rule", +) +def test_create_a_valid_rule(client): + """Create a valid rule + + Priority: high + """ + with specleft.step("Given a rule payload with one condition and one log channel"): + payload = { + "name": "alert-on-signup", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 201 with the created rule and its id"): + assert response.status_code == 201 + data = response.json() + assert data["id"] + assert data["name"] == payload["name"] + assert data["event_type"] == payload["event_type"] + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="delete-cascades-to-conditions-and-channels", +) +def test_delete_cascades_to_conditions_and_channels(client, db_session): + """Delete cascades to conditions and channels + + Priority: high + """ + with specleft.step("Given an existing rule with two conditions and one channel"): + payload = { + "name": "cascade-rule", + "event_type": "user.created", + "conditions": [ + {"field": "user.role", "operator": "eq", "value": "admin"}, + {"field": "user.id", "operator": "neq", "value": "0"}, + ], + "channels": [{"type": "log", "config": {}}], + } + create_response = client.post("/rules", json=payload) + rule_id = create_response.json()["id"] + + with specleft.step("When I DELETE the rule"): + response = client.delete(f"/rules/{rule_id}") + + with specleft.step("Then the conditions and channel are also removed"): + assert response.status_code == 204 + condition_count = db_session.execute( + text("select count(*) from conditions") + ).scalar() + channel_count = db_session.execute( + text("select count(*) from channels") + ).scalar() + assert condition_count == 0 + assert channel_count == 0 + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="get-rule-by-id", +) +def test_get_rule_by_id(client): + """Get rule by id + + Priority: medium + """ + with specleft.step("Given an existing rule"): + payload = { + "name": "get-rule", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + create_response = client.post("/rules", json=payload) + rule_id = create_response.json()["id"] + + with specleft.step(f"When I GET /rules/{id}"): + response = client.get(f"/rules/{rule_id}") + + with specleft.step("Then I receive 200 with the rule"): + assert response.status_code == 200 + data = response.json() + assert data["id"] == rule_id + assert data["name"] == payload["name"] + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="list-rules", +) +def test_list_rules(client): + """List rules + + Priority: medium + """ + with specleft.step("Given two existing rules"): + payload_one = { + "name": "list-rule-one", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + payload_two = { + "name": "list-rule-two", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "member"}], + "channels": [{"type": "log", "config": {}}], + } + client.post("/rules", json=payload_one) + client.post("/rules", json=payload_two) + + with specleft.step("When I GET /rules"): + response = client.get("/rules") + + with specleft.step("Then I receive 200 with both rules"): + assert response.status_code == 200 + data = response.json() + names = {item["name"] for item in data} + assert {payload_one["name"], payload_two["name"]}.issubset(names) + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="reject-duplicate-rule-name", +) +def test_reject_duplicate_rule_name(client): + """Reject duplicate rule name + + Priority: high + """ + with specleft.step('Given an existing rule named "alert-on-signup"'): + payload = { + "name": "alert-on-signup", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + client.post("/rules", json=payload) + + with specleft.step('When I POST a new rule with name "alert-on-signup"'): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 409 Conflict"): + assert response.status_code == 409 + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="rule-creation-rejected-without-a-channel", +) +def test_rule_creation_rejected_without_a_channel(client): + """Rule creation rejected without a channel + + Priority: high + """ + with specleft.step("Given a rule payload with one condition and no channels"): + payload = { + "name": "missing-channel", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 422"): + assert response.status_code == 422 + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="rule-creation-rejected-without-a-condition", +) +def test_rule_creation_rejected_without_a_condition(client): + """Rule creation rejected without a condition + + Priority: high + """ + with specleft.step("Given a rule payload with no conditions and one log channel"): + payload = { + "name": "missing-condition", + "event_type": "user.created", + "conditions": [], + "channels": [{"type": "log", "config": {}}], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 422"): + assert response.status_code == 422 + + +@specleft( + feature_id="f1-rule-management-crud", + scenario_id="update-rule-fields", +) +def test_update_rule_fields(client): + """Update rule fields + + Priority: medium + """ + with specleft.step("Given an existing rule"): + payload = { + "name": "update-rule", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + create_response = client.post("/rules", json=payload) + rule_id = create_response.json()["id"] + + with specleft.step(f"When I PATCH /rules/{id} with a new name and is_active false"): + response = client.patch( + f"/rules/{rule_id}", json={"name": "updated-rule", "is_active": False} + ) + + with specleft.step("Then I receive 200 with the updated rule"): + assert response.status_code == 200 + data = response.json() + assert data["name"] == "updated-rule" + assert data["is_active"] is False diff --git a/tests/test_f2-conditions.py b/tests/test_f2-conditions.py new file mode 100644 index 0000000..5ad05a4 --- /dev/null +++ b/tests/test_f2-conditions.py @@ -0,0 +1,107 @@ +""" +Auto-generated skeleton tests from Markdown specs. +Regenerate using: specleft test skeleton + +Generated by SpecLeft - https://github.com/SpecLeft/specleft +""" + +from specleft import specleft + +from conftest import assert_no_dispatch_records + +# ============================================================================= +# Feature: F2 — Conditions +# ID: f2-conditions +# ============================================================================= + +# Story: Default +# ID: default + + +@specleft( + feature_id="f2-conditions", + scenario_id="gt-on-non-numeric-evaluates-false", +) +def test_gt_on_non_numeric_evaluates_false(client): + """gt on non-numeric evaluates false + + Priority: high + """ + with specleft.step('Given a rule with condition "user.role" gt "5"'): + payload = { + "name": "gt-non-numeric", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "gt", "value": "5"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step('When I POST an event with payload {"user": {"role": "admin"}}'): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step("Then the rule is not triggered"): + assert event_response.status_code == 202 + assert payload["name"] not in event_response.json()["triggered_rules"] + assert_no_dispatch_records(client, rule_id) + + +@specleft( + feature_id="f2-conditions", + scenario_id="missing-field-path-evaluates-false", +) +def test_missing_field_path_evaluates_false(client): + """Missing field path evaluates false + + Priority: high + """ + with specleft.step('Given a rule with condition "user.role" eq "admin"'): + payload = { + "name": "missing-field", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step('When I POST an event with payload missing "user.role"'): + event_response = client.post( + "/events", json={"type": "user.created", "payload": {"user": {}}} + ) + + with specleft.step("Then the rule is not triggered"): + assert event_response.status_code == 202 + assert payload["name"] not in event_response.json()["triggered_rules"] + assert_no_dispatch_records(client, rule_id) + + +@specleft( + feature_id="f2-conditions", + scenario_id="reject-unknown-operator", +) +def test_reject_unknown_operator(client): + """Reject unknown operator + + Priority: high + """ + with specleft.step( + 'Given a rule payload with a condition using operator "between"' + ): + payload = { + "name": "invalid-operator", + "event_type": "user.created", + "conditions": [ + {"field": "user.role", "operator": "between", "value": "admin"} + ], + "channels": [{"type": "log", "config": {}}], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 422"): + assert response.status_code == 422 diff --git a/tests/test_f3-channels.py b/tests/test_f3-channels.py new file mode 100644 index 0000000..52efce1 --- /dev/null +++ b/tests/test_f3-channels.py @@ -0,0 +1,106 @@ +""" +Auto-generated skeleton tests from Markdown specs. +Regenerate using: specleft test skeleton + +Generated by SpecLeft - https://github.com/SpecLeft/specleft +""" + +from specleft import specleft + +from conftest import wait_for_dispatch_records + +# ============================================================================= +# Feature: F3 — Channels +# ID: f3-channels +# ============================================================================= + +# Story: Default +# ID: default + + +@specleft( + feature_id="f3-channels", + scenario_id="one-channel-failure-does-not-block-others", +) +def test_one_channel_failure_does_not_block_others(client): + """One channel failure does not block others + + Priority: high + """ + with specleft.step("Given a rule with a failing webhook channel and a log channel"): + payload = { + "name": "multi-channel", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [ + {"type": "webhook", "config": {"url": "https://fail.example.com"}}, + {"type": "log", "config": {}}, + ], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step("When the rule fires"): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step("Then the log channel dispatches successfully"): + assert event_response.status_code == 202 + records = wait_for_dispatch_records(client, rule_id, expected=2) + statuses = {(record["channel_type"], record["status"]) for record in records} + assert ("log", "sent") in statuses + + with specleft.step( + 'And a DispatchRecord with status "failed" exists for the webhook' + ): + assert ("webhook", "failed") in statuses + + +@specleft( + feature_id="f3-channels", + scenario_id="reject-invalid-email-config", +) +def test_reject_invalid_email_config(client): + """Reject invalid email config + + Priority: high + """ + with specleft.step("Given a rule payload with an email channel missing to"): + payload = { + "name": "invalid-email", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "email", "config": {}}], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 422"): + assert response.status_code == 422 + + +@specleft( + feature_id="f3-channels", + scenario_id="reject-invalid-webhook-config", +) +def test_reject_invalid_webhook_config(client): + """Reject invalid webhook config + + Priority: high + """ + with specleft.step("Given a rule payload with a webhook channel missing url"): + payload = { + "name": "invalid-webhook", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "webhook", "config": {}}], + } + + with specleft.step("When I POST to /rules"): + response = client.post("/rules", json=payload) + + with specleft.step("Then I receive 422"): + assert response.status_code == 422 diff --git a/tests/test_f4-event-publishing.py b/tests/test_f4-event-publishing.py new file mode 100644 index 0000000..908d73f --- /dev/null +++ b/tests/test_f4-event-publishing.py @@ -0,0 +1,187 @@ +""" +Auto-generated skeleton tests from Markdown specs. +Regenerate using: specleft test skeleton + +Generated by SpecLeft - https://github.com/SpecLeft/specleft +""" + +import time + +from specleft import specleft + +from conftest import assert_no_dispatch_records, wait_for_dispatch_records + +# ============================================================================= +# Feature: F4 — Event Publishing +# ID: f4-event-publishing +# ============================================================================= + +# Story: Default +# ID: default + + +@specleft( + feature_id="f4-event-publishing", + scenario_id="event-response-is-non-blocking", +) +def test_event_response_is_non_blocking(client): + """Event response is non-blocking + + Priority: medium + """ + with specleft.step("Given a rule with a slow webhook channel"): + payload = { + "name": "slow-webhook", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [ + {"type": "webhook", "config": {"url": "https://slow.example.com"}} + ], + } + client.post("/rules", json=payload) + + with specleft.step("When I POST a matching event"): + start = time.monotonic() + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + elapsed = time.monotonic() - start + + with specleft.step("Then the response returns 202 before the webhook completes"): + assert event_response.status_code == 202 + assert elapsed < 0.2 + + +@specleft( + feature_id="f4-event-publishing", + scenario_id="inactive-rule-is-skipped", +) +def test_inactive_rule_is_skipped(client): + """Inactive rule is skipped + + Priority: high + """ + with specleft.step("Given a deactivated rule matching the event type"): + payload = { + "name": "inactive-rule", + "event_type": "user.created", + "is_active": False, + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step("When I POST a matching event"): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step("Then the rule is not triggered"): + assert event_response.status_code == 202 + assert payload["name"] not in event_response.json()["triggered_rules"] + assert_no_dispatch_records(client, rule_id) + + +@specleft( + feature_id="f4-event-publishing", + scenario_id="only-matching-event-type-rules-evaluated", +) +def test_only_matching_event_type_rules_evaluated(client): + """Only matching event_type rules evaluated + + Priority: medium + """ + with specleft.step('Given a rule listening for event_type "order.created"'): + payload = { + "name": "order-rule", + "event_type": "order.created", + "conditions": [{"field": "order.total", "operator": "gt", "value": "10"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step('When I POST an event with type "user.created"'): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step("Then the rule is not triggered"): + assert event_response.status_code == 202 + assert payload["name"] not in event_response.json()["triggered_rules"] + assert_no_dispatch_records(client, rule_id) + + +@specleft( + feature_id="f4-event-publishing", + scenario_id="rule-does-not-fire-when-a-condition-fails", +) +def test_rule_does_not_fire_when_a_condition_fails(client): + """Rule does not fire when a condition fails + + Priority: high + """ + with specleft.step("Given the same rule"): + payload = { + "name": "role-admin", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step( + 'When I POST an event with payload {"user": {"role": "member"}}' + ): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "member"}}}, + ) + + with specleft.step("Then no DispatchRecord is created for that rule"): + assert event_response.status_code == 202 + assert_no_dispatch_records(client, rule_id) + + +@specleft( + feature_id="f4-event-publishing", + scenario_id="rule-fires-when-all-conditions-match", +) +def test_rule_fires_when_all_conditions_match(client): + """Rule fires when all conditions match + + Priority: high + """ + with specleft.step( + 'Given a rule with event_type "user.created" and condition user.role eq "admin"' + ): + payload = { + "name": "admin-rule", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + + with specleft.step( + 'When I POST an event {"type": "user.created", "payload": {"user": {"role": "admin"}}}' + ): + event_response = client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step( + 'Then the rule is triggered and a DispatchRecord with status "sent" is created' + ): + assert event_response.status_code == 202 + assert payload["name"] in event_response.json()["triggered_rules"] + records = wait_for_dispatch_records(client, rule_id, expected=1) + assert len(records) == 1 + assert records[0]["status"] == "sent" diff --git a/tests/test_f5-dispatch-records.py b/tests/test_f5-dispatch-records.py new file mode 100644 index 0000000..228a537 --- /dev/null +++ b/tests/test_f5-dispatch-records.py @@ -0,0 +1,55 @@ +""" +Auto-generated skeleton tests from Markdown specs. +Regenerate using: specleft test skeleton + +Generated by SpecLeft - https://github.com/SpecLeft/specleft +""" + +from specleft import specleft + +from conftest import wait_for_dispatch_records + +# ============================================================================= +# Feature: F5 — Dispatch Records +# ID: f5-dispatch-records +# ============================================================================= + +# Story: Default +# ID: default + + +@specleft( + feature_id="f5-dispatch-records", + scenario_id="records-returned-newest-first", +) +def test_records_returned_newest_first(client): + """Records returned newest first + + Priority: high + """ + with specleft.step( + "Given two events that triggered the same rule at different times" + ): + payload = { + "name": "record-order", + "event_type": "user.created", + "conditions": [{"field": "user.role", "operator": "eq", "value": "admin"}], + "channels": [{"type": "log", "config": {}}], + } + rule_response = client.post("/rules", json=payload) + rule_id = rule_response.json()["id"] + client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + client.post( + "/events", + json={"type": "user.created", "payload": {"user": {"role": "admin"}}}, + ) + + with specleft.step(f"When I GET /dispatch-records?rule_id={id}"): + records = wait_for_dispatch_records(client, rule_id, expected=2) + + with specleft.step("Then the more recent record appears first"): + assert len(records) >= 2 + assert records[0]["dispatched_at"] >= records[1]["dispatched_at"]