Files
AWatch-rus/aw-server/test_aw_worktime_api.py
T

717 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import importlib.util
import json
import tempfile
from datetime import datetime, timezone
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py")
SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class _FakeWriter:
def __init__(self, exc=None):
self.exc = exc
self.data = b""
def write(self, data):
if self.exc is not None:
raise self.exc
self.data += data
class _FakeHandler:
def __init__(self, exc=None):
self.wfile = _FakeWriter(exc=exc)
self.status = None
self.headers = {}
self.ended = False
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
self.ended = True
def _event(ts, username, session_id, active, **extra):
data = {
"username": username,
"userId": f"WORKGROUP\\{username}",
"sessionId": session_id,
"state": "Активно" if active else "Диск",
"active": active,
}
data.update(extra)
return {"timestamp": ts, "duration": 0.0, "data": data}
def test_aggregate_rows_uses_sample_seconds_and_merges_overlap():
start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 6, 59, 59, tzinfo=timezone.utc)
rows = MODULE.aggregate_rows(
[
_event("2026-05-14T06:00:00Z", "user5", 4, True, sampleSeconds=30),
_event("2026-05-14T06:00:30Z", "user5", 4, True, sampleSeconds=30),
_event("2026-05-14T06:00:15Z", "user5", 5, True, sampleSeconds=30),
_event("2026-05-14T06:01:00Z", "user5", 4, False, sampleSeconds=30),
],
start,
end,
"SHARKON2025",
)
assert len(rows) == 1
row = rows[0]
assert row["user"] == "user5"
assert row["user_id"] == "SHARKON2025\\user5"
assert row["active_seconds"] == 60
assert row["active_hhmm"] == "00:01"
assert row["sessions_count"] == 2
assert row["samples_count"] == 4
assert row["active_samples"] == 3
assert row["first_activity"] == "2026-05-14T06:00:00Z"
assert row["last_activity"] == "2026-05-14T06:01:00Z"
def test_aggregate_rows_falls_back_to_next_sample_delta():
start = datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc)
rows = MODULE.aggregate_rows(
[
_event("2026-05-14T07:00:00Z", "user1", 3, True),
_event("2026-05-14T07:00:05Z", "user1", 3, True),
_event("2026-05-14T07:00:10Z", "user1", 3, False),
],
start,
end,
"SHARKON2025",
)
row = rows[0]
assert row["active_seconds"] == 10
assert row["active_hhmm"] == "00:00"
def test_build_aw_api_base_accepts_root_and_api_urls():
assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0"
assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0"
assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0"
def test_worktime_health_payload_is_lightweight_and_ok():
payload = MODULE.worktime_health_payload()
assert payload["ok"] is True
assert payload["default_host"] == MODULE.DEFAULT_HOST
assert payload["aw_api_base"] == MODULE.AW
assert payload["report_timezone"] == str(MODULE.REPORT_TZ)
assert payload["generated_at_utc"].endswith("Z")
def test_aggregate_hourly_rows_splits_interval_by_local_hour():
start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 8, 59, 59, tzinfo=timezone.utc)
rows = MODULE.aggregate_hourly_rows(
[
_event("2026-05-14T06:50:00Z", "user5", 4, True, sampleSeconds=1800),
_event("2026-05-14T07:20:00Z", "user5", 4, True, sampleSeconds=1800),
],
start,
end,
"SHARKON2025",
)
assert [row["hour_local"] for row in rows] == ["09:00", "10:00"]
assert [row["active_seconds"] for row in rows] == [300, 300]
def test_build_true_active_apps_requires_foreground_not_afk_and_evidence():
start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc)
window_events = [
{"timestamp": "2026-05-14T06:00:00Z", "duration": 120, "data": {"app": "1cv8.exe", "title": "ИНФОВЕСТ"}},
{"timestamp": "2026-05-14T06:02:00Z", "duration": 180, "data": {"app": "1cv8.exe", "title": "Счета учета: Материалы"}},
{"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"app": "totalcmd.exe", "title": "Total Commander"}},
]
afk_events = [
{"timestamp": "2026-05-14T06:00:00Z", "duration": 600, "data": {"status": "not-afk"}},
{"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"status": "not-afk"}},
]
evidence_events_by_bucket = {
"aw-file-operations_SHARKON2025": [
{
"timestamp": "2026-05-14T07:05:00Z",
"duration": 0,
"data": {"signalType": "file_write", "path": "C:\\data\\report.xlsx"},
}
],
"aw-dlp-endpoint-signals_SHARKON2025": [
{
"timestamp": "2026-05-14T07:06:00Z",
"duration": 0,
"data": {"signalType": "collector_health", "eventsFlushed": 100},
}
],
}
rows = MODULE.build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end)
by_app = {row["application"]: row for row in rows}
assert "1С" in by_app
assert "Total Commander" in by_app
assert by_app["1С"]["proved_work_seconds"] == 300
assert by_app["1С"]["last_action"] == "Счета учета: Материалы"
assert by_app["Total Commander"]["proved_work_seconds"] == 480
assert by_app["Total Commander"]["last_action"] == "C:\\data\\report.xlsx"
def test_render_html_contains_true_active_apps_table():
html = MODULE.render_html(
[],
"SHARKON2025",
datetime(2026, 5, 14, tzinfo=timezone.utc).date(),
selected_day="today",
true_active_apps=[
{
"application": "1С",
"proved_work_human": "34 мин",
"proved_work_hhmm": "00:34",
"last_action_local": "15:31",
"last_action": "Счета учета: Материалы",
}
],
)
assert "Доказанная работа по приложениям" in html
assert "Приложение" in html
assert "Доказанная работа" in html
assert "Последнее действие" in html
assert "Счета учета: Материалы" in html
def test_build_management_payload_creates_actions_for_missing_and_late_users():
rows = [
{
"user": "user1",
"user_id": "SHARKON2025\\user1",
"active_seconds": 0,
"active_hhmm": "00:00",
"first_activity": "",
"last_activity": "",
"idle_seconds": 86400,
"sessions_count": 1,
"samples_count": 10,
"active_samples": 0,
"_intervals": [],
},
{
"user": "user5",
"user_id": "SHARKON2025\\user5",
"active_seconds": 5400,
"active_hhmm": "01:30",
"first_activity": "2026-05-14T08:30:00Z",
"last_activity": "2026-05-14T10:00:00Z",
"idle_seconds": 81000,
"sessions_count": 1,
"samples_count": 180,
"active_samples": 180,
"_intervals": [
(
datetime(2026, 5, 14, 8, 30, 0, tzinfo=timezone.utc),
datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc),
)
],
},
]
payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
assert payload["summary"]["users_count"] == 2
assert payload["summary"]["inactive_users"] == 1
assert payload["summary"]["actions_count"] >= 2
action_ids = {action["action_id"] for action in payload["actions"]}
assert "missing_activity" in action_ids
assert "late_start_review" in action_ids
assert payload["actions"][0]["priority"] == "critical"
def test_build_management_payload_uses_workday_window_not_midnight_activity():
rows = [
{
"user": "администратор",
"user_id": "SHARKON2025\\Администратор",
"active_seconds": 39600,
"active_hhmm": "11:00",
"first_activity": "2026-05-14T00:00:00Z",
"last_activity": "2026-05-14T11:00:00Z",
"idle_seconds": 0,
"sessions_count": 1,
"samples_count": 100,
"active_samples": 100,
"_intervals": [
(
datetime(2026, 5, 14, 0, 0, 0, tzinfo=timezone.utc),
datetime(2026, 5, 14, 11, 0, 0, tzinfo=timezone.utc),
)
],
}
]
payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
roster = payload["rows"][0]
assert roster["calendar_active_hhmm"] == "11:00"
assert roster["workday_active_hhmm"] == "05:00"
assert roster["coverage_pct"] == 55.56
assert payload["summary"]["calendar_total_active_hhmm"] == "11:00"
assert payload["summary"]["workday_total_active_hhmm"] == "05:00"
def test_build_management_payload_applies_alias_and_executive_summary():
original = MODULE.load_manager_aliases
original_owners = MODULE.load_manager_owners
original_sources = MODULE.build_source_freshness
original_trend = MODULE.build_management_trend
try:
MODULE.load_manager_aliases = lambda: {
"sharkon2025\\user1": {
"display_name": "Иван Петров",
"manager": "Руководитель смены",
"department": "Бухгалтерия",
"role": "Оператор 1С",
}
}
MODULE.load_manager_owners = lambda: {
"руководитель смены": {
"display_name": "Сменный руководитель",
"title": "Руководитель смены 1С",
"department": "Операторы 1С",
"contact": "@shift-lead",
"escalation_to": "Финансовый директор",
"notes": "Дневной контур",
}
}
MODULE.build_source_freshness = lambda host: ([], [])
MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: []
rows = [
{
"user": "user1",
"user_id": "SHARKON2025\\user1",
"active_seconds": 0,
"active_hhmm": "00:00",
"first_activity": "",
"last_activity": "",
"idle_seconds": 86400,
"sessions_count": 1,
"samples_count": 10,
"active_samples": 0,
"_intervals": [],
}
]
payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
row = payload["rows"][0]
assert row["user"] == "Иван Петров"
assert row["manager_owner"] == "Руководитель смены"
assert row["department"] == "Бухгалтерия"
assert payload["actions"][0]["owner"] == "Руководитель смены"
assert payload["executive"]["portfolio_state"] == "critical"
assert payload["executive"]["focus_items"][0]["owner"] == "Руководитель смены"
assert payload["owner_rollups"][0]["name"] == "Руководитель смены"
assert payload["owner_rollups"][0]["critical_actions_count"] >= 1
assert payload["department_rollups"][0]["name"] == "Бухгалтерия"
assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель"
assert payload["owner_roster"][0]["contact"] == "@shift-lead"
finally:
MODULE.load_manager_aliases = original
MODULE.load_manager_owners = original_owners
MODULE.build_source_freshness = original_sources
MODULE.build_management_trend = original_trend
def test_build_management_payload_filters_by_owner():
original_aliases = MODULE.load_manager_aliases
original_owners = MODULE.load_manager_owners
original_trend = MODULE.build_management_trend
original_sources = MODULE.build_source_freshness
try:
MODULE.load_manager_aliases = lambda: {
"sharkon2025\\user1": {
"display_name": "Иван Петров",
"manager": "Руководитель смены",
"department": "Бухгалтерия",
"role": "Оператор 1С",
},
"sharkon2025\\user2": {
"display_name": "Мария Соколова",
"manager": "Финансовый директор",
"department": "Финансы",
"role": "Главбух",
},
}
MODULE.load_manager_owners = lambda: {
"руководитель смены": {"display_name": "Сменный руководитель", "title": "Руководитель смены 1С"}
}
MODULE.build_management_trend = lambda *args, **kwargs: []
MODULE.build_source_freshness = lambda host: ([], [])
rows = [
{
"user": "user1",
"user_id": "SHARKON2025\\user1",
"active_seconds": 0,
"active_hhmm": "00:00",
"first_activity": "",
"last_activity": "",
"idle_seconds": 86400,
"sessions_count": 1,
"samples_count": 10,
"active_samples": 0,
"_intervals": [],
},
{
"user": "user2",
"user_id": "SHARKON2025\\user2",
"active_seconds": 3600,
"active_hhmm": "01:00",
"first_activity": "2026-05-14T07:00:00Z",
"last_activity": "2026-05-14T08:00:00Z",
"idle_seconds": 82800,
"sessions_count": 1,
"samples_count": 120,
"active_samples": 120,
"_intervals": [
(
datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc),
datetime(2026, 5, 14, 8, 0, 0, tzinfo=timezone.utc),
)
],
},
]
payload = MODULE.build_management_payload(
rows,
"SHARKON2025",
datetime(2026, 5, 14, tzinfo=timezone.utc).date(),
owner_filter="Руководитель смены",
)
assert payload["filters"]["owner"] == "Руководитель смены"
assert payload["filters"]["department"] == ""
assert payload["summary"]["users_count"] == 1
assert [row["user"] for row in payload["rows"]] == ["Иван Петров"]
assert len(payload["owner_rollups"]) == 1
assert payload["owner_rollups"][0]["name"] == "Руководитель смены"
assert len(payload["department_rollups"]) == 1
assert payload["department_rollups"][0]["name"] == "Бухгалтерия"
assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель"
assert all(action["owner"] == "Руководитель смены" for action in payload["actions"])
finally:
MODULE.load_manager_aliases = original_aliases
MODULE.load_manager_owners = original_owners
MODULE.build_management_trend = original_trend
MODULE.build_source_freshness = original_sources
def _minimal_management_payload(report_date, users_count=1):
return {
"summary": {
"users_count": users_count,
"active_users": users_count,
"inactive_users": 0,
"workday_total_active_seconds": 3600 * users_count,
"workday_total_active_hhmm": f"0{users_count}:00",
"portfolio_coverage_pct": 100.0,
"actions_count": 0,
"critical_actions_count": 0,
},
"rows": [],
"actions": [],
"sources": [],
"filters": {"owner": "", "department": ""},
}
def test_load_management_cache_keeps_historical_reports_after_ttl():
original_dir = MODULE.MANAGER_CACHE_DIR
original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS
try:
with tempfile.TemporaryDirectory() as tmp:
MODULE.MANAGER_CACHE_DIR = Path(tmp)
MODULE.MANAGER_CACHE_TTL_SECONDS = 1
report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
payload = _minimal_management_payload(report_date)
path = MODULE.management_cache_path("SHARKON2025", report_date)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
loaded = MODULE.load_management_cache("SHARKON2025", report_date)
assert loaded["summary"]["users_count"] == 1
finally:
MODULE.MANAGER_CACHE_DIR = original_dir
MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl
def test_build_management_trend_reuses_precomputed_anchor_payload():
original_trend_days = MODULE.MANAGER_TREND_DAYS
original_load = MODULE.load_management_cache
original_fetch = MODULE.fetch_events_for_date
original_aggregate = MODULE.aggregate_rows_with_intervals
fetch_dates = []
try:
MODULE.MANAGER_TREND_DAYS = 3
anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
def fake_load(host, report_date):
if report_date < anchor:
return _minimal_management_payload(report_date)
return None
def fake_fetch(host, report_date):
fetch_dates.append(report_date)
return MODULE.get_report_bounds(report_date), []
MODULE.load_management_cache = fake_load
MODULE.fetch_events_for_date = fake_fetch
MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: []
trend = MODULE.build_management_trend(
"SHARKON2025",
anchor,
precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)},
)
assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"]
assert trend[-1]["users_count"] == 2
assert fetch_dates == []
finally:
MODULE.MANAGER_TREND_DAYS = original_trend_days
MODULE.load_management_cache = original_load
MODULE.fetch_events_for_date = original_fetch
MODULE.aggregate_rows_with_intervals = original_aggregate
def test_render_management_html_contains_action_queue():
payload = {
"generated_at_utc": "2026-05-14T12:00:00Z",
"host": "SHARKON2025",
"report_date": "2026-05-14",
"report_timezone": "Europe/Moscow",
"filters": {
"owner": "Руководитель смены",
"department": "Бухгалтерия",
},
"workday": {
"start_local": "2026-05-14T09:00:00+03:00",
"end_local": "2026-05-14T18:00:00+03:00",
"expected_seconds_per_user": 32400,
"expected_hhmm_per_user": "09:00",
"target_coverage_pct": 75,
"low_coverage_pct": 35,
},
"summary": {
"users_count": 1,
"active_users": 0,
"inactive_users": 1,
"on_target_users": 0,
"below_target_users": 0,
"portfolio_coverage_pct": 0.0,
"actions_count": 1,
"critical_actions_count": 1,
"high_actions_count": 0,
"total_active_seconds": 0,
"total_active_hhmm": "00:00",
"calendar_total_active_hhmm": "00:00",
"workday_total_active_hhmm": "00:00",
"first_activity": "",
"last_activity": "",
"top_user": "",
"top_user_active_hhmm": "00:00",
},
"actions": [
{
"action_id": "missing_activity",
"priority": "critical",
"owner": "Руководитель смены",
"user_id": "SHARKON2025\\user1",
"deadline_hint": "today",
"reason": "Нет активности",
"recommended_action": "Проверить сотрудника",
"evidence": {},
}
],
"rows": [
{
"user": "Иван Петров",
"user_id": "SHARKON2025\\user1",
"canonical_user_id": "SHARKON2025\\user1",
"manager_owner": "Руководитель смены",
"department": "Бухгалтерия",
"active_hhmm": "00:00",
"calendar_active_hhmm": "00:00",
"workday_active_hhmm": "00:00",
"coverage_pct": 0.0,
"status": "inactive",
"first_activity_local": "",
"last_activity_local": "",
"workday_first_activity_local": "",
"workday_last_activity_local": "",
"sessions_count": 1,
}
],
"executive": {
"portfolio_state": "critical",
"headline": "Есть 1 критичный вопрос, требующий решения сегодня.",
"message": "Активны 0 из 1 сотрудников. Покрытие рабочего окна 0.0%.",
"focus_items": [
{
"priority": "critical",
"owner": "Руководитель смены",
"title": "missing_activity",
"reason": "Нет активности",
"recommended_action": "Проверить сотрудника",
}
],
"stale_sources": [],
},
"owner_rollups": [
{
"name": "Руководитель смены",
"users_count": 1,
"active_users": 0,
"inactive_users": 1,
"below_target_users": 0,
"workday_total_active_seconds": 0,
"workday_total_active_hhmm": "00:00",
"portfolio_coverage_pct": 0.0,
"actions_count": 1,
"critical_actions_count": 1,
"high_actions_count": 0,
"medium_actions_count": 0,
"low_actions_count": 0,
"users": ["Иван Петров"],
}
],
"department_rollups": [
{
"name": "Бухгалтерия",
"users_count": 1,
"active_users": 0,
"inactive_users": 1,
"below_target_users": 0,
"workday_total_active_seconds": 0,
"workday_total_active_hhmm": "00:00",
"portfolio_coverage_pct": 0.0,
"actions_count": 1,
"critical_actions_count": 1,
"high_actions_count": 0,
"medium_actions_count": 0,
"low_actions_count": 0,
"users": ["Иван Петров"],
}
],
"owner_roster": [
{
"name": "Руководитель смены",
"display_name": "Сменный руководитель",
"title": "Руководитель смены 1С",
"department": "Операторы 1С",
"contact": "@shift-lead",
"escalation_to": "Финансовый директор",
"notes": "Дневной контур",
"users_count": 1,
"active_users": 0,
"inactive_users": 1,
"below_target_users": 0,
"workday_total_active_seconds": 0,
"workday_total_active_hhmm": "00:00",
"portfolio_coverage_pct": 0.0,
"actions_count": 1,
"critical_actions_count": 1,
"high_actions_count": 0,
"medium_actions_count": 0,
"low_actions_count": 0,
"users": ["Иван Петров"]
}
],
"trend": [
{
"report_date": "2026-05-14",
"users_count": 1,
"active_users": 0,
"inactive_users": 1,
"workday_total_active_hhmm": "00:00",
"portfolio_coverage_pct": 0.0,
"actions_count": 1,
"critical_actions_count": 1,
}
],
"sources": [
{
"label": "RDP worktime sessions",
"status": "ok",
"status_label": "fresh",
"bucket_id": "aw-worktime-sessions_SHARKON2025",
"timestamp": "2026-05-14T12:00:00Z",
"age_seconds": 30,
"required": True,
"summary": "fresh (30s)",
"event_summary": "queue=0 failures=0 flushed=10",
}
],
"bucket_id": "aw-worktime-sessions_SHARKON2025",
"report_bounds": {
"start_utc": "2026-05-13T21:00:00Z",
"end_utc": "2026-05-14T20:59:59Z",
},
}
html = MODULE.render_management_html(payload, selected_day="today")
assert "Управленческий отчёт по работе в RDP" in html
assert "Очередь действий руководителя" in html
assert "missing_activity" in html
assert "Тренд за" in html
assert "По ответственным" in html
assert "Ответственные и эскалация" in html
assert "По подразделениям" in html
assert "Свежесть источников данных" in html
assert "Что делать сегодня" in html
assert "Фильтр:" in html
assert "Сбросить" in html
assert "@shift-lead" in html
assert "Иван Петров" in html
assert "Руководитель смены" in html
def test_build_source_freshness_uses_host_fileops_bucket_only():
original = MODULE.latest_bucket_event
original_now_utc = MODULE.now_utc
try:
def fake_latest(bucket_id):
if bucket_id == "aw-file-operations_SHARKON2025":
return {
"timestamp": "2026-05-23T12:00:00Z",
"data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50},
}
return None
MODULE.latest_bucket_event = fake_latest
MODULE.now_utc = lambda: datetime(2026, 5, 23, 12, 5, 0, tzinfo=timezone.utc)
sources, actions = MODULE.build_source_freshness("SHARKON2025")
file_source = next(source for source in sources if source["source_id"] == "file_operations")
assert file_source["bucket_id"] == "aw-file-operations_SHARKON2025"
assert file_source["status"] == "ok"
assert not any(action["evidence"].get("source_id") == "file_operations" for action in actions)
finally:
MODULE.latest_bucket_event = original
MODULE.now_utc = original_now_utc
def test_send_bytes_returns_false_on_broken_pipe():
handler = _FakeHandler(exc=BrokenPipeError())
ok = MODULE.send_bytes(handler, b"{}", "application/json; charset=utf-8")
assert ok is False
assert handler.status == 200
assert handler.headers["Content-Type"] == "application/json; charset=utf-8"
assert handler.headers["Content-Length"] == "2"
assert handler.ended is True
def test_send_bytes_writes_payload_when_client_is_connected():
handler = _FakeHandler()
ok = MODULE.send_bytes(handler, b"payload", "text/plain; charset=utf-8", status=201)
assert ok is True
assert handler.status == 201
assert handler.headers["Content-Length"] == "7"
assert handler.wfile.data == b"payload"