mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 22:13:08 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""Tests for scheduler_utils (cron / DST / delay / horizon)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from docsgpt.agents.scheduler_utils import (
|
|
ScheduleValidationError,
|
|
clamp_once_horizon,
|
|
cron_interval_seconds,
|
|
next_cron_run,
|
|
parse_cron,
|
|
parse_delay,
|
|
parse_run_at,
|
|
resolve_timezone,
|
|
)
|
|
|
|
|
|
class TestParseCron:
|
|
def test_valid(self):
|
|
parse_cron("0 9 * * 1")
|
|
|
|
def test_invalid(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_cron("not a cron")
|
|
|
|
def test_wrong_field_count(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_cron("0 9 * *")
|
|
|
|
|
|
class TestNextCronRunDST:
|
|
def test_daily_9am_warsaw_across_spring_forward(self):
|
|
tz = ZoneInfo("Europe/Warsaw")
|
|
before_dst = datetime(2026, 3, 28, 9, 30, tzinfo=tz)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=before_dst)
|
|
assert nxt.astimezone(tz) == datetime(2026, 3, 29, 9, 0, tzinfo=tz)
|
|
|
|
def test_daily_9am_warsaw_across_fall_back(self):
|
|
tz = ZoneInfo("Europe/Warsaw")
|
|
before_dst = datetime(2026, 10, 24, 9, 30, tzinfo=tz)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=before_dst)
|
|
assert nxt.astimezone(tz) == datetime(2026, 10, 25, 9, 0, tzinfo=tz)
|
|
|
|
def test_utc_default(self):
|
|
anchor = datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
nxt = next_cron_run("0 * * * *", None, after=anchor)
|
|
assert nxt > anchor
|
|
assert nxt.tzinfo is not None
|
|
|
|
def test_returned_value_is_utc(self):
|
|
anchor = datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
nxt = next_cron_run("0 9 * * *", "Europe/Warsaw", after=anchor)
|
|
assert nxt.tzinfo is not None
|
|
assert nxt.utcoffset() == timedelta(0)
|
|
|
|
|
|
class TestResolveTimezone:
|
|
def test_unknown(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
resolve_timezone("Atlantis/Nowhere")
|
|
|
|
def test_blank_defaults_utc(self):
|
|
assert resolve_timezone("").key == "UTC"
|
|
assert resolve_timezone(None).key == "UTC"
|
|
|
|
|
|
class TestParseDelay:
|
|
@pytest.mark.parametrize(
|
|
"raw,seconds",
|
|
[("30s", 30), ("15m", 900), ("2h", 7200), ("1d", 86_400)],
|
|
)
|
|
def test_units(self, raw, seconds):
|
|
assert parse_delay(raw).total_seconds() == seconds
|
|
|
|
def test_uppercase(self):
|
|
assert parse_delay("2H").total_seconds() == 7200
|
|
|
|
def test_zero_rejected(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_delay("0m")
|
|
|
|
def test_garbage(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_delay("two hours")
|
|
|
|
|
|
class TestParseRunAt:
|
|
def test_iso_utc(self):
|
|
parsed = parse_run_at("2026-05-19T12:00:00Z")
|
|
assert parsed.tzinfo is not None
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_iso_with_offset(self):
|
|
parsed = parse_run_at("2026-05-19T14:00:00+02:00")
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_naive_uses_tz(self):
|
|
parsed = parse_run_at("2026-05-19T14:00:00", "Europe/Warsaw")
|
|
assert parsed == datetime(2026, 5, 19, 12, 0, tzinfo=timezone.utc)
|
|
|
|
def test_invalid(self):
|
|
with pytest.raises(ScheduleValidationError):
|
|
parse_run_at("not a date")
|
|
|
|
|
|
class TestCronIntervalSeconds:
|
|
def test_every_minute_returns_60s(self):
|
|
assert cron_interval_seconds("* * * * *", None) == 60
|
|
|
|
def test_hourly_returns_3600s(self):
|
|
assert cron_interval_seconds("0 * * * *", None) == 3600
|
|
|
|
def test_bursty_cron_returns_smallest_gap(self):
|
|
# '* 9 * * *' has 60s gaps inside the 9 AM burst; sampling two adjacent
|
|
# ticks at random can miss them — the rolling window must catch the 60.
|
|
assert cron_interval_seconds("* 9 * * *", None) == 60
|
|
|
|
def test_bursty_cron_rejected_when_floor_above_burst(self):
|
|
from docsgpt.core.settings import settings as app_settings
|
|
burst = "* 9 * * *"
|
|
cadence = cron_interval_seconds(burst, None)
|
|
floor = max(0, int(app_settings.SCHEDULE_MIN_INTERVAL))
|
|
assert cadence < floor, (
|
|
f"bursty cron {burst!r} cadence {cadence}s must be below the "
|
|
f"configured SCHEDULE_MIN_INTERVAL floor ({floor}s)"
|
|
)
|
|
|
|
|
|
class TestClampOnceHorizon:
|
|
def test_rejects_past(self):
|
|
past = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
with pytest.raises(ScheduleValidationError):
|
|
clamp_once_horizon(past, max_horizon_seconds=3600)
|
|
|
|
def test_rejects_beyond_horizon(self):
|
|
far = datetime.now(timezone.utc) + timedelta(days=400)
|
|
with pytest.raises(ScheduleValidationError):
|
|
clamp_once_horizon(far, max_horizon_seconds=365 * 86_400)
|
|
|
|
def test_accepts_in_range(self):
|
|
soon = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
clamp_once_horizon(soon, max_horizon_seconds=86_400)
|