Compare commits
No commits in common. "4f50c4eb73e011f6222d56c029e9706303a67ba7" and "56c3fe6ecc0c514d66296450be0d47f9cea85747" have entirely different histories.
4f50c4eb73
...
56c3fe6ecc
6 changed files with 73 additions and 194 deletions
|
|
@ -266,28 +266,12 @@ def get_task(conn: sqlite3.Connection, id: str) -> dict | None:
|
||||||
return _row_to_dict(row)
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
VALID_TASK_SORT_FIELDS = frozenset({
|
|
||||||
"updated_at", "created_at", "priority", "status", "title", "id",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def list_tasks(
|
def list_tasks(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
project_id: str | None = None,
|
project_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int | None = None,
|
|
||||||
sort: str = "updated_at",
|
|
||||||
sort_dir: str = "desc",
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List tasks with optional project/status filters, limit, and sort.
|
"""List tasks with optional project/status filters."""
|
||||||
|
|
||||||
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"
|
query = "SELECT * FROM tasks WHERE 1=1"
|
||||||
params: list = []
|
params: list = []
|
||||||
if project_id:
|
if project_id:
|
||||||
|
|
@ -296,10 +280,7 @@ def list_tasks(
|
||||||
if status:
|
if status:
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
query += f" ORDER BY {sort_col} {sort_direction}"
|
query += " ORDER BY priority, created_at"
|
||||||
if limit is not None:
|
|
||||||
query += " LIMIT ?"
|
|
||||||
params.append(limit)
|
|
||||||
return _rows_to_list(conn.execute(query, params).fetchall())
|
return _rows_to_list(conn.execute(query, params).fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() == []
|
|
||||||
22
web/api.py
22
web/api.py
|
|
@ -654,28 +654,6 @@ def start_project_phase(project_id: str):
|
||||||
# Tasks
|
# 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}")
|
@app.get("/api/tasks/{task_id}")
|
||||||
def get_task(task_id: str):
|
def get_task(task_id: str):
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ vi.mock('vue-router', () => ({
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
api: {
|
api: {
|
||||||
notifications: vi.fn(),
|
notifications: vi.fn(),
|
||||||
listTasks: vi.fn(),
|
projects: vi.fn(),
|
||||||
|
project: vi.fn(),
|
||||||
reviseTask: vi.fn(),
|
reviseTask: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
@ -37,6 +38,40 @@ const localStorageMock = (() => {
|
||||||
})()
|
})()
|
||||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
|
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') {
|
function makeCompletedTask(id = 'TSK-1', title = 'Test task') {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -58,13 +93,21 @@ function makeCompletedTask(id = 'TSK-1', title = 'Test task') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeProjectDetail(
|
||||||
|
project: ReturnType<typeof makeProject>,
|
||||||
|
tasks: ReturnType<typeof makeCompletedTask>[],
|
||||||
|
) {
|
||||||
|
return { ...project, tasks, modules: [], decisions: [] }
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorageMock.clear()
|
localStorageMock.clear()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPush.mockClear()
|
mockPush.mockClear()
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.mocked(api.notifications).mockResolvedValue([])
|
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: '' })
|
vi.mocked(api.reviseTask).mockResolvedValue({ status: 'ok', comment: '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -73,8 +116,13 @@ afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mountWithCompleted(tasks = [makeCompletedTask()]) {
|
async function mountWithCompleted(
|
||||||
vi.mocked(api.listTasks).mockResolvedValue(tasks)
|
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)
|
const wrapper = mount(EscalationBanner)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
@ -110,7 +158,7 @@ describe('KIN-125 AC1: завершённые задачи отображают
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Бейдж не отображается когда нет завершённых задач', async () => {
|
it('Бейдж не отображается когда нет завершённых задач', async () => {
|
||||||
vi.mocked(api.listTasks).mockResolvedValue([])
|
vi.mocked(api.projects).mockResolvedValue([makeProject('proj-1', 'P', 0)])
|
||||||
const wrapper = mount(EscalationBanner)
|
const wrapper = mount(EscalationBanner)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const badge = wrapper.findAll('button').find(b => b.text().includes('Completed'))
|
const badge = wrapper.findAll('button').find(b => b.text().includes('Completed'))
|
||||||
|
|
|
||||||
|
|
@ -410,11 +410,6 @@ export const api = {
|
||||||
deleteAttachment: (taskId: string, id: number) =>
|
deleteAttachment: (taskId: string, id: number) =>
|
||||||
del<void>(`/tasks/${taskId}/attachments/${id}`),
|
del<void>(`/tasks/${taskId}/attachments/${id}`),
|
||||||
attachmentUrl: (id: number) => `${BASE}/attachments/${id}/file`,
|
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<Task[]>(`/tasks?${q.toString()}`)
|
|
||||||
},
|
|
||||||
getPipelineLogs: (pipelineId: string, sinceId: number) =>
|
getPipelineLogs: (pipelineId: string, sinceId: number) =>
|
||||||
get<PipelineLog[]>(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`),
|
get<PipelineLog[]>(`/pipelines/${pipelineId}/logs?since_id=${sinceId}`),
|
||||||
projectLinks: (projectId: string) =>
|
projectLinks: (projectId: string) =>
|
||||||
|
|
|
||||||
|
|
@ -148,8 +148,24 @@ const visibleCompleted = computed(() =>
|
||||||
|
|
||||||
async function loadCompletedTasks() {
|
async function loadCompletedTasks() {
|
||||||
try {
|
try {
|
||||||
const tasks = await api.listTasks('completed', 20, 'updated_at')
|
const projects = await api.projects()
|
||||||
completedTasks.value = tasks.map(t => ({ ...t, project_name: t.project_id }))
|
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 {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue