From b930f0298435e12b45e12aecaefa994511f51fd4 Mon Sep 17 00:00:00 2001 From: Omar Abou Selo Date: Wed, 15 Jan 2025 18:21:26 +0300 Subject: [PATCH] Add image testing backend (#244) * Extract TestExecutionStarter class from start_test_execution controller * Move dependencies to controller class * Extract methods of TestExecutionController for readability * Remove weird try catch * Remove unused import * Store revision for charms too * Simplify create artefact function * Validate stage name on start test request * Add image family * Add image artefact fields * Add functionality to store artefact image specific fields * Make start image test more thorough * Add some todos * Use multiple parameter Literal * Fix some linting issues * Add image_url to Artefact * CExtract assertion logic * Test start test for all families * Create TestStartTest class to test all families together * Move test_requires_family_field into TestStartTest * Add tests for required fields of image and charm start test * Add test for reuse of objects on test start * Update artefact uniqueness * Move all family independent start tests into common class * Support getting image artefacts * Add logic to get previous test results for images * Fix artefact versions for images * Remove test requiring ci_link * Add some image artefacts to seed script * Fix tests --- .github/workflows/test_backend.yml | 2 +- backend/migrations/env.py | 10 +- ...1_08_1312-121edad6b53f_add_image_family.py | 100 ++++ ...68efd_update_artefact_unique_constraint.py | 73 +++ backend/pyproject.toml | 4 + backend/scripts/seed_data.py | 48 +- .../controllers/artefacts/artefacts.py | 2 + .../controllers/artefacts/models.py | 5 + .../controllers/reports/test_executions.py | 2 + .../controllers/reports/test_results.py | 2 + .../controllers/test_executions/logic.py | 25 +- .../controllers/test_executions/models.py | 28 +- .../controllers/test_executions/start_test.py | 176 ++++--- backend/test_observer/data_access/models.py | 28 +- .../test_observer/data_access/models_enums.py | 3 + .../test_observer/data_access/repository.py | 136 ++--- .../controllers/artefacts/test_artefacts.py | 124 ++--- .../reports/test_reports_test_executions.py | 2 + .../reports/test_reports_test_results.py | 2 + .../test_executions/test_reruns.py | 7 +- .../test_executions/test_start_test.py | 469 +++++++++--------- backend/tests/data_access/test_repository.py | 22 - backend/tests/data_generator.py | 36 ++ 23 files changed, 826 insertions(+), 480 deletions(-) create mode 100644 backend/migrations/versions/2025_01_08_1312-121edad6b53f_add_image_family.py create mode 100644 backend/migrations/versions/2025_01_14_0934-2be627e68efd_update_artefact_unique_constraint.py diff --git a/.github/workflows/test_backend.yml b/.github/workflows/test_backend.yml index 3838df6c..588122d5 100644 --- a/.github/workflows/test_backend.yml +++ b/.github/workflows/test_backend.yml @@ -39,7 +39,7 @@ jobs: - run: poetry install - run: poetry run black --check test_observer tests migrations scripts tasks - run: poetry run ruff test_observer tests migrations scripts tasks - - run: poetry run mypy --explicit-package-bases test_observer tests migrations scripts tasks + - run: poetry run mypy . - run: poetry run pytest env: TEST_DB_URL: postgresql+pg8000://postgres:password@localhost:5432/postgres diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 60d65917..9e054b58 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,11 +1,9 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config, pool - from alembic import context +from sqlalchemy import engine_from_config, pool from test_observer.data_access import Base - from test_observer.data_access.setup import DB_URL # for 'autogenerate' support @@ -59,7 +57,11 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure( + connection=connection, + target_metadata=target_metadata, + transaction_per_migration=True, + ) with context.begin_transaction(): context.run_migrations() diff --git a/backend/migrations/versions/2025_01_08_1312-121edad6b53f_add_image_family.py b/backend/migrations/versions/2025_01_08_1312-121edad6b53f_add_image_family.py new file mode 100644 index 00000000..2b813367 --- /dev/null +++ b/backend/migrations/versions/2025_01_08_1312-121edad6b53f_add_image_family.py @@ -0,0 +1,100 @@ +"""Add image family + +Revision ID: 121edad6b53f +Revises: 7878a1b29384 +Create Date: 2025-01-08 13:12:05.831020+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "121edad6b53f" +down_revision = "7878a1b29384" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + add_image_family() + add_new_stages() + + add_os_column() + add_release_column() + add_sha256_column() + add_owner_column() + add_image_url_column() + + +def add_image_family(): + op.execute("ALTER TYPE familyname ADD VALUE IF NOT EXISTS 'image'") + + +def add_new_stages(): + op.execute("ALTER TYPE stagename ADD VALUE IF NOT EXISTS 'pending'") + op.execute("ALTER TYPE stagename ADD VALUE IF NOT EXISTS 'current'") + + +def add_os_column(): + op.add_column("artefact", sa.Column("os", sa.String(length=200))) + op.execute("UPDATE artefact SET os = '' WHERE os is NULL") + op.alter_column("artefact", "os", nullable=False) + + +def add_release_column(): + op.add_column("artefact", sa.Column("release", sa.String(length=200))) + op.execute("UPDATE artefact SET release = '' WHERE release is NULL") + op.alter_column("artefact", "release", nullable=False) + + +def add_sha256_column(): + op.add_column("artefact", sa.Column("sha256", sa.String(length=200))) + op.execute("UPDATE artefact SET sha256 = '' WHERE sha256 is NULL") + op.alter_column("artefact", "sha256", nullable=False) + + +def add_owner_column(): + op.add_column("artefact", sa.Column("owner", sa.String(length=200))) + op.execute("UPDATE artefact SET owner = '' WHERE owner is NULL") + op.alter_column("artefact", "owner", nullable=False) + + +def add_image_url_column(): + op.add_column("artefact", sa.Column("image_url", sa.String(length=200))) + op.execute("UPDATE artefact SET image_url = '' WHERE image_url is NULL") + op.alter_column("artefact", "image_url", nullable=False) + + +def downgrade() -> None: + op.execute("DELETE FROM artefact WHERE family = 'image'") + op.drop_column("artefact", "owner") + op.drop_column("artefact", "sha256") + op.drop_column("artefact", "release") + op.drop_column("artefact", "os") + op.drop_column("artefact", "image_url") + + remove_image_family_enum_value() + remove_added_stage_enum_values() + + +def remove_image_family_enum_value(): + op.execute("ALTER TYPE familyname RENAME TO familyname_old") + op.execute("CREATE TYPE familyname AS " "ENUM('snap', 'deb', 'charm')") + op.execute( + "ALTER TABLE artefact ALTER COLUMN family TYPE " + "familyname USING family::text::familyname" + ) + op.execute("DROP TYPE familyname_old") + + +def remove_added_stage_enum_values(): + op.execute("ALTER TYPE stagename RENAME TO stagename_old") + op.execute( + "CREATE TYPE stagename AS " + "ENUM('edge', 'beta', 'candidate', 'stable', 'proposed', 'updates')" + ) + op.execute( + "ALTER TABLE artefact ALTER COLUMN stage TYPE " + "stagename USING stage::text::stagename" + ) + op.execute("DROP TYPE stagename_old") diff --git a/backend/migrations/versions/2025_01_14_0934-2be627e68efd_update_artefact_unique_constraint.py b/backend/migrations/versions/2025_01_14_0934-2be627e68efd_update_artefact_unique_constraint.py new file mode 100644 index 00000000..eda619c9 --- /dev/null +++ b/backend/migrations/versions/2025_01_14_0934-2be627e68efd_update_artefact_unique_constraint.py @@ -0,0 +1,73 @@ +"""Update artefact unique constraint + +Revision ID: 2be627e68efd +Revises: 121edad6b53f +Create Date: 2025-01-14 09:34:28.190863+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2be627e68efd" +down_revision = "121edad6b53f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "unique_charm", + "artefact", + ["name", "version", "track"], + unique=True, + postgresql_where=sa.text("family = 'charm'"), + ) + op.create_index( + "unique_image", + "artefact", + ["sha256"], + unique=True, + postgresql_where=sa.text("family = 'image'"), + ) + + op.drop_index("unique_snap", "artefact") + op.create_index( + "unique_snap", + "artefact", + ["name", "version", "track"], + unique=True, + postgresql_where=sa.text("family = 'snap'"), + ) + + op.drop_index("unique_deb", "artefact") + op.create_index( + "unique_deb", + "artefact", + ["name", "version", "series", "repo"], + unique=True, + postgresql_where=sa.text("family = 'deb'"), + ) + + +def downgrade() -> None: + op.drop_index("unique_deb", "artefact") + op.create_index( + "unique_deb", + "artefact", + ["name", "version", "series", "repo"], + unique=True, + postgresql_where=sa.text("series != '' AND repo != ''"), + ) + + op.drop_index("unique_snap", "artefact") + op.create_index( + "unique_snap", + "artefact", + ["name", "version", "track"], + unique=True, + postgresql_where=sa.text("track != ''"), + ) + + op.drop_index("unique_image", table_name="artefact") + op.drop_index("unique_charm", table_name="artefact") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7dc58bdf..f2c9ba1c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -57,6 +57,10 @@ select = [ ] ignore = ["ANN201", "ANN003", "N999", "ANN101", "ANN204", "N818", "B008"] +[tool.mypy] +exclude = "charm/*" +explicit_package_bases = true + [[tool.mypy.overrides]] module = "celery.*" ignore_missing_imports = true diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py index 8a84d622..20150942 100644 --- a/backend/scripts/seed_data.py +++ b/backend/scripts/seed_data.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -# ruff: noqa: E501 Line too long +# ruff: noqa from datetime import date, timedelta from textwrap import dedent @@ -21,6 +21,7 @@ EndTestExecutionRequest, StartCharmTestExecutionRequest, StartDebTestExecutionRequest, + StartImageTestExecutionRequest, StartSnapTestExecutionRequest, ) from test_observer.data_access.models import Artefact @@ -265,6 +266,51 @@ ci_link="http://example13", test_plan="com.canonical.solutions-qa::tbd", ), + StartImageTestExecutionRequest( + name="noble-live-desktop-amd64", + os="ubuntu", + release="noble", + arch="amd64", + version="20240827", + sha256="e71fb5681e63330445eec6fc3fe043f365289c2e595e3ceeac08fbeccfb9a957", + owner="foundations", + image_url=HttpUrl( + "https://cdimage.ubuntu.com/noble/daily-live/20240827/noble-desktop-amd64.iso" + ), + execution_stage=StageName.pending, + test_plan="image test plan", + environment="xps", + ), + StartImageTestExecutionRequest( + name="noble-live-desktop-amd64", + os="ubuntu", + release="noble", + arch="amd64", + version="20240827", + sha256="e71fb5681e63330445eec6fc3fe043f365289c2e595e3ceeac08fbeccfb9a957", + owner="foundations", + image_url=HttpUrl( + "https://cdimage.ubuntu.com/noble/daily-live/20240827/noble-desktop-amd64.iso" + ), + execution_stage=StageName.pending, + test_plan="desktop image test plan", + environment="xps", + ), + StartImageTestExecutionRequest( + name="ubuntu-core-20-arm64-raspi", + os="ubuntu-core", + release="20", + arch="amd64+raspi", + version="20221025.4", + sha256="e94418aa109cf5886a50e828e98ac68361ea7e3ca1ab4aed2bbddc0a299b334f", + owner="snapd", + image_url=HttpUrl( + "https://cdimage.ubuntu.com/ubuntu-core/20/stable/20221025.4/ubuntu-core-20-arm64+raspi.img.xz" + ), + execution_stage=StageName.pending, + test_plan="core image test plan", + environment="rpi3", + ), ] END_TEST_EXECUTION_REQUESTS = [ diff --git a/backend/test_observer/controllers/artefacts/artefacts.py b/backend/test_observer/controllers/artefacts/artefacts.py index b59a8fda..648da7cb 100644 --- a/backend/test_observer/controllers/artefacts/artefacts.py +++ b/backend/test_observer/controllers/artefacts/artefacts.py @@ -133,5 +133,7 @@ def get_artefact_versions( .where(Artefact.track == artefact.track) .where(Artefact.series == artefact.series) .where(Artefact.repo == artefact.repo) + .where(Artefact.os == artefact.os) + .where(Artefact.series == artefact.series) .order_by(Artefact.id.desc()) ) diff --git a/backend/test_observer/controllers/artefacts/models.py b/backend/test_observer/controllers/artefacts/models.py index d7d1014c..72a2805d 100644 --- a/backend/test_observer/controllers/artefacts/models.py +++ b/backend/test_observer/controllers/artefacts/models.py @@ -55,6 +55,11 @@ class ArtefactDTO(BaseModel): store: str series: str repo: str + os: str + release: str + owner: str + sha256: str + image_url: str stage: str status: ArtefactStatus assignee: UserDTO | None diff --git a/backend/test_observer/controllers/reports/test_executions.py b/backend/test_observer/controllers/reports/test_executions.py index 71db1359..c6c99119 100644 --- a/backend/test_observer/controllers/reports/test_executions.py +++ b/backend/test_observer/controllers/reports/test_executions.py @@ -27,6 +27,8 @@ Artefact.track, Artefact.series, Artefact.repo, + Artefact.os, + Artefact.series, TestExecution.id, TestExecution.status, TestExecution.ci_link, diff --git a/backend/test_observer/controllers/reports/test_results.py b/backend/test_observer/controllers/reports/test_results.py index b716ad76..0799736e 100644 --- a/backend/test_observer/controllers/reports/test_results.py +++ b/backend/test_observer/controllers/reports/test_results.py @@ -28,6 +28,8 @@ Artefact.track, Artefact.series, Artefact.repo, + Artefact.os, + Artefact.series, Artefact.created_at, TestExecution.id, TestExecution.status, diff --git a/backend/test_observer/controllers/test_executions/logic.py b/backend/test_observer/controllers/test_executions/logic.py index ca38781b..0d0939f1 100644 --- a/backend/test_observer/controllers/test_executions/logic.py +++ b/backend/test_observer/controllers/test_executions/logic.py @@ -17,7 +17,7 @@ from collections import defaultdict from sqlalchemy import delete, desc, func, or_, over, select -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import Session from test_observer.common.constants import PREVIOUS_TEST_RESULT_COUNT from test_observer.controllers.test_executions.models import PreviousTestResult @@ -30,27 +30,6 @@ ) -def delete_related_rerun_requests( - db: Session, artefact_build_id: int, environment_id: int, test_plan: str -): - related_test_execution_runs = db.scalars( - select(TestExecution) - .where( - TestExecution.artefact_build_id == artefact_build_id, - TestExecution.environment_id == environment_id, - TestExecution.test_plan == test_plan, - ) - .options(selectinload(TestExecution.rerun_request)) - ) - - for te in related_test_execution_runs: - rerun_request = te.rerun_request - if rerun_request: - db.delete(rerun_request) - - db.commit() - - def delete_previous_results( db: Session, test_execution: TestExecution, @@ -111,6 +90,8 @@ def get_previous_test_results( Artefact.series == test_execution.artefact_build.artefact.series, Artefact.repo == test_execution.artefact_build.artefact.repo, Artefact.track == test_execution.artefact_build.artefact.track, + Artefact.os == test_execution.artefact_build.artefact.os, + Artefact.series == test_execution.artefact_build.artefact.series, Artefact.id <= test_execution.artefact_build.artefact_id, or_( TestExecution.id < test_execution.id, diff --git a/backend/test_observer/controllers/test_executions/models.py b/backend/test_observer/controllers/test_executions/models.py index 9eaaacd0..03113cd9 100644 --- a/backend/test_observer/controllers/test_executions/models.py +++ b/backend/test_observer/controllers/test_executions/models.py @@ -32,24 +32,23 @@ ) from test_observer.common.constants import PREVIOUS_TEST_RESULT_COUNT +from test_observer.controllers.artefacts.models import ( + ArtefactBuildMinimalDTO, + ArtefactDTO, + TestExecutionDTO, +) from test_observer.data_access.models_enums import ( FamilyName, StageName, TestExecutionStatus, TestResultStatus, ) -from test_observer.controllers.artefacts.models import ( - TestExecutionDTO, - ArtefactDTO, - ArtefactBuildMinimalDTO, -) class _StartTestExecutionRequest(BaseModel): name: str version: str arch: str - execution_stage: StageName environment: str ci_link: Annotated[str, HttpUrl] | None = None test_plan: str = Field(max_length=200) @@ -68,18 +67,35 @@ class StartSnapTestExecutionRequest(_StartTestExecutionRequest): revision: int track: str store: str + execution_stage: Literal[ + StageName.edge, StageName.beta, StageName.candidate, StageName.stable + ] class StartDebTestExecutionRequest(_StartTestExecutionRequest): family: Literal[FamilyName.deb] series: str repo: str + execution_stage: Literal[StageName.proposed, StageName.updates] class StartCharmTestExecutionRequest(_StartTestExecutionRequest): family: Literal[FamilyName.charm] revision: int track: str + execution_stage: Literal[ + StageName.edge, StageName.beta, StageName.candidate, StageName.stable + ] + + +class StartImageTestExecutionRequest(_StartTestExecutionRequest): + family: Literal[FamilyName.image] = FamilyName.image + execution_stage: Literal[StageName.pending, StageName.current] + os: str + release: str + sha256: str + owner: str + image_url: HttpUrl class C3TestResultStatus(str, Enum): diff --git a/backend/test_observer/controllers/test_executions/start_test.py b/backend/test_observer/controllers/test_executions/start_test.py index 6710a628..7f81a72b 100644 --- a/backend/test_observer/controllers/test_executions/start_test.py +++ b/backend/test_observer/controllers/test_executions/start_test.py @@ -16,12 +16,10 @@ import random -from fastapi import APIRouter, Body, Depends, HTTPException -from sqlalchemy.orm import Session +from fastapi import APIRouter, Body, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload -from test_observer.controllers.test_executions.logic import ( - delete_related_rerun_requests, -) from test_observer.data_access.models import ( Artefact, ArtefactBuild, @@ -36,99 +34,131 @@ from .models import ( StartCharmTestExecutionRequest, StartDebTestExecutionRequest, + StartImageTestExecutionRequest, StartSnapTestExecutionRequest, ) router = APIRouter() -@router.put("/start-test") -def start_test_execution( - request: StartSnapTestExecutionRequest - | StartDebTestExecutionRequest - | StartCharmTestExecutionRequest = Body(discriminator="family"), - db: Session = Depends(get_db), -): - try: - artefact_filter_kwargs: dict[str, str | int] = { - "name": request.name, - "version": request.version, - } - match request: - case StartSnapTestExecutionRequest(): - artefact_filter_kwargs["store"] = request.store - artefact_filter_kwargs["track"] = request.track - case StartDebTestExecutionRequest(): - artefact_filter_kwargs["series"] = request.series - artefact_filter_kwargs["repo"] = request.repo - case StartCharmTestExecutionRequest(): - artefact_filter_kwargs["track"] = request.track - - artefact = get_or_create( - db, - Artefact, - filter_kwargs=artefact_filter_kwargs, +class StartTestExecutionController: + def __init__( + self, + request: StartSnapTestExecutionRequest + | StartDebTestExecutionRequest + | StartCharmTestExecutionRequest + | StartImageTestExecutionRequest = Body(discriminator="family"), + db: Session = Depends(get_db), + ): + self.request = request + self.db = db + + def execute(self): + self.create_artefact() + self.create_environment() + self.create_artefact_build() + self.create_artefact_build_environment() + self.create_test_execution() + + self.delete_related_rerun_requests() + + self.assign_reviewer() + + return {"id": self.test_execution.id} + + def assign_reviewer(self): + if self.artefact.assignee_id is None and (users := self.db.query(User).all()): + self.artefact.assignee = random.choice(users) + self.db.commit() + + def create_test_execution(self): + self.test_execution = get_or_create( + self.db, + TestExecution, + filter_kwargs={ + "ci_link": self.request.ci_link, + }, creation_kwargs={ - "family": request.family.value, - "stage": request.execution_stage, + "status": self.request.initial_status, + "environment_id": self.environment.id, + "artefact_build_id": self.artefact_build.id, + "test_plan": self.request.test_plan, }, ) - environment = get_or_create( - db, - Environment, - filter_kwargs={"name": request.environment, "architecture": request.arch}, + def create_artefact_build_environment(self): + get_or_create( + self.db, + ArtefactBuildEnvironmentReview, + filter_kwargs={ + "environment_id": self.environment.id, + "artefact_build_id": self.artefact_build.id, + }, ) - artefact_build = get_or_create( - db, + def create_artefact_build(self): + self.artefact_build = get_or_create( + self.db, ArtefactBuild, filter_kwargs={ - "architecture": request.arch, - "revision": request.revision - if isinstance( - request, - StartSnapTestExecutionRequest | StartCharmTestExecutionRequest, - ) - else None, - "artefact_id": artefact.id, + "architecture": self.request.arch, + "revision": getattr(self.request, "revision", None), + "artefact_id": self.artefact.id, }, ) - get_or_create( - db, - ArtefactBuildEnvironmentReview, + def create_environment(self): + self.environment = get_or_create( + self.db, + Environment, filter_kwargs={ - "environment_id": environment.id, - "artefact_build_id": artefact_build.id, + "name": self.request.environment, + "architecture": self.request.arch, }, ) - test_execution = get_or_create( - db, - TestExecution, + def create_artefact(self) -> None: + self.artefact = get_or_create( + self.db, + Artefact, filter_kwargs={ - "ci_link": request.ci_link, - }, - creation_kwargs={ - "status": request.initial_status, - "environment_id": environment.id, - "artefact_build_id": artefact_build.id, - "test_plan": request.test_plan, + "name": self.request.name, + "version": self.request.version, + "family": self.request.family, + "store": getattr(self.request, "store", ""), + "track": getattr(self.request, "track", ""), + "series": getattr(self.request, "series", ""), + "repo": getattr(self.request, "repo", ""), + "os": getattr(self.request, "os", ""), + "release": getattr(self.request, "release", ""), + "sha256": getattr(self.request, "sha256", ""), + "owner": getattr(self.request, "owner", ""), + "image_url": getattr(self.request, "image_url", ""), }, + creation_kwargs={"stage": self.request.execution_stage}, ) - delete_related_rerun_requests( - db, - test_execution.artefact_build_id, - test_execution.environment_id, - test_execution.test_plan, + def delete_related_rerun_requests(self): + related_test_execution_runs = self.db.scalars( + select(TestExecution) + .where( + TestExecution.artefact_build_id == self.artefact_build.id, + TestExecution.environment_id == self.environment.id, + TestExecution.test_plan == self.test_execution.test_plan, + ) + .options(selectinload(TestExecution.rerun_request)) ) - if artefact.assignee_id is None and (users := db.query(User).all()): - artefact.assignee = random.choice(users) - db.commit() + for te in related_test_execution_runs: + rerun_request = te.rerun_request + if rerun_request: + self.db.delete(rerun_request) + + self.db.commit() + - return {"id": test_execution.id} - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.put("/start-test") +def start_test_execution( + test_starter: StartTestExecutionController = Depends(StartTestExecutionController), +): + return test_starter.execute() diff --git a/backend/test_observer/data_access/models.py b/backend/test_observer/data_access/models.py index d371dd74..0dd1b2be 100644 --- a/backend/test_observer/data_access/models.py +++ b/backend/test_observer/data_access/models.py @@ -129,6 +129,11 @@ class Artefact(Base): store: Mapped[str] = mapped_column(default="") series: Mapped[str] = mapped_column(default="") repo: Mapped[str] = mapped_column(default="") + os: Mapped[str] = mapped_column(String(200), default="") + release: Mapped[str] = mapped_column(String(200), default="") + sha256: Mapped[str] = mapped_column(String(200), default="") + owner: Mapped[str] = mapped_column(String(200), default="") + image_url: Mapped[str] = mapped_column(String(200), default="") # Relationships builds: Mapped[list["ArtefactBuild"]] = relationship( @@ -149,7 +154,15 @@ def architectures(self) -> set[str]: "name", "version", "track", - postgresql_where=column("track") != "", + postgresql_where=column("family") == FamilyName.snap.name, + unique=True, + ), + Index( + "unique_charm", + "name", + "version", + "track", + postgresql_where=column("family") == FamilyName.charm.name, unique=True, ), Index( @@ -158,7 +171,13 @@ def architectures(self) -> set[str]: "version", "series", "repo", - postgresql_where=(column("series") != "") & (column("repo") != ""), + postgresql_where=column("family") == FamilyName.deb.name, + unique=True, + ), + Index( + "unique_image", + "sha256", + postgresql_where=column("family") == FamilyName.image.name, unique=True, ), ) @@ -174,6 +193,11 @@ def __repr__(self) -> str: "store", "series", "repo", + "os", + "release", + "sha256", + "owner", + "image_url", "due_date", "status", ) diff --git a/backend/test_observer/data_access/models_enums.py b/backend/test_observer/data_access/models_enums.py index 04d8fdc1..f3008d38 100644 --- a/backend/test_observer/data_access/models_enums.py +++ b/backend/test_observer/data_access/models_enums.py @@ -26,6 +26,7 @@ class FamilyName(str, Enum): snap = "snap" deb = "deb" charm = "charm" + image = "image" class StageName(str, Enum): @@ -35,6 +36,8 @@ class StageName(str, Enum): beta = "beta" candidate = "candidate" stable = "stable" + pending = "pending" + current = "current" def _compare(self, other: str) -> int: stages = list(StageName.__members__.values()) diff --git a/backend/test_observer/data_access/repository.py b/backend/test_observer/data_access/repository.py index f864a348..947fa450 100644 --- a/backend/test_observer/data_access/repository.py +++ b/backend/test_observer/data_access/repository.py @@ -34,7 +34,6 @@ def get_artefacts_by_family( session: Session, family: FamilyName, - latest_only: bool = True, load_environment_reviews: bool = False, order_by_columns: Iterable[Any] | None = None, ) -> list[Artefact]: @@ -43,85 +42,96 @@ def get_artefacts_by_family( :session: DB session :family: name of the family - :latest_only: return only latest artefacts, i.e. for each group of artefacts - with the same name and source but different version return - the latest one in a stage :load_stage: whether to eagerly load stage object in all artefacts :return: list of Artefacts """ - if latest_only: - base_query = ( - session.query( - Artefact.stage, - Artefact.name, - func.max(Artefact.created_at).label("max_created"), - ) - .filter(Artefact.family == family) - .group_by(Artefact.stage, Artefact.name) + base_query = ( + session.query( + Artefact.stage, + Artefact.name, + func.max(Artefact.created_at).label("max_created"), ) + .filter(Artefact.family == family) + .group_by(Artefact.stage, Artefact.name) + ) + + match family: + case FamilyName.snap: + subquery = ( + base_query.add_columns(Artefact.track) + .group_by(Artefact.track) + .subquery() + ) - match family: - case FamilyName.snap: - subquery = ( - base_query.add_columns(Artefact.track) - .group_by(Artefact.track) - .subquery() - ) + query = session.query(Artefact).join( + subquery, + and_( + Artefact.stage == subquery.c.stage, + Artefact.name == subquery.c.name, + Artefact.created_at == subquery.c.max_created, + Artefact.track == subquery.c.track, + ), + ) - query = session.query(Artefact).join( - subquery, - and_( - Artefact.stage == subquery.c.stage, - Artefact.name == subquery.c.name, - Artefact.created_at == subquery.c.max_created, - Artefact.track == subquery.c.track, - ), - ) + case FamilyName.deb: + subquery = ( + base_query.add_columns(Artefact.repo, Artefact.series) + .group_by(Artefact.repo, Artefact.series) + .subquery() + ) - case FamilyName.deb: - subquery = ( - base_query.add_columns(Artefact.repo, Artefact.series) - .group_by(Artefact.repo, Artefact.series) - .subquery() - ) + query = session.query(Artefact).join( + subquery, + and_( + Artefact.stage == subquery.c.stage, + Artefact.name == subquery.c.name, + Artefact.created_at == subquery.c.max_created, + Artefact.repo == subquery.c.repo, + Artefact.series == subquery.c.series, + ), + ) - query = session.query(Artefact).join( + case FamilyName.charm: + subquery = ( + base_query.join(ArtefactBuild) + .add_columns(Artefact.track, ArtefactBuild.architecture) + .group_by(Artefact.track, ArtefactBuild.architecture) + .subquery() + ) + + query = ( + session.query(Artefact) + .join(ArtefactBuild) + .join( subquery, and_( Artefact.stage == subquery.c.stage, Artefact.name == subquery.c.name, Artefact.created_at == subquery.c.max_created, - Artefact.repo == subquery.c.repo, - Artefact.series == subquery.c.series, + Artefact.track == subquery.c.track, + ArtefactBuild.architecture == subquery.c.architecture, ), ) + .distinct() + ) - case FamilyName.charm: - subquery = ( - base_query.join(ArtefactBuild) - .add_columns(Artefact.track, ArtefactBuild.architecture) - .group_by(Artefact.track, ArtefactBuild.architecture) - .subquery() - ) - - query = ( - session.query(Artefact) - .join(ArtefactBuild) - .join( - subquery, - and_( - Artefact.stage == subquery.c.stage, - Artefact.name == subquery.c.name, - Artefact.created_at == subquery.c.max_created, - Artefact.track == subquery.c.track, - ArtefactBuild.architecture == subquery.c.architecture, - ), - ) - .distinct() - ) + case FamilyName.image: + subquery = ( + base_query.add_columns(Artefact.os, Artefact.release) + .group_by(Artefact.os, Artefact.release) + .subquery() + ) - else: - query = session.query(Artefact).filter(Artefact.family == family) + query = session.query(Artefact).join( + subquery, + and_( + Artefact.stage == subquery.c.stage, + Artefact.name == subquery.c.name, + Artefact.created_at == subquery.c.max_created, + Artefact.os == subquery.c.os, + Artefact.release == subquery.c.release, + ), + ) if load_environment_reviews: query = query.options( diff --git a/backend/tests/controllers/artefacts/test_artefacts.py b/backend/tests/controllers/artefacts/test_artefacts.py index 66a557a7..bf64824b 100644 --- a/backend/tests/controllers/artefacts/test_artefacts.py +++ b/backend/tests/controllers/artefacts/test_artefacts.py @@ -18,11 +18,12 @@ # Omar Selo # Nadzeya Hutsko from datetime import date, timedelta +from typing import Any from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from test_observer.data_access.models import TestExecution +from test_observer.data_access.models import Artefact, TestExecution from test_observer.data_access.models_enums import ( ArtefactBuildEnvironmentReviewDecision, ArtefactStatus, @@ -47,30 +48,26 @@ def test_get_latest_artefacts_by_family( generator.gen_artefact(StageName.proposed, family=FamilyName.deb) response = test_client.get("/v1/artefacts", params={"family": "snap"}) + assert response.status_code == 200 + _assert_get_artefacts_response(response.json(), [relevant_artefact]) + + +def test_get_relevant_image_artefacts( + test_client: TestClient, generator: DataGenerator +): + old_image = generator.gen_image() + new_image = generator.gen_image( + sha256="someothersha256", + version="20250101", + created_at=old_image.created_at + timedelta(days=1), + ) + + response = test_client.get("/v1/artefacts", params={"family": "image"}) assert response.status_code == 200 - assert response.json() == [ - { - "id": relevant_artefact.id, - "name": relevant_artefact.name, - "version": relevant_artefact.version, - "track": relevant_artefact.track, - "store": relevant_artefact.store, - "series": relevant_artefact.series, - "repo": relevant_artefact.repo, - "stage": relevant_artefact.stage, - "status": relevant_artefact.status, - "assignee": None, - "due_date": ( - relevant_artefact.due_date.strftime("%Y-%m-%d") - if relevant_artefact.due_date - else None - ), - "bug_link": "", - "all_environment_reviews_count": 0, - "completed_environment_reviews_count": 0, - } - ] + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["sha256"] == new_image.sha256 def test_get_artefact(test_client: TestClient, generator: DataGenerator): @@ -87,27 +84,7 @@ def test_get_artefact(test_client: TestClient, generator: DataGenerator): response = test_client.get(f"/v1/artefacts/{a.id}") assert response.status_code == 200 - assert response.json() == { - "id": a.id, - "name": a.name, - "version": a.version, - "track": a.track, - "store": a.store, - "series": a.series, - "repo": a.repo, - "stage": a.stage, - "status": a.status, - "assignee": { - "id": u.id, - "launchpad_handle": u.launchpad_handle, - "launchpad_email": u.launchpad_email, - "name": u.name, - }, - "due_date": "2024-12-24", - "bug_link": a.bug_link, - "all_environment_reviews_count": 0, - "completed_environment_reviews_count": 0, - } + _assert_get_artefact_response(response.json(), a) def test_get_artefact_environment_reviews_counts_only_latest_build( @@ -175,24 +152,6 @@ def test_artefact_signoff_approve(test_client: TestClient, generator: DataGenera assert response.status_code == 200 assert artefact.status == ArtefactStatus.APPROVED - assert response.json() == { - "id": artefact.id, - "name": artefact.name, - "version": artefact.version, - "track": artefact.track, - "store": artefact.store, - "series": artefact.series, - "repo": artefact.repo, - "stage": artefact.stage, - "status": artefact.status, - "assignee": None, - "due_date": ( - artefact.due_date.strftime("%Y-%m-%d") if artefact.due_date else None - ), - "bug_link": "", - "all_environment_reviews_count": 0, - "completed_environment_reviews_count": 0, - } def test_artefact_signoff_disallow_approve( @@ -297,3 +256,44 @@ def test_get_artefact_versions(test_client: TestClient, generator: DataGenerator response = test_client.get(f"/v1/artefacts/{artefact3.id}/versions") assert response.status_code == 200 assert response.json() == expected_result + + +def _assert_get_artefacts_response( + response_json: list[dict[str, Any]], artefacts: list[Artefact] +) -> None: + for r, a in zip(response_json, artefacts, strict=True): + _assert_get_artefact_response(r, a) + + +def _assert_get_artefact_response(response: dict[str, Any], artefact: Artefact) -> None: + expected = { + "id": artefact.id, + "name": artefact.name, + "version": artefact.version, + "track": artefact.track, + "store": artefact.store, + "series": artefact.series, + "repo": artefact.repo, + "stage": artefact.stage, + "os": artefact.os, + "release": artefact.release, + "owner": artefact.owner, + "sha256": artefact.sha256, + "image_url": artefact.image_url, + "status": artefact.status, + "assignee": None, + "due_date": ( + artefact.due_date.strftime("%Y-%m-%d") if artefact.due_date else None + ), + "bug_link": artefact.bug_link, + "all_environment_reviews_count": artefact.all_environment_reviews_count, + "completed_environment_reviews_count": artefact.completed_environment_reviews_count, # noqa: E501 + } + if artefact.assignee: + expected["assignee"] = { + "id": artefact.assignee.id, + "launchpad_email": artefact.assignee.launchpad_email, + "launchpad_handle": artefact.assignee.launchpad_handle, + "name": artefact.assignee.name, + } + assert response == expected diff --git a/backend/tests/controllers/reports/test_reports_test_executions.py b/backend/tests/controllers/reports/test_reports_test_executions.py index d738a745..635bfdf3 100644 --- a/backend/tests/controllers/reports/test_reports_test_executions.py +++ b/backend/tests/controllers/reports/test_reports_test_executions.py @@ -143,6 +143,8 @@ def _expected_report_row( artefact.track, artefact.series, artefact.repo, + artefact.os, + artefact.series, str(test_execution.id), test_execution.status.name, "" if not test_execution.ci_link else test_execution.ci_link, diff --git a/backend/tests/controllers/reports/test_reports_test_results.py b/backend/tests/controllers/reports/test_reports_test_results.py index 8f0ec913..4cfd9e04 100644 --- a/backend/tests/controllers/reports/test_reports_test_results.py +++ b/backend/tests/controllers/reports/test_reports_test_results.py @@ -84,6 +84,8 @@ def _expected_report_row(test_result: TestResult) -> list: artefact.track, artefact.series, artefact.repo, + artefact.os, + artefact.series, str(artefact.created_at), str(test_execution.id), test_execution.status.name, diff --git a/backend/tests/controllers/test_executions/test_reruns.py b/backend/tests/controllers/test_executions/test_reruns.py index 4224c84f..907e7932 100644 --- a/backend/tests/controllers/test_executions/test_reruns.py +++ b/backend/tests/controllers/test_executions/test_reruns.py @@ -3,8 +3,8 @@ from typing import Any, TypeAlias import pytest -from fastapi.testclient import TestClient from fastapi.encoders import jsonable_encoder +from fastapi.testclient import TestClient from httpx import Response from test_observer.data_access.models import TestExecution @@ -70,6 +70,11 @@ def test_execution_to_pending_rerun(test_execution: TestExecution) -> dict: "store": test_execution.artefact_build.artefact.store, "series": test_execution.artefact_build.artefact.series, "repo": test_execution.artefact_build.artefact.repo, + "os": test_execution.artefact_build.artefact.os, + "release": test_execution.artefact_build.artefact.release, + "sha256": test_execution.artefact_build.artefact.sha256, + "image_url": test_execution.artefact_build.artefact.image_url, + "owner": test_execution.artefact_build.artefact.owner, "stage": test_execution.artefact_build.artefact.stage, "status": test_execution.artefact_build.artefact.status, "assignee": test_execution.artefact_build.artefact.assignee, diff --git a/backend/tests/controllers/test_executions/test_start_test.py b/backend/tests/controllers/test_executions/test_start_test.py index 8d32d935..40651131 100644 --- a/backend/tests/controllers/test_executions/test_start_test.py +++ b/backend/tests/controllers/test_executions/test_start_test.py @@ -23,18 +23,11 @@ from httpx import Response from sqlalchemy.orm import Session -from test_observer.controllers.test_executions.models import ( - StartSnapTestExecutionRequest, -) from test_observer.data_access.models import ( Artefact, - ArtefactBuild, - ArtefactBuildEnvironmentReview, - Environment, TestExecution, ) from test_observer.data_access.models_enums import ( - FamilyName, StageName, TestExecutionStatus, ) @@ -44,6 +37,14 @@ Execute: TypeAlias = Callable[[dict[str, Any]], Response] +@pytest.fixture +def execute(test_client: TestClient) -> Execute: + def execute_helper(data: dict[str, Any]) -> Response: + return test_client.put("/v1/test-executions/start-test", json=data) + + return execute_helper + + snap_test_request = { "family": "snap", "name": "core22", @@ -71,21 +72,177 @@ "test_plan": "test plan", } +charm_test_request = { + "family": "charm", + "name": "postgresql", + "version": "abec123", + "revision": 123, + "track": "22", + "arch": "arm64", + "execution_stage": StageName.beta, + "environment": "juju 3 - microk8s 2", + "ci_link": "http://localhost", + "test_plan": "test plan", +} -@pytest.fixture -def execute(test_client: TestClient) -> Execute: - def execute_helper(data: dict[str, Any]) -> Response: - return test_client.put("/v1/test-executions/start-test", json=data) - - return execute_helper +image_test_request = { + "family": "image", + "name": "noble-desktop-amd64", + "os": "ubuntu", + "release": "noble", + "arch": "amd64", + "version": "20240827", + "sha256": "e71fb5681e63330445eec6fc3fe043f365289c2e595e3ceeac08fbeccfb9a957", + "owner": "foundations", + "image_url": "https://cdimage.ubuntu.com/noble/daily-live/20240827/noble-desktop-amd64.iso", + "execution_stage": StageName.pending, + "test_plan": "image test plan", + "environment": "xps", + "ci_link": "http://localhost", +} -def test_requires_family_field(execute: Execute): - request = snap_test_request.copy() - request.pop("family") - response = execute(request) +@pytest.mark.parametrize( + "start_request", + [snap_test_request, deb_test_request, charm_test_request, image_test_request], +) +class TestFamilyIndependentTests: + def test_starts_a_test(self, execute: Execute, start_request: dict[str, Any]): + response = execute(start_request) + self._assert_objects_created(start_request, response) + + def test_requires_family_field( + self, execute: Execute, start_request: dict[str, Any] + ): + request = start_request.copy() + request.pop("family") + response = execute(request) + + assert response.status_code == 422 + + def test_reuses_test_execution( + self, execute: Execute, start_request: dict[str, Any] + ): + response = execute(start_request) + + test_execution = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution + + response = execute(start_request) + assert response.json()["id"] == test_execution.id + + def test_reuses_environment_and_build( + self, execute: Execute, start_request: dict[str, Any] + ): + response = execute(start_request) + test_execution_1 = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution_1 + + response = execute({**start_request, "ci_link": "http://someother.link"}) + test_execution_2 = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution_2 + + assert test_execution_2.id != test_execution_1.id + assert test_execution_2.environment_id == test_execution_1.environment_id + assert test_execution_2.artefact_build_id == test_execution_1.artefact_build_id + + def test_new_artefacts_get_assigned_a_reviewer( + self, execute: Execute, generator: DataGenerator, start_request: dict[str, Any] + ): + user = generator.gen_user() + + response = execute(start_request) + + test_execution = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution + assignee = test_execution.artefact_build.artefact.assignee + assert assignee is not None + assert assignee.launchpad_handle == user.launchpad_handle + + def test_deletes_rerun_requests( + self, execute: Execute, generator: DataGenerator, start_request: dict[str, Any] + ): + response = execute(start_request) + + test_execution = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution + + generator.gen_rerun_request(test_execution) + assert test_execution.rerun_request + + execute({**start_request, "ci_link": "http://someother.link"}) + self._db_session.refresh(test_execution) + assert not test_execution.rerun_request + + def test_keeps_rerun_request_of_different_plan( + self, execute: Execute, generator: DataGenerator, start_request: dict[str, Any] + ): + response = execute(start_request) + + test_execution = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution + + generator.gen_rerun_request(test_execution) + assert test_execution.rerun_request + + execute( + { + **start_request, + "ci_link": "http://someother.link", + "test_plan": "different plan", + } + ) + self._db_session.refresh(test_execution) + assert test_execution.rerun_request + + def test_sets_initial_test_execution_status( + self, execute: Execute, start_request: dict[str, Any] + ): + response = execute({**start_request, "initial_status": "NOT_STARTED"}) + + assert response.status_code == 200 + te = self._db_session.get(TestExecution, response.json()["id"]) + assert te is not None + assert te.status == TestExecutionStatus.NOT_STARTED + + @pytest.fixture(autouse=True) + def _set_db_session(self, db_session: Session) -> None: + self._db_session = db_session + + def _assert_objects_created( + self, request: dict[str, Any], response: Response + ) -> None: + assert response.status_code == 200 + test_execution = self._db_session.get(TestExecution, response.json()["id"]) + assert test_execution + assert test_execution.ci_link == request["ci_link"] + assert test_execution.test_plan == request["test_plan"] + assert test_execution.status == request.get( + "initial_status", TestExecutionStatus.IN_PROGRESS + ) - assert response.status_code == 422 + environment = test_execution.environment + assert environment.architecture == request["arch"] + assert environment.name == request["environment"] + + artefact_build = test_execution.artefact_build + assert artefact_build.architecture == request["arch"] + assert artefact_build.revision == request.get("revision") + + artefact = artefact_build.artefact + assert artefact.name == request["name"] + assert artefact.family == request["family"] + assert artefact.stage == request["execution_stage"] + assert artefact.version == request["version"] + assert artefact.os == request.get("os", "") + assert artefact.release == request.get("release", "") + assert artefact.sha256 == request.get("sha256", "") + assert artefact.owner == request.get("owner", "") + assert artefact.image_url == request.get("image_url", "") + assert artefact.store == request.get("store", "") + assert artefact.track == request.get("track", "") + assert artefact.series == request.get("series", "") + assert artefact.repo == request.get("repo", "") @pytest.mark.parametrize( @@ -131,118 +288,48 @@ def test_deb_required_fields(execute: Execute, field: str): assert_fails_validation(response, field, "missing") -def test_creates_all_data_models(db_session: Session, execute: Execute): - response = execute(snap_test_request) - - artefact = ( - db_session.query(Artefact) - .filter( - Artefact.name == snap_test_request["name"], - Artefact.version == snap_test_request["version"], - Artefact.store == snap_test_request["store"], - Artefact.track == snap_test_request["track"], - Artefact.stage == snap_test_request["execution_stage"], - ) - .one_or_none() - ) - assert artefact - - environment = ( - db_session.query(Environment) - .filter( - Environment.name == snap_test_request["environment"], - Environment.architecture == snap_test_request["arch"], - ) - .one_or_none() - ) - assert environment - - artefact_build = ( - db_session.query(ArtefactBuild) - .filter( - ArtefactBuild.architecture == snap_test_request["arch"], - ArtefactBuild.artefact == artefact, - ArtefactBuild.revision == snap_test_request["revision"], - ) - .one_or_none() - ) - assert artefact_build - - environment_review = ( - db_session.query(ArtefactBuildEnvironmentReview) - .filter( - ArtefactBuildEnvironmentReview.artefact_build_id == artefact_build.id, - ArtefactBuildEnvironmentReview.environment_id == environment.id, - ) - .one_or_none() - ) - assert environment_review - - test_execution = ( - db_session.query(TestExecution) - .filter( - TestExecution.artefact_build == artefact_build, - TestExecution.environment == environment, - TestExecution.status == TestExecutionStatus.IN_PROGRESS, - TestExecution.test_plan == snap_test_request["test_plan"], - ) - .one_or_none() - ) - assert test_execution - assert response.json() == {"id": test_execution.id} - - -def test_uses_existing_models( - db_session: Session, - execute: Execute, - generator: DataGenerator, -): - artefact = generator.gen_artefact(StageName.beta) - environment = generator.gen_environment() - artefact_build = generator.gen_artefact_build(artefact, revision=1) - - request = StartSnapTestExecutionRequest( - family=FamilyName.snap, - name=artefact.name, - version=artefact.version, - revision=1, - track=artefact.track, - store=artefact.store, - arch=artefact_build.architecture, - execution_stage=artefact.stage, - environment=environment.name, - ci_link="http://localhost/", - test_plan="test plan", - ) - - test_execution_id = execute( - request.model_dump(mode="json"), - ).json()["id"] - - test_execution = ( - db_session.query(TestExecution) - .where(TestExecution.id == test_execution_id) - .one() - ) - - assert test_execution.artefact_build_id == artefact_build.id - assert test_execution.environment_id == environment.id - assert test_execution.status == TestExecutionStatus.IN_PROGRESS - assert test_execution.ci_link == "http://localhost/" - assert test_execution.c3_link is None - assert test_execution.test_plan == "test plan" +@pytest.mark.parametrize( + "field", + [ + "name", + "version", + "track", + "revision", + "arch", + "execution_stage", + "environment", + "test_plan", + ], +) +def test_charm_required_fields(execute: Execute, field: str): + request = charm_test_request.copy() + request.pop(field) + response = execute(request) + assert_fails_validation(response, field, "missing") -def test_new_artefacts_get_assigned_a_reviewer( - db_session: Session, execute: Execute, generator: DataGenerator -): - user = generator.gen_user() - execute(snap_test_request) +@pytest.mark.parametrize( + "field", + [ + "name", + "version", + "os", + "release", + "arch", + "sha256", + "owner", + "image_url", + "execution_stage", + "environment", + ], +) +def test_image_required_fields(execute: Execute, field: str): + request = image_test_request.copy() + request.pop(field) + response = execute(request) - artefact = db_session.query(Artefact).filter(Artefact.name == "core22").one() - assert artefact.assignee is not None - assert artefact.assignee.launchpad_handle == user.launchpad_handle + assert_fails_validation(response, field, "missing") def test_non_kernel_artefact_due_date(db_session: Session, execute: Execute): @@ -290,107 +377,43 @@ def test_kernel_artefact_due_date(db_session: Session, execute: Execute): assert artefact.due_date is None -def test_deletes_rerun_requests( - execute: Execute, generator: DataGenerator, db_session: Session -): - a = generator.gen_artefact(StageName.beta) - ab = generator.gen_artefact_build(a) - e = generator.gen_environment() - te1 = generator.gen_test_execution(ab, e, ci_link="ci1.link") - te2 = generator.gen_test_execution(ab, e, ci_link="ci2.link") - generator.gen_rerun_request(te1) - generator.gen_rerun_request(te2) - - execute( - { - "family": a.family, - "name": a.name, - "version": a.version, - "revision": ab.revision, - "track": a.track, - "store": a.store, - "arch": ab.architecture, - "execution_stage": a.stage, - "environment": e.name, - "ci_link": "different-ci.link", - "test_plan": te1.test_plan, - }, - ) - - db_session.refresh(te1) - db_session.refresh(te2) - assert not te1.rerun_request - assert not te2.rerun_request - - -def test_keeps_rerun_request_of_different_plan( - execute: Execute, generator: DataGenerator, db_session: Session -): - a = generator.gen_artefact(StageName.beta) - ab = generator.gen_artefact_build(a) - e = generator.gen_environment() - te = generator.gen_test_execution(ab, e, ci_link="ci1.link", test_plan="plan1") - generator.gen_rerun_request(te) - - execute( - { - "family": a.family, - "name": a.name, - "version": a.version, - "revision": ab.revision, - "track": a.track, - "store": a.store, - "arch": ab.architecture, - "execution_stage": a.stage, - "environment": e.name, - "ci_link": "different-ci.link", - "test_plan": "plan2", - }, - ) - - db_session.refresh(te) - assert te.rerun_request - - -def test_sets_initial_test_execution_status(db_session: Session, execute: Execute): - response = execute({**deb_test_request, "initial_status": "NOT_STARTED"}) - - assert response.status_code == 200 - te = db_session.get(TestExecution, response.json()["id"]) - assert te is not None - assert te.status == TestExecutionStatus.NOT_STARTED - - -def test_allows_null_ci_link(db_session: Session, execute: Execute): - request = {**deb_test_request, "ci_link": None} - - response = execute(request) - - assert response.status_code == 200 - te = db_session.get(TestExecution, response.json()["id"]) - assert te is not None - assert te.ci_link is None +@pytest.mark.parametrize( + "invalid_stage", + set(StageName) - {StageName.proposed, StageName.updates}, +) +def test_validates_stage_for_debs(execute: Execute, invalid_stage: StageName): + response = execute({**deb_test_request, "execution_stage": invalid_stage}) + assert response.status_code == 422 -def test_allows_omitting_ci_link(db_session: Session, execute: Execute): - request = {**deb_test_request} - del request["ci_link"] - response = execute(request) - - assert response.status_code == 200 - te = db_session.get(TestExecution, response.json()["id"]) - assert te is not None - assert te.ci_link is None +@pytest.mark.parametrize( + "invalid_stage", + set(StageName) + - { + StageName.edge, + StageName.beta, + StageName.candidate, + StageName.stable, + }, +) +def test_validates_stage_for_snaps(execute: Execute, invalid_stage: StageName): + response = execute({**snap_test_request, "execution_stage": invalid_stage}) + assert response.status_code == 422 -def test_create_two_executions_for_null_ci_link(execute: Execute): - request = {**deb_test_request} - del request["ci_link"] - response_1 = execute(request) - response_2 = execute(request) +@pytest.mark.parametrize( + "invalid_stage", + set(StageName) + - { + StageName.edge, + StageName.beta, + StageName.candidate, + StageName.stable, + }, +) +def test_validates_stage_for_charms(execute: Execute, invalid_stage: StageName): + response = execute({**charm_test_request, "execution_stage": invalid_stage}) - assert response_1.status_code == 200 - assert response_2.status_code == 200 - assert response_1.json()["id"] != response_2.json()["id"] + assert response.status_code == 422 diff --git a/backend/tests/data_access/test_repository.py b/backend/tests/data_access/test_repository.py index 4e969dfe..1248fff5 100644 --- a/backend/tests/data_access/test_repository.py +++ b/backend/tests/data_access/test_repository.py @@ -29,28 +29,6 @@ from tests.data_generator import DataGenerator -def test_get_artefacts_by_family(db_session: Session, generator: DataGenerator): - """We should get a valid list of all artefacts""" - # Arrange - artefact_name_stage_pair = { - ("core20", StageName.edge), - ("core22", StageName.beta), - ("docker", StageName.candidate), - } - - for name, stage in artefact_name_stage_pair: - generator.gen_artefact(stage, name=name) - - # Act - artefacts = get_artefacts_by_family(db_session, FamilyName.snap, latest_only=False) - - # Assert - assert len(artefacts) == len(artefact_name_stage_pair) - assert { - (artefact.name, artefact.stage) for artefact in artefacts - } == artefact_name_stage_pair - - def test_get_artefacts_by_family_latest(db_session: Session, generator: DataGenerator): """We should get a only latest artefacts in each stage for the specified family""" # Arrange diff --git a/backend/tests/data_generator.py b/backend/tests/data_generator.py index 5ca6b287..341d1f1b 100644 --- a/backend/tests/data_generator.py +++ b/backend/tests/data_generator.py @@ -92,6 +92,42 @@ def gen_artefact( self._add_object(artefact) return artefact + def gen_image( + self, + stage: StageName = StageName.pending, + name: str = "noble-desktop-amd64", + version: str = "20240827", + os: str = "ubuntu", + release: str = "noble", + sha256: str = "e71fb5681e63330445eec6fc3fe043f36" + "5289c2e595e3ceeac08fbeccfb9a957", + owner: str = "foundations", + image_url: str = "https://cdimage.ubuntu.com/noble/daily-live/20240827/noble-desktop-amd64.iso", + created_at: datetime | None = None, + status: ArtefactStatus = ArtefactStatus.UNDECIDED, + bug_link: str = "", + due_date: date | None = None, + assignee_id: int | None = None, + ): + image = Artefact( + name=name, + stage=stage, + family=FamilyName.image, + version=version, + os=os, + release=release, + sha256=sha256, + owner=owner, + image_url=image_url, + created_at=created_at, + status=status, + bug_link=bug_link, + due_date=due_date, + assignee_id=assignee_id, + ) + self._add_object(image) + return image + def gen_artefact_build( self, artefact: Artefact,