feat(qg0): configurable QG-0 title limit via ORCH_QG0_TITLE_MAX (default 200)
Replace the hardcoded `len(name) > 80` cap in the QG-0 entry validation (_qg0_errors) with a configurable Settings.qg0_title_max (env ORCH_QG0_TITLE_MAX, default 200). The 80-char cap was a hygiene limit, not structural, so valid 81-200 char titles were rejected without a business reason. The limit is read dynamically per call and the error text interpolates the active value. Graceful degradation (AC-3, self-hosting safety): an empty/non-numeric env value no longer crashes the process on startup. A field_validator(mode="before") intercepts the raw env before int-parsing and falls back to 200 (never raises), suppressing pydantic ValidationError. Additive and backward-compatible (default 200 > old 80). Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry, DB schema, slug [:30], lower limits, soft-QG-0 warning path, API. Refs: ORCH-069 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -199,3 +199,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
|
||||
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
|
||||
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
|
||||
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500
|
||||
|
||||
# ── QG-0 entry validation (ORCH-069) ──────────────────────────────────────────
|
||||
# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the
|
||||
# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully
|
||||
# degrades to 200 (the process never crashes on startup).
|
||||
ORCH_QG0_TITLE_MAX=200
|
||||
|
||||
@@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0
|
||||
DEPLOY_SSH_USER=slin
|
||||
DEPLOY_SSH_HOST=127.0.0.1
|
||||
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||
|
||||
# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200.
|
||||
ORCH_QG0_TITLE_MAX=200
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -407,6 +408,24 @@ class Settings(BaseSettings):
|
||||
# Неизвестное/пустое значение трактуется как edit (см. notifications).
|
||||
tracker_mode: str = "edit"
|
||||
|
||||
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
|
||||
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
|
||||
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
|
||||
# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash).
|
||||
qg0_title_max: int = 200
|
||||
|
||||
@field_validator("qg0_title_max", mode="before")
|
||||
@classmethod
|
||||
def _qg0_title_max_default(cls, v):
|
||||
# Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the
|
||||
# process must not crash on startup. Never raises (self-hosting safety).
|
||||
try:
|
||||
if v is None or (isinstance(v, str) and v.strip() == ""):
|
||||
return 200
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 200
|
||||
|
||||
class Config:
|
||||
env_prefix = "ORCH_"
|
||||
env_file = ".env"
|
||||
|
||||
@@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list:
|
||||
errors = []
|
||||
if not name or len(name) < 5:
|
||||
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
if len(name) > 80:
|
||||
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
if len(name) > settings.qg0_title_max:
|
||||
errors.append(
|
||||
f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 "
|
||||
f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)"
|
||||
)
|
||||
if not description or len(description.strip()) < 20:
|
||||
errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
|
||||
|
||||
|
||||
117
tests/test_qg0_title_limit.py
Normal file
117
tests/test_qg0_title_limit.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""ORCH-069: unit tests for the configurable QG-0 title-length limit.
|
||||
|
||||
Covers `_qg0_errors` (src/webhooks/plane.py) reading the upper title limit
|
||||
dynamically from `settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, default 200),
|
||||
plus the graceful env-degradation field-validator on `Settings`.
|
||||
|
||||
The tests patch `src.config.settings.qg0_title_max` (the same object imported into
|
||||
`src.webhooks.plane`) and assert boundary behaviour and error texts. For env-driven
|
||||
cases a FRESH `Settings()` instance is created locally, since the module-level
|
||||
singleton is built once on import.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import Settings, settings
|
||||
from src.webhooks.plane import _qg0_errors
|
||||
|
||||
VALID_DESCRIPTION = "x" * 30 # >= 20 chars, always passes the description check
|
||||
|
||||
|
||||
def _title_length_error(errors):
|
||||
"""Return the title length-limit error string, or None if absent.
|
||||
|
||||
The short-title error ('нужно >= 5') and the description error are excluded;
|
||||
only the 'too long' title error is matched (it contains 'максимум').
|
||||
"""
|
||||
for e in errors:
|
||||
if "Title" in e and "максимум" in e:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# --- AC-1: default limit 200, boundary at 201 ------------------------------
|
||||
|
||||
def test_tc01_default_limit_200_boundary_pass(monkeypatch):
|
||||
"""TC-01: title of exactly 200 chars -> no title length error (PASS)."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 200, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
|
||||
|
||||
def test_tc02_default_limit_200_boundary_fail(monkeypatch):
|
||||
"""TC-02: title of 201 chars -> length error mentioning '200'."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 201, VALID_DESCRIPTION)
|
||||
err = _title_length_error(errors)
|
||||
assert err is not None
|
||||
assert "200" in err
|
||||
|
||||
|
||||
# --- AC-2: configurable limit 120, boundary at 121 -------------------------
|
||||
|
||||
def test_tc03_custom_limit_120_boundary_pass(monkeypatch):
|
||||
"""TC-03: with limit 120, a 120-char title passes."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 120)
|
||||
errors = _qg0_errors("x" * 120, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
|
||||
|
||||
def test_tc04_custom_limit_120_boundary_fail(monkeypatch):
|
||||
"""TC-04: with limit 120, a 121-char title fails; text mentions 120 not 80."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 120)
|
||||
errors = _qg0_errors("x" * 121, VALID_DESCRIPTION)
|
||||
err = _title_length_error(errors)
|
||||
assert err is not None
|
||||
assert "120" in err
|
||||
assert "80" not in err
|
||||
|
||||
|
||||
# --- AC-3: graceful handling of invalid/empty env --------------------------
|
||||
|
||||
def test_tc05_graceful_non_numeric_env(monkeypatch):
|
||||
"""TC-05: non-numeric env -> Settings() does not raise, limit == 200."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "abc")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 200
|
||||
|
||||
|
||||
def test_tc06_graceful_empty_env(monkeypatch):
|
||||
"""TC-06: empty-string env -> default 200, no exception."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 200
|
||||
|
||||
|
||||
def test_tc07_valid_numeric_env(monkeypatch):
|
||||
"""TC-07: valid numeric env -> the given value is applied (positive path)."""
|
||||
monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "150")
|
||||
s = Settings()
|
||||
assert s.qg0_title_max == 150
|
||||
|
||||
|
||||
# --- AC-4: lower limits unchanged ------------------------------------------
|
||||
|
||||
def test_tc08_short_title_still_errors(monkeypatch):
|
||||
"""TC-08: title < 5 chars still raises the short-title error."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("abc", VALID_DESCRIPTION)
|
||||
assert any("Title" in e and "нужно >= 5" in e for e in errors)
|
||||
|
||||
|
||||
def test_tc09_short_description_still_errors(monkeypatch):
|
||||
"""TC-09: description < 20 chars still raises the short-description error."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("Valid title", "short")
|
||||
assert any("Description" in e for e in errors)
|
||||
|
||||
|
||||
# --- AC-7: backward compatibility ------------------------------------------
|
||||
|
||||
def test_tc10_backward_compat_titles_81_to_200(monkeypatch):
|
||||
"""TC-10: a title previously rejected by the 80-char cap now passes at 200."""
|
||||
monkeypatch.setattr(settings, "qg0_title_max", 200)
|
||||
errors = _qg0_errors("x" * 100, VALID_DESCRIPTION)
|
||||
assert _title_length_error(errors) is None
|
||||
Reference in New Issue
Block a user