diff --git a/core/models.py b/core/models.py index dde8f65..62587a6 100644 --- a/core/models.py +++ b/core/models.py @@ -266,28 +266,12 @@ 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, 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" - + """List tasks with optional project/status filters.""" query = "SELECT * FROM tasks WHERE 1=1" params: list = [] if project_id: @@ -296,10 +280,7 @@ def list_tasks( if status: query += " AND status = ?" params.append(status) - query += f" ORDER BY {sort_col} {sort_direction}" - if limit is not None: - query += " LIMIT ?" - params.append(limit) + query += " ORDER BY priority, created_at" return _rows_to_list(conn.execute(query, params).fetchall()) diff --git a/tests/test_kin_ui_015_regression.py b/tests/test_kin_ui_015_regression.py deleted file mode 100644 index e05872e..0000000 --- a/tests/test_kin_ui_015_regression.py +++ /dev/null @@ -1,139 +0,0 @@ -"""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() == [] diff --git a/web/api.py b/web/api.py index c788acd..adc169d 100644 --- a/web/api.py +++ b/web/api.py @@ -654,28 +654,6 @@ 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() diff --git a/web/frontend/src/__tests__/completed-tasks-banner.test.ts b/web/frontend/src/__tests__/completed-tasks-banner.test.ts index 7b35975..a71c160 100644 --- a/web/frontend/src/__tests__/completed-tasks-banner.test.ts +++ b/web/frontend/src/__tests__/completed-tasks-banner.test.ts @@ -19,7 +19,8 @@ vi.mock('vue-router', () => ({ vi.mock('../api', () => ({ api: { notifications: vi.fn(), - listTasks: vi.fn(), + projects: vi.fn(), + project: vi.fn(), reviseTask: vi.fn(), }, })) @@ -37,6 +38,40 @@ 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, @@ -58,13 +93,21 @@ 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.listTasks).mockResolvedValue([]) + vi.mocked(api.projects).mockResolvedValue([]) + vi.mocked(api.project).mockResolvedValue(makeProjectDetail(makeProject(), [])) vi.mocked(api.reviseTask).mockResolvedValue({ status: 'ok', comment: '' }) }) @@ -73,8 +116,13 @@ afterEach(() => { vi.restoreAllMocks() }) -async function mountWithCompleted(tasks = [makeCompletedTask()]) { - vi.mocked(api.listTasks).mockResolvedValue(tasks) +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)) const wrapper = mount(EscalationBanner) await flushPromises() return wrapper @@ -110,7 +158,7 @@ describe('KIN-125 AC1: завершённые задачи отображают }) it('Бейдж не отображается когда нет завершённых задач', async () => { - vi.mocked(api.listTasks).mockResolvedValue([]) + vi.mocked(api.projects).mockResolvedValue([makeProject('proj-1', 'P', 0)]) 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 97c6fe5..b446172 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -410,11 +410,6 @@ 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 043f0ba..04a78cb 100644 --- a/web/frontend/src/components/EscalationBanner.vue +++ b/web/frontend/src/components/EscalationBanner.vue @@ -148,8 +148,24 @@ const visibleCompleted = computed(() => async function loadCompletedTasks() { try { - const tasks = await api.listTasks('completed', 20, 'updated_at') - completedTasks.value = tasks.map(t => ({ ...t, project_name: t.project_id })) + 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) } catch { // silent }