diff --git a/backend/migrations/versions/2024_08_30_1303-ba6550a03bc8_add_testcaseissue_table.py b/backend/migrations/versions/2024_08_30_1303-ba6550a03bc8_add_testcaseissue_table.py new file mode 100644 index 00000000..4a08131c --- /dev/null +++ b/backend/migrations/versions/2024_08_30_1303-ba6550a03bc8_add_testcaseissue_table.py @@ -0,0 +1,47 @@ +"""Add TestCaseIssue table + +Revision ID: ba6550a03bc8 +Revises: 2745d4e5bc72 +Create Date: 2024-08-30 13:03:39.864116+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ba6550a03bc8" +down_revision = "2745d4e5bc72" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "test_case_issue", + sa.Column("template_id", sa.String(), nullable=False), + sa.Column("case_name", sa.String(), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("test_case_issue_pkey")), + ) + op.create_index( + op.f("test_case_issue_case_name_ix"), + "test_case_issue", + ["case_name"], + unique=False, + ) + op.create_index( + op.f("test_case_issue_template_id_ix"), + "test_case_issue", + ["template_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("test_case_issue_template_id_ix"), table_name="test_case_issue") + op.drop_index(op.f("test_case_issue_case_name_ix"), table_name="test_case_issue") + op.drop_table("test_case_issue") diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index a3657a7d..38d28109 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -7,9 +7,11 @@ import requests from fastapi.testclient import TestClient +from pydantic import HttpUrl from sqlalchemy import select from sqlalchemy.orm import Session +from test_observer.controllers.test_cases.models import ReportedIssueRequest from test_observer.controllers.test_executions.models import ( C3TestResult, C3TestResultStatus, @@ -25,6 +27,7 @@ START_TEST_EXECUTION_URL = f"{BASE_URL}/test-executions/start-test" END_TEST_EXECUTION_URL = f"{BASE_URL}/test-executions/end-test" RERUN_TEST_EXECUTION_URL = f"{BASE_URL}/test-executions/reruns" +TEST_CASE_ISSUE_URL = f"{BASE_URL}/test-cases/reported-issues" START_TEST_EXECUTION_REQUESTS = [ StartTestExecutionRequest( @@ -225,6 +228,7 @@ ), C3TestResult( name="bluetooth4/beacon_eddystone_url_hci0", + template_id="bluetooth4/beacon_eddystone_url_interface", status=C3TestResultStatus.FAIL, category="Bluetooth tests", comment="", @@ -283,6 +287,24 @@ ), ] +TEST_CASE_ISSUE_REQUESTS = [ + ReportedIssueRequest( + template_id=END_TEST_EXECUTION_REQUESTS[0].test_results[2].template_id, # type: ignore + url=HttpUrl("http://bug1.link"), + description="known issue 1", + ), + ReportedIssueRequest( + case_name=END_TEST_EXECUTION_REQUESTS[0].test_results[0].name, + url=HttpUrl("http://bug2.link"), + description="known issue 2", + ), + ReportedIssueRequest( + case_name=END_TEST_EXECUTION_REQUESTS[0].test_results[1].name, + url=HttpUrl("http://bug3.link"), + description="known issue 3", + ), +] + def seed_data(client: TestClient | requests.Session, session: Session | None = None): session = session or SessionLocal() @@ -307,6 +329,11 @@ def seed_data(client: TestClient | requests.Session, session: Session | None = N END_TEST_EXECUTION_URL, json=end_request.model_dump(mode="json") ).raise_for_status() + for case_issue_request in TEST_CASE_ISSUE_REQUESTS: + client.post( + TEST_CASE_ISSUE_URL, json=case_issue_request.model_dump(mode="json") + ).raise_for_status() + _rerun_some_test_executions(client, test_executions) _add_bugurl_and_duedate(session) diff --git a/backend/test_observer/controllers/router.py b/backend/test_observer/controllers/router.py index 1fa473ea..37c87a41 100644 --- a/backend/test_observer/controllers/router.py +++ b/backend/test_observer/controllers/router.py @@ -23,7 +23,7 @@ from test_observer.data_access.setup import get_db -from . import test_executions +from . import test_cases, test_executions from .application import version from .artefacts import artefacts from .reports import reports @@ -33,6 +33,7 @@ router.include_router(test_executions.router, prefix="/v1/test-executions") router.include_router(artefacts.router, prefix="/v1/artefacts") router.include_router(reports.router, prefix="/v1/reports") +router.include_router(test_cases.router, prefix="/v1/test-cases") @router.get("/") diff --git a/backend/test_observer/controllers/test_cases/__init__.py b/backend/test_observer/controllers/test_cases/__init__.py new file mode 100644 index 00000000..5651fff8 --- /dev/null +++ b/backend/test_observer/controllers/test_cases/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from . import reported_issues + +router = APIRouter(tags=["test-cases"]) +router.include_router(reported_issues.router) diff --git a/backend/test_observer/controllers/test_cases/models.py b/backend/test_observer/controllers/test_cases/models.py new file mode 100644 index 00000000..78c3fb89 --- /dev/null +++ b/backend/test_observer/controllers/test_cases/models.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import BaseModel, HttpUrl, model_validator + + +class ReportedIssueRequest(BaseModel): + template_id: str = "" + case_name: str = "" + description: str + url: HttpUrl + + @model_validator(mode="after") + def check_a_or_b(self): + if not self.case_name and not self.template_id: + raise ValueError("Either case_name or template_id is required") + return self + + +class ReportedIssueResponse(BaseModel): + id: int + template_id: str = "" + case_name: str = "" + description: str + url: HttpUrl + created_at: datetime + updated_at: datetime diff --git a/backend/test_observer/controllers/test_cases/reported_issues.py b/backend/test_observer/controllers/test_cases/reported_issues.py new file mode 100644 index 00000000..95966655 --- /dev/null +++ b/backend/test_observer/controllers/test_cases/reported_issues.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from test_observer.data_access.models import TestCaseIssue +from test_observer.data_access.setup import get_db + +from .models import ReportedIssueRequest, ReportedIssueResponse + +router = APIRouter() + + +endpoint = "/reported-issues" + + +@router.get(endpoint, response_model=list[ReportedIssueResponse]) +def get_reported_issues( + template_id: str | None = None, + case_name: str | None = None, + db: Session = Depends(get_db), +): + stmt = select(TestCaseIssue) + if template_id: + stmt = stmt.where(TestCaseIssue.template_id == template_id) + if case_name: + stmt = stmt.where(TestCaseIssue.case_name == case_name) + return db.execute(stmt).scalars() + + +@router.post(endpoint, response_model=ReportedIssueResponse) +def create_reported_issue(request: ReportedIssueRequest, db: Session = Depends(get_db)): + issue = TestCaseIssue( + template_id=request.template_id, + url=request.url, + description=request.description, + case_name=request.case_name, + ) + db.add(issue) + db.commit() + + return issue + + +@router.put(endpoint + "/{issue_id}", response_model=ReportedIssueResponse) +def update_reported_issue( + issue_id: int, request: ReportedIssueRequest, db: Session = Depends(get_db) +): + issue = db.get(TestCaseIssue, issue_id) + for field in request.model_fields: + setattr(issue, field, getattr(request, field)) + db.commit() + return issue + + +@router.delete(endpoint + "/{issue_id}") +def delete_reported_issue(issue_id: int, db: Session = Depends(get_db)): + db.delete(db.get(TestCaseIssue, issue_id)) + db.commit() diff --git a/backend/test_observer/controllers/test_executions/models.py b/backend/test_observer/controllers/test_executions/models.py index ce649d1f..951f6ac3 100644 --- a/backend/test_observer/controllers/test_executions/models.py +++ b/backend/test_observer/controllers/test_executions/models.py @@ -18,10 +18,10 @@ # Omar Selo +from datetime import datetime from enum import Enum from typing import Annotated -from datetime import datetime from pydantic import ( AliasPath, BaseModel, @@ -143,6 +143,7 @@ class TestResultDTO(BaseModel): id: int name: str = Field(validation_alias=AliasPath("test_case", "name")) category: str = Field(validation_alias=AliasPath("test_case", "category")) + template_id: str = Field(validation_alias=AliasPath("test_case", "template_id")) status: TestResultStatus comment: str io_log: str diff --git a/backend/test_observer/data_access/models.py b/backend/test_observer/data_access/models.py index a702ede2..33f4b6da 100644 --- a/backend/test_observer/data_access/models.py +++ b/backend/test_observer/data_access/models.py @@ -447,3 +447,33 @@ class TestEvent(Base): ForeignKey("test_execution.id", ondelete="CASCADE") ) test_execution: Mapped["TestExecution"] = relationship(back_populates="test_events") + + def __repr__(self) -> str: + return data_model_repr( + self, + "event_name", + "timestamp", + "detail", + "test_execution_id", + ) + + +class TestCaseIssue(Base): + """ + A table to store issues reported on certain tests + """ + + __tablename__ = "test_case_issue" + + template_id: Mapped[str] = mapped_column(index=True) + case_name: Mapped[str] = mapped_column(index=True) + url: Mapped[str] + description: Mapped[str] + + def __repr__(self) -> str: + return data_model_repr( + self, + "template_id", + "url", + "description", + ) diff --git a/backend/tests/controllers/test_cases/test_reported_issues.py b/backend/tests/controllers/test_cases/test_reported_issues.py new file mode 100644 index 00000000..8c43c7ce --- /dev/null +++ b/backend/tests/controllers/test_cases/test_reported_issues.py @@ -0,0 +1,182 @@ +from collections.abc import Callable +from typing import Any, TypeAlias + +import pytest +from fastapi.testclient import TestClient +from httpx import Response + +endpoint = "/v1/test-cases/reported-issues" +valid_post_data = { + "template_id": "template 1", + "case_name": "case", + "url": "http://issue.link/", + "description": "some description", +} + + +@pytest.fixture +def post(test_client: TestClient): + def post_helper(data: Any) -> Response: # noqa: ANN401 + return test_client.post(endpoint, json=data) + + return post_helper + + +@pytest.fixture +def get(test_client: TestClient): + def get_helper(query_parameters: dict[str, str] | None = None) -> Response: + return test_client.get(endpoint, params=query_parameters) + + return get_helper + + +@pytest.fixture +def put(test_client: TestClient): + def put_helper(id: int, data: Any) -> Response: # noqa: ANN401 + return test_client.put(f"{endpoint}/{id}", json=data) + + return put_helper + + +@pytest.fixture +def delete(test_client: TestClient): + def delete_helper(id: int) -> Response: + return test_client.delete(f"{endpoint}/{id}") + + return delete_helper + + +Post: TypeAlias = Callable[[Any], Response] +Put: TypeAlias = Callable[[int, Any], Response] +Get: TypeAlias = Callable[..., Response] +Delete: TypeAlias = Callable[[int], Response] + + +def test_empty_get(get: Get): + response = get() + assert response.status_code == 200 + assert response.json() == [] + + +@pytest.mark.parametrize("field", ["url", "description"]) +def test_post_requires_field(post: Post, field: str): + data = {k: v for k, v in valid_post_data.items() if k != field} + response = post(data) + _assert_fails_validation(response, field, "missing") + + +def test_post_requires_template_id_or_case_name(post: Post): + data = {**valid_post_data} + data.pop("template_id") + data.pop("case_name") + response = post(data) + + assert response.status_code == 422 + + +def test_post_validates_url(post: Post): + response = post({**valid_post_data, "url": "invalid url"}) + _assert_fails_validation(response, "url", "url_parsing") + + +def test_valid_template_id_post(post: Post): + data = {k: v for k, v in valid_post_data.items() if k != "case_name"} + response = post(data) + assert response.status_code == 200 + _assert_reported_issue(response.json(), data) + + +def test_valid_case_name_post(post: Post): + data = {k: v for k, v in valid_post_data.items() if k != "template_id"} + response = post(data) + assert response.status_code == 200 + _assert_reported_issue(response.json(), data) + + +def test_post_three_then_get(post: Post, get: Get): + issue1 = {**valid_post_data, "description": "Description 1"} + issue2 = {**valid_post_data, "description": "Description 2"} + issue3 = {**valid_post_data, "description": "Description 3"} + + post(issue1) + post(issue2) + post(issue3) + + response = get() + assert response.status_code == 200 + json = response.json() + _assert_reported_issue(json[0], issue1) + _assert_reported_issue(json[1], issue2) + _assert_reported_issue(json[2], issue3) + + +def test_get_specific_template_id(post: Post, get: Get): + issue1 = {**valid_post_data, "template_id": "Template 1"} + issue2 = {**valid_post_data, "template_id": "Template 2"} + + post(issue1) + post(issue2) + + response = get({"template_id": "Template 2"}) + assert response.status_code == 200 + json = response.json() + assert len(json) == 1 + _assert_reported_issue(json[0], issue2) + + +def test_get_specific_case_name(post: Post, get: Get): + issue1 = {**valid_post_data, "case_name": "Case 1"} + issue2 = {**valid_post_data, "case_name": "Case 2"} + + post(issue1) + post(issue2) + + response = get({"case_name": "Case 2"}) + assert response.status_code == 200 + json = response.json() + assert len(json) == 1 + _assert_reported_issue(json[0], issue2) + + +def test_update_description(post: Post, get: Get, put: Put): + response = post(valid_post_data) + issue = response.json() + issue["description"] = "Updated" + response = put(issue["id"], {**issue, "description": "Updated"}) + + assert response.status_code == 200 + _assert_reported_issue(response.json(), issue) + + response = get() + _assert_reported_issue(response.json()[0], issue) + + +def test_delete_issue(post: Post, get: Get, delete: Delete): + response = post(valid_post_data) + + response = delete(response.json()["id"]) + assert response.status_code == 200 + + response = get() + assert response.json() == [] + + +def _assert_fails_validation(response: Response, field: str, type: str) -> None: + assert response.status_code == 422 + problem = response.json()["detail"][0] + assert problem["type"] == type + assert problem["loc"] == ["body", field] + + +def _assert_reported_issue(value: dict, expected: dict) -> None: + if "template_id" in expected: + assert value["template_id"] == expected["template_id"] + + if "case_name" in expected: + assert value["case_name"] == expected["case_name"] + + assert value["url"] == expected["url"] + assert value["description"] == expected["description"] + assert isinstance(value["id"], int) + assert isinstance(value["created_at"], str) + assert isinstance(value["updated_at"], str) diff --git a/backend/tests/controllers/test_executions/test_get_test_results.py b/backend/tests/controllers/test_executions/test_get_test_results.py index 7159a764..dfb1511a 100644 --- a/backend/tests/controllers/test_executions/test_get_test_results.py +++ b/backend/tests/controllers/test_executions/test_get_test_results.py @@ -22,7 +22,7 @@ def test_fetch_test_results(test_client: TestClient, generator: DataGenerator): environment = generator.gen_environment() - test_case = generator.gen_test_case() + test_case = generator.gen_test_case(template_id="template") artefact_first = generator.gen_artefact("beta", version="1.1.1") artefact_build_first = generator.gen_artefact_build(artefact_first) @@ -56,6 +56,7 @@ def test_fetch_test_results(test_client: TestClient, generator: DataGenerator): json = response.json() assert json[0]["name"] == test_case.name assert json[0]["category"] == test_case.category + assert json[0]["template_id"] == test_case.template_id assert json[0]["status"] == test_result_second.status.name assert json[0]["comment"] == test_result_second.comment assert json[0]["io_log"] == test_result_second.io_log