Skip to content

Commit 3e1fe83

Browse files
committed
test: add unit coverage and enforce CI gates
1 parent cf81cf0 commit 3e1fe83

16 files changed

Lines changed: 711 additions & 10 deletions

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ jobs:
1313
- uses: actions/checkout@v4
1414
- uses: astral-sh/ruff-action@v3
1515

16+
test-python:
17+
name: Unit Tests (Python)
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: actions/setup-python@v5
22+
with:
23+
python-version: "3.12"
24+
- shell: bash
25+
run: python -m unittest discover -s tests -p 'test_*.py'
26+
1627
typecheck-ts:
1728
name: TypeScript (${{ matrix.os }})
1829
runs-on: ${{ matrix.os }}
@@ -26,3 +37,14 @@ jobs:
2637
run: cd ui && bun install --frozen-lockfile 2>/dev/null || bun install
2738
- shell: bash
2839
run: cd ui && bun run typecheck
40+
41+
test-ts:
42+
name: Unit Tests (TypeScript)
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@v4
46+
- uses: oven-sh/setup-bun@v2
47+
- shell: bash
48+
run: cd ui && bun install --frozen-lockfile 2>/dev/null || bun install
49+
- shell: bash
50+
run: cd ui && bun run test

.github/workflows/release.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,21 @@ jobs:
1414
name: Precheck
1515
uses: ./.github/workflows/ci.yml
1616

17+
package:
18+
name: Package Check
19+
needs: ci
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: 22
26+
- name: Verify npm package can be packed
27+
run: npm pack --dry-run
28+
1729
release:
1830
name: Semantic Release
19-
needs: ci
31+
needs: [ci, package]
2032
runs-on: ubuntu-latest
2133
if: github.repository == 'izzzzzi/izTeamSlots'
2234

CONTRIBUTING.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,30 @@ izTeamSlots/
4242
└── .github/workflows/ # CI/CD
4343
```
4444

45-
## Линтеры
45+
## Проверки и тесты
4646

4747
Перед коммитом убедитесь что код проходит проверки:
4848

4949
```bash
50-
# Python — ruff
51-
ruff check backend/
50+
# Python — lint + unit tests
51+
ruff check backend tests
52+
python -m unittest discover -s tests -p 'test_*.py'
5253

53-
# TypeScript — tsc
54-
npm run typecheck:ui
54+
# TypeScript — typecheck + unit tests
55+
npm --prefix ui run typecheck
56+
npm --prefix ui run test
5557
```
5658

57-
CI автоматически запускает оба линтера на каждый push и PR.
59+
CI автоматически запускает эти проверки на каждый PR.
60+
61+
## Требование по тестам
62+
63+
- Любая новая функциональность или заметное изменение логики должно сопровождаться тестами.
64+
- Минимум: покрывайте тот слой, который можно проверить локально без браузерного e2e.
65+
- Для Python-логики добавляйте `unittest`-тесты в `tests/`.
66+
- Для чистой TypeScript-логики добавляйте тесты в `ui/tests/`.
67+
- Если изменение нельзя адекватно покрыть unit-тестом, это нужно явно отметить в описании PR.
68+
- PR без тестов для новой логики может быть отклонён.
5869

5970
## Conventional Commits
6071

@@ -100,8 +111,9 @@ chore: update seleniumbase to 4.33
100111

101112
1. Один PR — одно логическое изменение.
102113
2. Следуйте Conventional Commits.
103-
3. Убедитесь что `ruff check` и `tsc --noEmit` проходят.
104-
4. Обновите документацию если изменение затрагивает пользовательское поведение.
114+
3. Добавьте или обновите тесты, если меняется логика приложения.
115+
4. Убедитесь что lint, unit tests и typecheck проходят локально.
116+
5. Обновите документацию если изменение затрагивает пользовательское поведение.
105117

106118
### Новый почтовый провайдер
107119

backend/rpc_server.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,19 @@ def _handle_request(self, req: RPCRequest) -> dict[str, Any]:
155155
)
156156
return make_success_response(req.request_id, {"job_id": job_id})
157157

158+
if m == "workspace.sync_preview":
159+
admin_email = self._as_str_param(p, "admin_email")
160+
result = self.facade.preview_workspace_sync(admin_email)
161+
return make_success_response(req.request_id, result)
162+
163+
if m == "job.sync_workspace":
164+
admin_email = self._as_str_param(p, "admin_email")
165+
job_id = self._run_job(
166+
f"Синхронизация WS: {admin_email}",
167+
lambda ctx: self.facade.sync_workspace(admin_email, ctx.log),
168+
)
169+
return make_success_response(req.request_id, {"job_id": job_id})
170+
158171
if m == "job.open_admin_browser":
159172
email = self._as_str_param(p, "email")
160173
job_id = self._run_job(

backend/slot_orchestrator.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,106 @@ def get_members(self) -> list[dict]:
310310
api = self._get_api(page)
311311
return api.get_members()
312312

313+
def _build_workspace_sync_plan(
314+
self,
315+
members: list[dict[str, Any]],
316+
pending_invites: list[dict[str, Any]],
317+
) -> dict[str, Any]:
318+
local_emails = {w.email.strip().lower() for w in self._get_workers() if w.email}
319+
admin_email = (self.admin_email or "").strip().lower()
320+
321+
extra_members: list[dict[str, str]] = []
322+
extra_invites: list[dict[str, str]] = []
323+
skipped: list[dict[str, str]] = []
324+
325+
for member in members:
326+
email = str(member.get("email") or "").strip()
327+
if not email:
328+
continue
329+
email_key = email.lower()
330+
role = str(member.get("role") or "")
331+
332+
if email_key == admin_email:
333+
skipped.append({"type": "member", "email": email, "reason": "self"})
334+
continue
335+
if role and role != "standard-user":
336+
skipped.append({"type": "member", "email": email, "reason": f"role:{role}"})
337+
continue
338+
if email_key in local_emails:
339+
continue
340+
341+
extra_members.append(
342+
{
343+
"email": email,
344+
"id": str(member.get("id") or ""),
345+
"role": role or "standard-user",
346+
}
347+
)
348+
349+
for invite in pending_invites:
350+
email = str(invite.get("email") or invite.get("email_address") or "").strip()
351+
if not email:
352+
continue
353+
email_key = email.lower()
354+
if email_key == admin_email:
355+
skipped.append({"type": "invite", "email": email, "reason": "self"})
356+
continue
357+
if email_key in local_emails:
358+
continue
359+
360+
extra_invites.append({"email": email})
361+
362+
return {
363+
"admin_email": self.admin_email,
364+
"workspace_id": self.account_id,
365+
"local_workers_total": len(local_emails),
366+
"members_total": len(members),
367+
"pending_invites_total": len(pending_invites),
368+
"extra_members": extra_members,
369+
"extra_invites": extra_invites,
370+
"skipped": skipped,
371+
"removed_members": [],
372+
"removed_invites": [],
373+
}
374+
375+
def sync_workspace(self, *, dry_run: bool = True) -> dict[str, Any]:
376+
page = self._ensure_admin_page()
377+
api = self._get_api(page)
378+
379+
members = api.get_members()
380+
pending_invites = api.get_pending_invites()
381+
result = self._build_workspace_sync_plan(members, pending_invites)
382+
result["dry_run"] = dry_run
383+
384+
if dry_run:
385+
self._log(f"WS preview: лишних участников {len(result['extra_members'])}, лишних инвайтов {len(result['extra_invites'])}")
386+
return result
387+
388+
for member in result["extra_members"]:
389+
member_id = member.get("id")
390+
email = member.get("email", "")
391+
if not member_id:
392+
self._log(f"[предупреждение] Пропускаю участника без id: {email}")
393+
continue
394+
api.delete_member(member_id)
395+
result["removed_members"].append(email)
396+
self._log(f"Удалён участник WS: {email}")
397+
398+
for invite in result["extra_invites"]:
399+
email = invite.get("email", "")
400+
if not email:
401+
continue
402+
api.delete_invite(email)
403+
result["removed_invites"].append(email)
404+
self._log(f"Удалён инвайт WS: {email}")
405+
406+
self._log(
407+
"WS синхронизирован: "
408+
f"участников удалено {len(result['removed_members'])}, "
409+
f"инвайтов удалено {len(result['removed_invites'])}"
410+
)
411+
return result
412+
313413
def register_slot(self, worker: WorkerAccount, invite_url: str) -> None:
314414
"""Зарегистрировать приглашённый аккаунт через инвайт-ссылку."""
315415
mailbox = self._mailboxes.get(worker.email)

backend/ui_facade.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,18 @@ def login_admin_manual(self, email: str, log: LogFunc) -> None:
209209
manager.login_admin_manual()
210210
self.sync_codex_files()
211211

212+
def preview_workspace_sync(self, admin_email: str) -> dict[str, Any]:
213+
manager = SlotManager(store=self.store, admin_email=admin_email, log=lambda _msg: None, headless=True)
214+
try:
215+
return manager.sync_workspace(dry_run=True)
216+
finally:
217+
manager.close()
218+
219+
def sync_workspace(self, admin_email: str, log: LogFunc) -> dict[str, Any]:
220+
manager = SlotManager(store=self.store, admin_email=admin_email, log=log, headless=False)
221+
self._replace_manager(manager)
222+
return manager.sync_workspace(dry_run=False)
223+
212224
def run_slots_pipeline(
213225
self,
214226
admin_email: str,

tests/test_account_store.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
from backend.account_store import AccountStore
9+
10+
11+
class TestAccountStore(unittest.TestCase):
12+
def setUp(self) -> None:
13+
self.temp_dir = tempfile.TemporaryDirectory()
14+
self.base_dir = Path(self.temp_dir.name) / "accounts"
15+
self.store = AccountStore(self.base_dir)
16+
17+
def tearDown(self) -> None:
18+
self.temp_dir.cleanup()
19+
20+
def test_admin_crud_roundtrip(self) -> None:
21+
admin = self.store.add_admin("admin@example.com", "pw")
22+
admin.access_token = "token"
23+
admin.workspace_id = "ws-1"
24+
admin.account_id = "acc-1"
25+
admin.workspaces = [{"workspace_id": "ws-1"}]
26+
admin.last_login = "2026-03-07T00:00:00Z"
27+
self.store.update_admin(admin)
28+
29+
loaded = self.store.get_admin("admin@example.com")
30+
31+
self.assertIsNotNone(loaded)
32+
assert loaded is not None
33+
self.assertEqual(loaded.access_token, "token")
34+
self.assertEqual(loaded.workspace_id, "ws-1")
35+
self.assertEqual(loaded.account_id, "acc-1")
36+
self.assertEqual(loaded.workspaces, [{"workspace_id": "ws-1"}])
37+
self.assertEqual(loaded.last_login, "2026-03-07T00:00:00Z")
38+
39+
self.store.delete_admin("admin@example.com")
40+
self.assertIsNone(self.store.get_admin("admin@example.com"))
41+
42+
def test_worker_crud_roundtrip(self) -> None:
43+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
44+
worker.status = "registered"
45+
worker.openai_password = "openai-pw"
46+
worker.access_token = "token"
47+
worker.workspace_id = "ws-1"
48+
self.store.update_worker(worker)
49+
50+
loaded = self.store.get_worker("slot@example.com")
51+
52+
self.assertIsNotNone(loaded)
53+
assert loaded is not None
54+
self.assertEqual(loaded.status, "registered")
55+
self.assertEqual(loaded.openai_password, "openai-pw")
56+
self.assertEqual(loaded.access_token, "token")
57+
self.assertEqual(loaded.workspace_id, "ws-1")
58+
self.assertEqual(loaded.admin_email, "admin@example.com")
59+
60+
self.store.delete_worker("slot@example.com")
61+
self.assertIsNone(self.store.get_worker("slot@example.com"))
62+
63+
def test_doctor_rebuilds_broken_worker_index(self) -> None:
64+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
65+
worker_dir = self.store.worker_dir / worker.id
66+
index_path = self.store.worker_dir / "index.json"
67+
index_path.write_text("{broken", encoding="utf-8")
68+
69+
fixes = self.store.doctor()
70+
71+
rebuilt = json.loads(index_path.read_text(encoding="utf-8"))
72+
self.assertIn("slot@example.com", rebuilt)
73+
self.assertTrue(any("index.json был повреждён" in fix for fix in fixes))
74+
self.assertTrue(worker_dir.exists())
75+
76+
def test_doctor_recreates_missing_meta(self) -> None:
77+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
78+
meta_path = self.store.worker_dir / worker.id / "meta.json"
79+
meta_path.unlink()
80+
81+
fixes = self.store.doctor()
82+
83+
rebuilt = json.loads(meta_path.read_text(encoding="utf-8"))
84+
self.assertEqual(rebuilt["email"], "slot@example.com")
85+
self.assertEqual(rebuilt["status"], "created")
86+
self.assertTrue(any("meta.json отсутствовал" in fix for fix in fixes))

tests/test_dto.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
from backend.account_store import AdminAccount, WorkerAccount
6+
from backend.dto import AdminRow, AppStateDTO, WorkerRow
7+
8+
9+
class TestDTO(unittest.TestCase):
10+
def test_admin_row_statuses(self) -> None:
11+
account = AdminAccount(id="1", email="admin@example.com", password="pw")
12+
self.assertEqual(AdminRow.from_account(account).status_label, "Не настроен")
13+
self.assertEqual(AdminRow.from_account(account, has_browser_profile=True).status_label, "Нужен вход")
14+
15+
account.access_token = "token"
16+
self.assertEqual(AdminRow.from_account(account).status_label, "Есть токен")
17+
self.assertEqual(AdminRow.from_account(account, has_browser_profile=True).status_label, "Готов")
18+
19+
def test_worker_row_statuses(self) -> None:
20+
account = WorkerAccount(id="1", email="slot@example.com", password="pw", admin_email="admin@example.com")
21+
self.assertEqual(WorkerRow.from_account(account).status_label, "Создан")
22+
23+
account.status = "invited"
24+
self.assertEqual(WorkerRow.from_account(account).status_label, "Инвайт отправлен")
25+
26+
account.status = "registered"
27+
self.assertEqual(WorkerRow.from_account(account).status_label, "Зарегистрирован")
28+
29+
account.access_token = "token"
30+
self.assertEqual(WorkerRow.from_account(account, has_browser_profile=True).status_label, "Готов")
31+
32+
def test_app_state_to_dict(self) -> None:
33+
dto = AppStateDTO(
34+
admins=[AdminRow.from_account(AdminAccount(id="1", email="admin@example.com", password="pw"))],
35+
workers=[WorkerRow.from_account(WorkerAccount(id="2", email="slot@example.com", password="pw"))],
36+
)
37+
38+
result = dto.to_dict()
39+
40+
self.assertIn("admins", result)
41+
self.assertIn("workers", result)
42+
self.assertEqual(result["admins"][0]["email"], "admin@example.com")
43+
self.assertEqual(result["workers"][0]["email"], "slot@example.com")

0 commit comments

Comments
 (0)