From 34c57bef868f33983857d3b08fb0a92674deaa6a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:45:43 +0200 Subject: [PATCH 1/3] kin: KIN-UI-015-backend_dev --- core/models.py | 23 +++++++++++++++++++++-- web/api.py | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/core/models.py b/core/models.py index 62587a6..dde8f65 100644 --- a/core/models.py +++ b/core/models.py @@ -266,12 +266,28 @@ def get_task(conn: sqlite3.Connection, id: str) -> dict | None: return _row_to_dict(row) +VALID_TASK_SORT_FIELDS = frozenset({ + "updated_at", "created_at", "priority", "status", "title", "id", +}) + + def list_tasks( conn: sqlite3.Connection, project_id: str | None = None, status: str | None = None, + limit: int | None = None, + sort: str = "updated_at", + sort_dir: str = "desc", ) -> list[dict]: - """List tasks with optional project/status filters.""" + """List tasks with optional project/status filters, limit, and sort. + + sort: column name (validated against VALID_TASK_SORT_FIELDS, default 'updated_at') + sort_dir: 'asc' or 'desc' (default 'desc') + """ + # Validate sort field to prevent SQL injection + sort_col = sort if sort in VALID_TASK_SORT_FIELDS else "updated_at" + sort_direction = "DESC" if sort_dir.lower() != "asc" else "ASC" + query = "SELECT * FROM tasks WHERE 1=1" params: list = [] if project_id: @@ -280,7 +296,10 @@ def list_tasks( if status: query += " AND status = ?" params.append(status) - query += " ORDER BY priority, created_at" + query += f" ORDER BY {sort_col} {sort_direction}" + if limit is not None: + query += " LIMIT ?" + params.append(limit) return _rows_to_list(conn.execute(query, params).fetchall()) diff --git a/web/api.py b/web/api.py index adc169d..c788acd 100644 --- a/web/api.py +++ b/web/api.py @@ -654,6 +654,28 @@ def start_project_phase(project_id: str): # Tasks # --------------------------------------------------------------------------- +@app.get("/api/tasks") +def list_tasks( + status: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=500), + sort: str = Query(default="updated_at"), + project_id: str | None = Query(default=None), +): + """List tasks with optional filters. sort defaults to updated_at desc.""" + from core.models import VALID_TASK_SORT_FIELDS + conn = get_conn() + tasks = models.list_tasks( + conn, + project_id=project_id, + status=status, + limit=limit, + sort=sort if sort in VALID_TASK_SORT_FIELDS else "updated_at", + sort_dir="desc", + ) + conn.close() + return tasks + + @app.get("/api/tasks/{task_id}") def get_task(task_id: str): conn = get_conn() From 62a483b62a1b8cb119fc609f05e8373d3955b207 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:50:08 +0200 Subject: [PATCH 2/3] kin: KIN-UI-015-frontend_dev --- .../__tests__/completed-tasks-banner.test.ts | 58 ++----------------- web/frontend/src/api.ts | 5 ++ .../src/components/EscalationBanner.vue | 20 +------ 3 files changed, 12 insertions(+), 71 deletions(-) diff --git a/web/frontend/src/__tests__/completed-tasks-banner.test.ts b/web/frontend/src/__tests__/completed-tasks-banner.test.ts index a71c160..7b35975 100644 --- a/web/frontend/src/__tests__/completed-tasks-banner.test.ts +++ b/web/frontend/src/__tests__/completed-tasks-banner.test.ts @@ -19,8 +19,7 @@ vi.mock('vue-router', () => ({ vi.mock('../api', () => ({ api: { notifications: vi.fn(), - projects: vi.fn(), - project: vi.fn(), + listTasks: vi.fn(), reviseTask: vi.fn(), }, })) @@ -38,40 +37,6 @@ const localStorageMock = (() => { })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true }) -function makeProject(id = 'proj-1', name = 'MyProject', doneCount = 1) { - return { - id, - name, - path: '/projects/test', - status: 'active', - priority: 1, - tech_stack: ['python'], - execution_mode: null, - autocommit_enabled: null, - auto_test_enabled: null, - worktrees_enabled: null, - obsidian_vault_path: null, - deploy_command: null, - test_command: null, - deploy_host: null, - deploy_path: null, - deploy_runtime: null, - deploy_restart_cmd: null, - created_at: '2024-01-01', - total_tasks: doneCount, - done_tasks: doneCount, - active_tasks: 0, - blocked_tasks: 0, - review_tasks: 0, - project_type: null, - ssh_host: null, - ssh_user: null, - ssh_key_path: null, - ssh_proxy_jump: null, - description: null, - } -} - function makeCompletedTask(id = 'TSK-1', title = 'Test task') { return { id, @@ -93,21 +58,13 @@ function makeCompletedTask(id = 'TSK-1', title = 'Test task') { } } -function makeProjectDetail( - project: ReturnType, - tasks: ReturnType[], -) { - return { ...project, tasks, modules: [], decisions: [] } -} - beforeEach(() => { localStorageMock.clear() vi.clearAllMocks() mockPush.mockClear() vi.useFakeTimers() vi.mocked(api.notifications).mockResolvedValue([]) - vi.mocked(api.projects).mockResolvedValue([]) - vi.mocked(api.project).mockResolvedValue(makeProjectDetail(makeProject(), [])) + vi.mocked(api.listTasks).mockResolvedValue([]) vi.mocked(api.reviseTask).mockResolvedValue({ status: 'ok', comment: '' }) }) @@ -116,13 +73,8 @@ afterEach(() => { vi.restoreAllMocks() }) -async function mountWithCompleted( - tasks = [makeCompletedTask()], - projectName = 'MyProject', -) { - const project = makeProject('proj-1', projectName) - vi.mocked(api.projects).mockResolvedValue([project]) - vi.mocked(api.project).mockResolvedValue(makeProjectDetail(project, tasks)) +async function mountWithCompleted(tasks = [makeCompletedTask()]) { + vi.mocked(api.listTasks).mockResolvedValue(tasks) const wrapper = mount(EscalationBanner) await flushPromises() return wrapper @@ -158,7 +110,7 @@ describe('KIN-125 AC1: завершённые задачи отображают }) it('Бейдж не отображается когда нет завершённых задач', async () => { - vi.mocked(api.projects).mockResolvedValue([makeProject('proj-1', 'P', 0)]) + vi.mocked(api.listTasks).mockResolvedValue([]) const wrapper = mount(EscalationBanner) await flushPromises() const badge = wrapper.findAll('button').find(b => b.text().includes('Completed')) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index b446172..97c6fe5 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -410,6 +410,11 @@ export const api = { deleteAttachment: (taskId: string, id: number) => del(`/tasks/${taskId}/attachments/${id}`), attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`, + listTasks: (status?: string, limit = 20, sort = 'updated_at') => { + const q = new URLSearchParams({ limit: String(limit), sort }) + if (status) q.set('status', status) + return get(`/tasks?${q.toString()}`) + }, getPipelineLogs: (pipelineId: string, sinceId: number) => get(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`), projectLinks: (projectId: string) => diff --git a/web/frontend/src/components/EscalationBanner.vue b/web/frontend/src/components/EscalationBanner.vue index 04a78cb..043f0ba 100644 --- a/web/frontend/src/components/EscalationBanner.vue +++ b/web/frontend/src/components/EscalationBanner.vue @@ -148,24 +148,8 @@ const visibleCompleted = computed(() => async function loadCompletedTasks() { try { - const projects = await api.projects() - const withCompleted = projects.filter(p => p.done_tasks > 0) - if (withCompleted.length === 0) { - completedTasks.value = [] - return - } - const details = await Promise.all(withCompleted.map(p => api.project(p.id))) - const results: CompletedTaskItem[] = [] - for (let i = 0; i < details.length; i++) { - const projectName = withCompleted[i].name - for (const task of details[i].tasks) { - if (task.status === 'completed') { - results.push({ ...task, project_name: projectName }) - } - } - } - results.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) - completedTasks.value = results.slice(0, 20) + const tasks = await api.listTasks('completed', 20, 'updated_at') + completedTasks.value = tasks.map(t => ({ ...t, project_name: t.project_id })) } catch { // silent } From 4f50c4eb73e011f6222d56c029e9706303a67ba7 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:52:27 +0200 Subject: [PATCH 3/3] kin: auto-commit after pipeline --- tests/test_kin_ui_015_regression.py | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/test_kin_ui_015_regression.py diff --git a/tests/test_kin_ui_015_regression.py b/tests/test_kin_ui_015_regression.py new file mode 100644 index 0000000..e05872e --- /dev/null +++ b/tests/test_kin_ui_015_regression.py @@ -0,0 +1,139 @@ +"""Regression tests for KIN-UI-015 — GET /api/tasks endpoint. + +New endpoint: GET /api/tasks?status=...&limit=...&sort=... +Acceptance criteria: +- Эндпоинт существует и возвращает корректные данные +- Фильтрация по status работает корректно +- Параметр limit ограничивает количество результатов +- Параметр sort задаёт порядок сортировки +- Невалидный sort-field фоллбэчит на updated_at (защита от SQL-инъекций) + +Coverage: +(1) GET /api/tasks возвращает все задачи проекта +(2) GET /api/tasks?status=completed возвращает только completed +(3) GET /api/tasks?status=completed не возвращает задачи с другим статусом +(4) GET /api/tasks?limit=1 возвращает не более 1 результата +(5) GET /api/tasks?sort=priority — сортировка по priority работает +(6) GET /api/tasks?sort=invalid_field — фоллбэк на updated_at (нет 500-ошибки) +(7) GET /api/tasks?status=completed&limit=20&sort=updated_at — все параметры вместе +(8) GET /api/tasks возвращает 200 при пустой базе +""" + +import pytest +import web.api as api_module +from fastapi.testclient import TestClient +from core.db import init_db +from core import models + + +@pytest.fixture +def client(tmp_path): + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + c = TestClient(app) + c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/tmp/p1"}) + return c + + +@pytest.fixture +def client_with_tasks(client, tmp_path): + """Client pre-seeded with tasks of various statuses.""" + conn = init_db(api_module.DB_PATH) + models.create_task(conn, "P1-001", "p1", "Pending task", status="pending") + models.create_task(conn, "P1-002", "p1", "Completed task 1", status="completed") + models.create_task(conn, "P1-003", "p1", "Completed task 2", status="completed") + models.create_task(conn, "P1-004", "p1", "In-progress task", status="in_progress") + conn.close() + return client + + +# --------------------------------------------------------------------------- +# (1) GET /api/tasks возвращает все задачи +# --------------------------------------------------------------------------- + +def test_list_tasks_returns_all(client_with_tasks): + r = client_with_tasks.get("/api/tasks") + assert r.status_code == 200 + tasks = r.json() + assert len(tasks) == 4 + + +# --------------------------------------------------------------------------- +# (2) GET /api/tasks?status=completed возвращает только completed +# --------------------------------------------------------------------------- + +def test_list_tasks_filter_by_status_completed(client_with_tasks): + r = client_with_tasks.get("/api/tasks?status=completed") + assert r.status_code == 200 + tasks = r.json() + assert len(tasks) == 2 + assert all(t["status"] == "completed" for t in tasks) + + +# --------------------------------------------------------------------------- +# (3) Другие статусы не попадают в фильтр status=completed +# --------------------------------------------------------------------------- + +def test_list_tasks_filter_excludes_other_statuses(client_with_tasks): + r = client_with_tasks.get("/api/tasks?status=completed") + assert r.status_code == 200 + tasks = r.json() + titles = [t["title"] for t in tasks] + assert "Pending task" not in titles + assert "In-progress task" not in titles + + +# --------------------------------------------------------------------------- +# (4) GET /api/tasks?limit=1 возвращает не более 1 результата +# --------------------------------------------------------------------------- + +def test_list_tasks_limit_respected(client_with_tasks): + r = client_with_tasks.get("/api/tasks?limit=1") + assert r.status_code == 200 + tasks = r.json() + assert len(tasks) == 1 + + +# --------------------------------------------------------------------------- +# (5) GET /api/tasks?sort=priority — сортировка работает (нет 500-ошибки) +# --------------------------------------------------------------------------- + +def test_list_tasks_sort_by_priority(client_with_tasks): + r = client_with_tasks.get("/api/tasks?sort=priority") + assert r.status_code == 200 + tasks = r.json() + assert len(tasks) == 4 + + +# --------------------------------------------------------------------------- +# (6) Невалидный sort-field фоллбэчит на updated_at (нет 500-ошибки) +# --------------------------------------------------------------------------- + +def test_list_tasks_invalid_sort_falls_back(client_with_tasks): + r = client_with_tasks.get("/api/tasks?sort='; DROP TABLE tasks; --") + assert r.status_code == 200 + tasks = r.json() + assert isinstance(tasks, list) + + +# --------------------------------------------------------------------------- +# (7) Все параметры вместе: status=completed&limit=20&sort=updated_at +# --------------------------------------------------------------------------- + +def test_list_tasks_combined_params(client_with_tasks): + r = client_with_tasks.get("/api/tasks?status=completed&limit=20&sort=updated_at") + assert r.status_code == 200 + tasks = r.json() + assert len(tasks) == 2 + assert all(t["status"] == "completed" for t in tasks) + + +# --------------------------------------------------------------------------- +# (8) Пустая база — возвращает 200 и пустой список +# --------------------------------------------------------------------------- + +def test_list_tasks_empty_db(client): + r = client.get("/api/tasks") + assert r.status_code == 200 + assert r.json() == []