From cfd79e55432bd16438ebb399adf1af943b526482 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 12:52:43 -0700 Subject: [PATCH 01/14] refactor: share pydantic validations for sample add and update schemas This enables the same validations to be used for both adding and updating samples, ensuring consistency and reducing code duplication. --- schemas_v2/sample.py | 73 ++++++++++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index 0634ab68f..7bd6990e9 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -16,12 +16,46 @@ from datetime import datetime, timezone from pydantic import BaseModel, field_validator -from db.engine import get_db_session, session_ctx +from db.engine import session_ctx from db import Thing +# -------- VALIDATE ---------- + + +class SampleValidator(BaseModel): + """ + Validator for Sample data. + """ + + @field_validator("thing_id", check_fields=False) + def validate_thing_id_exists(cls, thing_id: int) -> int: + """ + Validate that the thing_id exists in the database. + """ + if thing_id: + with session_ctx() as session: + thing = session.get(Thing, thing_id) + if not thing: + raise ValueError(f"Thing with ID {thing_id} does not exist.") + return thing_id + + @field_validator("sample_date", check_fields=False) + def validate_sample_date(cls, sample_date: datetime) -> datetime: + """ + Validate that the sample_date is not in the future. + """ + if sample_date: + if sample_date: + if sample_date > datetime.now(tz=timezone.utc): + raise ValueError( + f"Sample date {sample_date} cannot be in the future." + ) + return sample_date + + # -------- CREATE ---------- -class CreateSample(BaseModel): +class CreateSample(SampleValidator): thing_id: int sample_type: str field_sample_id: str @@ -56,40 +90,19 @@ class CreateGeothermalSample(BaseModel): sample_id: int -# -------- RESPONSE ---------- -class SampleResponse(BaseModel): - id: int - sample_date: datetime - sample_method: str - thing_id: int - - # -------- UPDATE ---------- -class UpdateSample(BaseModel): +class UpdateSample(SampleValidator): sample_date: datetime | None = None sample_method: str | None = None thing_id: int | None = None - @field_validator("thing_id") - def validate_thing_id_exists(cls, thing_id: int) -> int: - """ - Validate that the thing_id exists in the database. - """ - with session_ctx() as session: - thing = session.get(Thing, thing_id) - if not thing: - raise ValueError(f"Thing with ID {thing_id} does not exist.") - return thing_id - @field_validator("sample_date") - def validate_sample_date(cls, sample_date: datetime) -> datetime: - """ - Validate that the sample_date is not in the future. - """ - if sample_date: - if sample_date > datetime.now(tz=timezone.utc): - raise ValueError(f"Sample date {sample_date} cannot be in the future.") - return sample_date +# -------- RESPONSE ---------- +class SampleResponse(BaseModel): + id: int + sample_date: datetime + sample_method: str + thing_id: int # ============= EOF ============================================= From 59ed0e48a9eed0cd06b7fefd722fca489671b321 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 12:56:04 -0700 Subject: [PATCH 02/14] doc: note field validation refactor todos --- schemas_v2/contact.py | 7 +++++++ schemas_v2/location.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/schemas_v2/contact.py b/schemas_v2/contact.py index 08bf6e33b..95eb4ad80 100644 --- a/schemas_v2/contact.py +++ b/schemas_v2/contact.py @@ -24,6 +24,13 @@ from schemas import ORMBaseModel from schemas_v2.thing import ThingResponse +""" +REFACTOR TODO + +Create common validator classes to be shared amongst create and update schemas. +Since many fields are optional in the update schemas set check_fields=False in the field_validator. +""" + # -------- CREATE ---------- class CreateEmail(BaseModel): diff --git a/schemas_v2/location.py b/schemas_v2/location.py index afee10a92..af48e8f9d 100644 --- a/schemas_v2/location.py +++ b/schemas_v2/location.py @@ -20,6 +20,13 @@ from schemas import ORMBaseModel +""" +REFACTOR TODO + +Create common validator classes to be shared amongst create and update schemas. +Since many fields are optional in the update schemas set check_fields=False in the field_validator. +""" + # -------- CREATE ---------- class CreateLocation(BaseModel): From c3710fb9ceba70483eba18fa51448bac87929140 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 13:02:25 -0700 Subject: [PATCH 03/14] docs: note area where enums can be used to constrict values --- services/thing_helper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/thing_helper.py b/services/thing_helper.py index ad871d73c..ee4f6d968 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -100,6 +100,7 @@ def transformer(records): return paginate(query=sql, conn=session, transformer=transformer) +# REFACTOR TODO: use enums (or enum-like object) for thing_type def add_thing(session: Session, data: BaseModel | dict, thing_type: str = None) -> Base: if isinstance(data, BaseModel): From b51bae3d0f053b6abc69e8e4e893cf6b28239021 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 18:20:22 -0600 Subject: [PATCH 04/14] fix: update sample model with non-nullable constraints and notes --- db/sample.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/db/sample.py b/db/sample.py index e47084568..6a0098f33 100644 --- a/db/sample.py +++ b/db/sample.py @@ -13,17 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import DateTime, String, ForeignKey, Integer, UniqueConstraint, Float -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import mapped_column, relationship, Mapped, declared_attr +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, Float +from sqlalchemy.orm import mapped_column, relationship, Mapped # import models from classes that are defined in separate files -from db import lexicon_term from db.base import Base, AutoBaseMixin, ReleaseMixin from db.thing import Thing from db.sensor import Sensor -from typing import List, Optional +from typing import Optional import datetime @@ -45,28 +43,32 @@ class Sample(Base, AutoBaseMixin, ReleaseMixin): ) sensor_id: Mapped[Optional[int]] = mapped_column( ForeignKey("sensor.id"), - unique=True, comment="Foreign key for the specific equipment used.", ) # Sample Attributes sample_date: Mapped[datetime.datetime] = mapped_column( - DateTime, comment="Date and time of sample collection." + DateTime, nullable=False, comment="Date and time of sample collection." ) + # REFACTOR TODO: update with enum/restricted values sample_matrix: Mapped[Optional[str]] = mapped_column( comment="The material of the sample (e.g., 'gw', 'soil')." ) + # REFACTOR TODO: update with enum/restricted values sample_method: Mapped[Optional[str]] = mapped_column( comment="Method used to collect the sample." ) field_sample_id: Mapped[str] = mapped_column( - unique=True, comment="User-defined ID for field tracking." + unique=True, nullable=False, comment="User-defined ID for field tracking." ) + # REFACTOR TODO: update with enum/restricted values sampler_name: Mapped[Optional[str]] = mapped_column( - comment="Name of the person who collected the sample." + nullable=False, comment="Name of the person who collected the sample." ) + # REFACTOR TODO: update with enum/restricted values qc_sample: Mapped[str] = mapped_column( default="Original", + nullable=False, comment="Quality control sample type (e.g., 'Original', 'field dupe').", ) sample_top: Mapped[Optional[float]] = mapped_column( From bd8cbe04b6e0ea524585080b8e2102f855b24597 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 18:21:51 -0600 Subject: [PATCH 05/14] feat: add new schema model fields and inherit field validators POST and PATCH requests require the same field validators, so we inherit the validators from a base model class for both request types. --- schemas_v2/sample.py | 165 +++++++++++++++++++++++++++++++++---------- 1 file changed, 126 insertions(+), 39 deletions(-) diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index 7bd6990e9..0f9c54a03 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -17,23 +17,57 @@ from pydantic import BaseModel, field_validator from db.engine import session_ctx -from db import Thing +from db import Thing, Sensor, Sample -# -------- VALIDATE ---------- +""" +REFACTOR TODO: can we use inheritance for commonly defined fields and then set them as optional +or not between Create, Update, and Response schemas? +""" -class SampleValidator(BaseModel): +# -------- VALIDATE ---------- +class ValidateSample(BaseModel): """ - Validator for Sample data. + Validator for Sample data for Create and Update schemas. """ + @field_validator("field_sample_id", check_fields=False) + def validate_field_sample_id(cls, field_sample_id: str) -> str: + """ + Validate that the field_sample_id is unique. + """ + if field_sample_id is not None: + with session_ctx() as session: + existing_sample = ( + session.query(Sample) + .filter_by(field_sample_id=field_sample_id) + .first() + ) + if existing_sample: + raise ValueError( + f"Field sample ID {field_sample_id} already exists." + ) + return field_sample_id + + @field_validator("sensor_id", check_fields=False) + def validate_sensor_id(cls, sensor_id: int | None) -> int | None: + """ + Validate that the sensor_idexists in the database. + """ + if sensor_id is not None: + with session_ctx() as session: + sensor = session.get(Sensor, sensor_id) + if not sensor: + raise ValueError(f"Sensor with ID {sensor_id} does not exist.") + return sensor_id + @field_validator("thing_id", check_fields=False) - def validate_thing_id_exists(cls, thing_id: int) -> int: + def validate_thing_id_exists(cls, thing_id: int | None) -> int | None: """ Validate that the thing_id exists in the database. """ - if thing_id: + if thing_id is not None: with session_ctx() as session: thing = session.get(Thing, thing_id) if not thing: @@ -41,68 +75,121 @@ def validate_thing_id_exists(cls, thing_id: int) -> int: return thing_id @field_validator("sample_date", check_fields=False) - def validate_sample_date(cls, sample_date: datetime) -> datetime: + def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: """ Validate that the sample_date is not in the future. """ - if sample_date: - if sample_date: - if sample_date > datetime.now(tz=timezone.utc): - raise ValueError( - f"Sample date {sample_date} cannot be in the future." - ) + if sample_date is not None: + if sample_date > datetime.now(tz=timezone.utc): + raise ValueError(f"Sample date {sample_date} cannot be in the future.") return sample_date + # # REFACTOR TODO: is below ground negative or positive? the combine this with validate_sample_bottom defined below + # @field_validator("sample_bottom", check_fields=False) + # def validate_sample_bottom(cls, sample_bottom: float | None, values) -> float | None: + # """ + # Validate that the sample_bottom is not less than sample_top. + # """ + # sample_top = values.get('sample_top') + # if sample_bottom is not None and sample_top is not None: + # if sample_bottom > sample_top: + # raise ValueError( + # "Sample bottom cannot be greater than sample top." + # ) + # return sample_bottom + + @field_validator("sample_bottom", check_fields=False) + def validate_sample_bottom( + cls, sample_bottom: float | None, values: dict + ) -> float | None: + """ + Validate that the sample_bottom is definfed if sample_top is defined + """ + sample_top = values.get("sample_top") + if sample_bottom is not None and sample_top is None: + raise ValueError("Sample bottom must be defined if sample top is defined.") + elif sample_bottom is None and sample_top is not None: + raise ValueError("Sample bottom must be defined if sample top is defined.") + return sample_bottom + # -------- CREATE ---------- -class CreateSample(SampleValidator): +class CreateSample(ValidateSample): thing_id: int sample_type: str field_sample_id: str + sample_date: datetime + release_status: str + sampler_name: str # REFACTOR TODO: update with enum/restricted values + qc_sample: str = "Original" sensor_id: int | None = None - sample_date: datetime | None = None - sample_matrix: str | None = None - sample_method: str | None = None - sampler_name: str | None = None - qc_sample: str | None = None - duplicate_sample_number: int | None = None + sample_matrix: str | None = ( + None # REFACTOR TODO: update with enum/restricted values + ) + sample_method: str | None = ( + None # REFACTOR TODO: update with enum/restricted values + ) + duplicate_sample_number: int | None = 0 + + # REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above? + # for example: wells below, rain above, and soil/rock could be at ground surface sample_top: float | None = None sample_bottom: float | None = None - release_status: str - -class CreateGeochemicalSample(BaseModel): - """ - Represents a geochemical sample in the collaborative network. +# -------- UPDATE ---------- +class UpdateSample(ValidateSample): """ + Development notes: - sample_id: int - - -class CreateGeothermalSample(BaseModel): - """ - Represents a geothermal sample in the collaborative network. + setting = None makes the field optional, but if it is defined it must be of that type. """ - sample_id: int + thing_id: int = None # REFACTOR TODO: should users be able to change this? + sample_type: str = None + field_sample_id: str = None + sample_date: datetime = None + release_status: str = None + sampler_name: str = None # REFACTOR TODO: update with enum/restricted values + qc_sample: str = None + + sensor_id: int | None = None # REFACTOR TODO: should users be able to change this? + sample_matrix: str | None = ( + None # REFACTOR TODO: update with enum/restricted values + ) + sample_method: str | None = ( + None # REFACTOR TODO: update with enum/restricted values + ) + duplicate_sample_number: int | None = None -# -------- UPDATE ---------- -class UpdateSample(SampleValidator): - sample_date: datetime | None = None - sample_method: str | None = None - thing_id: int | None = None + # REFACTOR TODO: update with numeric restrictions? Are negative values below ground and positive above? + # for example: wells below, rain above, and soil/rock could be at ground surface + sample_top: float | None = None + sample_bottom: float | None = None # -------- RESPONSE ---------- class SampleResponse(BaseModel): id: int - sample_date: datetime - sample_method: str thing_id: int + sample_type: str + field_sample_id: str + sample_date: datetime + release_status: str + sampler_name: str + qc_sample: str + + sensor_id: int | None + sample_matrix: str | None + sample_method: str | None + + duplicate_sample_number: int | None + + sample_top: float | None + sample_bottom: float | None # ============= EOF ============================================= From 819c515825ee7bf2e0f95d31536abad3b6ff381f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 18:49:26 -0600 Subject: [PATCH 06/14] fix: validate sample top and bottom separately --- schemas_v2/sample.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index 0f9c54a03..7994f1143 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -98,18 +98,30 @@ def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: # ) # return sample_bottom + @field_validator("sample_top", check_fields=False) + def validate_sample_top(cls, sample_top: float | None, values) -> float | None: + """ + Validate that the sample_top is not less than sample_bottom. + """ + sample_bottom = values.data.get("sample_bottom") + if sample_bottom is None and sample_top is not None: + raise ValueError("Sample bottom must be defined if sample top is defined.") + elif sample_bottom is not None and sample_top is None: + raise ValueError("Sample top must be defined if sample bottom is defined.") + return sample_top + @field_validator("sample_bottom", check_fields=False) def validate_sample_bottom( - cls, sample_bottom: float | None, values: dict + cls, sample_bottom: float | None, values ) -> float | None: """ - Validate that the sample_bottom is definfed if sample_top is defined + Validate that the sample_bottom is defined if sample_top is defined and vice versa """ - sample_top = values.get("sample_top") - if sample_bottom is not None and sample_top is None: - raise ValueError("Sample bottom must be defined if sample top is defined.") - elif sample_bottom is None and sample_top is not None: + sample_top = values.data.get("sample_top") + if sample_bottom is None and sample_top is not None: raise ValueError("Sample bottom must be defined if sample top is defined.") + elif sample_bottom is not None and sample_top is None: + raise ValueError("Sample top must be defined if sample bottom is defined.") return sample_bottom From ab50181bd2b2399d0c8c0e65e21374bd7439c4c1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 19:00:00 -0600 Subject: [PATCH 07/14] docs: note about sampel top and bottom validations --- schemas_v2/sample.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index 7994f1143..e25c747ab 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -98,6 +98,9 @@ def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: # ) # return sample_bottom + # REFACTOR TODO: fields are evaluated in the order in which they are defined. + # are sample top/bottom really working as expected? + @field_validator("sample_top", check_fields=False) def validate_sample_top(cls, sample_top: float | None, values) -> float | None: """ From 267bfcb16972ab2879bbc659c39d5fa17a98a853 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 4 Aug 2025 19:01:30 -0600 Subject: [PATCH 08/14] feat: add and update tests for updated sample model and schemas --- tests/conftest.py | 30 ++-- tests/test_sample.py | 409 +++++++++++++++++++++++++++++++++---------- 2 files changed, 330 insertions(+), 109 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index af7926a27..7957ee379 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,29 +36,35 @@ def thing(location): @pytest.fixture(scope="session") -def sample(thing): +def sensor(): + with session_ctx() as session: + sensor = Sensor(name=f"Test Sensor {uuid.uuid4()}") + session.add(sensor) + session.commit() + yield sensor + session.close() + + +@pytest.fixture(scope="session") +def sample(thing, sensor): with session_ctx() as session: sample = Sample( sample_date="2025-01-01T00:00:00", - sample_method="manual", thing_id=thing.id, sample_type="groundwater", sampler_name="Test Sampler", release_status="draft", field_sample_id=f"FS-{uuid.uuid4()}", + qc_sample="Original", + sensor_id=sensor.id, + sample_matrix="water", + sample_method="manual", + duplicate_sample_number=0, + sample_top=None, + sample_bottom=None, ) session.add(sample) session.commit() yield sample session.close() - - -@pytest.fixture(scope="session") -def sensor(): - with session_ctx() as session: - sensor = Sensor(name=f"Test Sensor {uuid.uuid4()}") - session.add(sensor) - session.commit() - yield sensor - session.close() diff --git a/tests/test_sample.py b/tests/test_sample.py index 4e2517dc5..f6bf17629 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import pytest from datetime import datetime +from copy import deepcopy from db.engine import session_ctx from db.sample import Sample @@ -36,85 +36,64 @@ def test_add_sample(thing): "sample_type": "groundwater", "sampler": "Test Sampler A", "field_sample_id": "FS-12345", + "sampler_name": "Test Add Sampler", }, ) data = response.json() - assert response.status_code == 201 - assert data["id"] is not None - assert data["thing_id"] == thing.id + assert data["id"] == data["id"] + assert data["thing_id"] == data["thing_id"] + assert data["sample_type"] == data["sample_type"] + assert data["field_sample_id"] == data["field_sample_id"] + assert data["sample_date"] == data["sample_date"] + assert data["release_status"] == data["release_status"] + assert data["sampler_name"] == data["sampler_name"] + assert data["qc_sample"] == data["qc_sample"] + assert data["sensor_id"] == data["sensor_id"] + assert data["sample_matrix"] == data["sample_matrix"] + assert data["sample_method"] == data["sample_method"] + assert data["duplicate_sample_number"] == data["duplicate_sample_number"] + assert data["sample_top"] == data["sample_top"] + assert data["sample_bottom"] == data["sample_bottom"] # cleanup after adding the sample - sample_id = data["id"] with session_ctx() as session: - session.query(Sample).where(Sample.id == sample_id).delete() + session.query(Sample).where(Sample.id == data["id"]).delete() session.commit() -@pytest.mark.skip(reason="Geochemical sample endpoint not implemented yet") -def test_add_geochemical_sample(): - """ - Test adding a geochemical sample to the collaborative network. - """ - response = client.post( - "/sample/geochemical", - json={ - "sample_id": 1, - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["id"] is not None - assert data["sample_id"] == 1 - - -@pytest.mark.skip(reason="Geothermal sample endpoint not implemented yet") -def test_add_geothermal_sample(): - """ - Test adding a geothermal sample to the collaborative network. - """ - response = client.post( - "/sample/geothermal", - json={ - "sample_id": 1, - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["id"] is not None - assert data["sample_id"] == 1 - - # ============= Patch tests for samples ============================================= def test_patch_sample(sample): """ Test updating a sample in the collaborative network. """ - original_method_patch = sample.sample_method - original_timestamp_patch = sample.sample_date + original_sample = deepcopy(sample) + sampler_name = "Test Sampler B" sample_method_patch = "continuous" sample_date_patch = "2025-01-02T00:00:00+00:00" response = client.patch( f"/sample/{sample.id}", json={ + "sampler_name": sampler_name, "sample_method": sample_method_patch, "sample_date": sample_date_patch, }, ) assert response.status_code == 200 data = response.json() - assert data == { - "id": sample.id, - "sample_date": sample_date_patch.split("+")[0], - "sample_method": sample_method_patch, - "thing_id": sample.thing_id, - } + + assert data["id"] == sample.id + assert data["sampler_name"] == sampler_name + assert data["sample_date"] == sample_date_patch.split("+")[0] + assert data["sample_method"] == sample_method_patch + assert data["thing_id"] == sample.thing_id # cleanup after patching the sample with session_ctx() as session: updated_sample = session.query(Sample).filter(Sample.id == sample.id).one() - updated_sample.sample_method = original_method_patch - updated_sample.sample_date = original_timestamp_patch + updated_sample.sample_method = original_sample.sample_method + updated_sample.sample_date = original_sample.sample_date + updated_sample.sampler_name = original_sample.sampler_name session.commit() @@ -147,15 +126,14 @@ def test_patch_sample_422_thing_id_not_found(sample): ) assert response.status_code == 422 data = response.json() - assert data["detail"] == [ - { - "type": "value_error", - "loc": ["body", "thing_id"], - "msg": f"Value error, Thing with ID {bad_thing_id} does not exist.", - "input": bad_thing_id, - "ctx": {"error": {}}, - } - ] + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + error = data["detail"][0] + assert error["type"] == "value_error" + assert error["loc"] == ["body", "thing_id"] + assert error["msg"] == f"Value error, Thing with ID {bad_thing_id} does not exist." + assert error["input"] == bad_thing_id + assert error["ctx"] == {"error": {}} def test_patch_sample_422_invalid_timestamp(sample): @@ -178,48 +156,277 @@ def test_patch_sample_422_invalid_timestamp(sample): assert len(detail) == 1 assert detail[0]["type"] == "value_error" assert detail[0]["loc"] == ["body", "sample_date"] + assert ( + detail[0]["msg"] + == f"Value error, Sample date {bad_sample_date_dt} cannot be in the future." + ) + assert detail[0]["input"] == bad_sample_date + assert detail[0]["ctx"] == {"error": {}} -# ============= Get tests for samples ============================================= -def test_get_samples(sample): - """ - Test retrieving samples from the collaborative network. - """ - response = client.get("/sample") - assert response.status_code == 200 - data = response.json() - assert data["items"] == [ - { - "id": sample.id, - "sample_date": sample.sample_date, - "sample_method": sample.sample_method, - "thing_id": sample.thing_id, - } - ] +# ============== Custom validations for samples ============================================= +""" +Development notes: -@pytest.mark.skip(reason="Geochemical samples endpoint not implemented yet") -def test_get_geochemical_samples(): - """ - Test retrieving geochemical samples from the collaborative network. - """ - response = client.get("/sample/geochemical") - assert response.status_code == 200 - data = response.json() - assert "items" in data - assert len(data["items"]) > 0 +Both POST and PATCH endpoints use the same custom validators, which is why +they are being tested together here. For true unit testing, though, these can be separated +into different POST and PATCH validation tests. +""" + + +def test_validate_thing_id(sample): + bad_thing_id = 999 + + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": bad_thing_id, + "sample_date": "2025-01-01T00:00:00Z", + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": "FS-12345", + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "thing_id": bad_thing_id, + }, + ) + + data = response.json() + assert response.status_code == 422 + + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "thing_id"] + assert ( + detail["msg"] + == f"Value error, Thing with ID {bad_thing_id} does not exist." + ) + assert detail["input"] == bad_thing_id + assert detail["ctx"] == {"error": {}} + + +def test_validate_sample_date(sample): + bad_sample_date = "3500-01-01T00:00:00Z" + bad_sample_date_dt = datetime.fromisoformat(bad_sample_date.replace("Z", "+00:00")) + + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": sample.thing_id, + "sample_date": bad_sample_date, + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": "FS-12345", + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "sample_date": bad_sample_date, + }, + ) + data = response.json() + assert response.status_code == 422 + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "sample_date"] + assert ( + detail["msg"] + == f"Value error, Sample date {bad_sample_date_dt} cannot be in the future." + ) + assert detail["input"] == bad_sample_date + assert detail["ctx"] == {"error": {}} -@pytest.mark.skip(reason="Geothermal samples endpoint not implemented yet") -def test_get_geothermal_samples(): + +def test_validate_field_sample_id(sample): + bad_field_sample_id = sample.field_sample_id + + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": sample.thing_id, + "sample_date": "2025-01-01T00:00:00Z", + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": bad_field_sample_id, + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "field_sample_id": bad_field_sample_id, + }, + ) + assert response.status_code == 422 + data = response.json() + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "field_sample_id"] + assert ( + detail["msg"] + == f"Value error, Field sample ID {bad_field_sample_id} already exists." + ) + + +def test_validate_sensor_id(sample): + sensor_id = 999999999 + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": sample.thing_id, + "sample_date": "2025-01-01T00:00:00Z", + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": "FS-12345", + "sensor_id": sensor_id, + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "sensor_id": sensor_id, + }, + ) + assert response.status_code == 422 + data = response.json() + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "sensor_id"] + assert ( + detail["msg"] == f"Value error, Sensor with ID {sensor_id} does not exist." + ) + + +def test_validate_sample_bottom(sample): + sample_bottom = 10.0 + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": sample.thing_id, + "sample_date": "2025-01-01T00:00:00Z", + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": "FS-12345", + "sample_bottom": sample_bottom, + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "sample_bottom": sample_bottom, + }, + ) + data = response.json() + assert response.status_code == 422 + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "sample_bottom"] + assert ( + detail["msg"] + == "Value error, Sample top must be defined if sample bottom is defined." + ) + + +def test_validate_sample_top(sample): + sample_top = 10.0 + for method in ["post", "patch"]: + if method == "post": + response = client.post( + "/sample", + json={ + "thing_id": sample.thing_id, + "sample_date": "2025-01-01T00:00:00Z", + "sample_method": "manual", + "release_status": "draft", + "sample_type": "groundwater", + "sampler_name": "Test Sampler", + "field_sample_id": "FS-12345", + "sample_top": sample_top, + }, + ) + else: + response = client.patch( + f"/sample/{sample.id}", + json={ + "sample_top": sample_top, + }, + ) + data = response.json() + assert response.status_code == 422 + assert isinstance(data["detail"], list) + assert len(data["detail"]) == 1 + detail = data["detail"][0] + assert detail["type"] == "value_error" + assert detail["loc"] == ["body", "sample_top"] + assert ( + detail["msg"] + == "Value error, Sample bottom must be defined if sample top is defined." + ) + + +# ============= Get tests for samples ============================================= +def test_get_samples(sample): """ - Test retrieving geothermal samples from the collaborative network. + Test retrieving samples from the collaborative network. """ - response = client.get("/sample/geothermal") + response = client.get("/sample") assert response.status_code == 200 data = response.json() - assert "items" in data - assert len(data["items"]) > 0 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == sample.id + assert data["items"][0]["thing_id"] == sample.thing_id + assert data["items"][0]["sample_type"] == sample.sample_type + assert data["items"][0]["field_sample_id"] == sample.field_sample_id + assert data["items"][0]["sample_date"] == sample.sample_date + assert data["items"][0]["release_status"] == sample.release_status + assert data["items"][0]["sampler_name"] == sample.sampler_name + assert data["items"][0]["qc_sample"] == sample.qc_sample + assert data["items"][0]["sensor_id"] == sample.sensor_id + assert data["items"][0]["sample_matrix"] == sample.sample_matrix + assert data["items"][0]["sample_method"] == sample.sample_method + assert data["items"][0]["duplicate_sample_number"] == sample.duplicate_sample_number + assert data["items"][0]["sample_top"] == sample.sample_top + assert data["items"][0]["sample_bottom"] == sample.sample_bottom def test_get_sample_by_id(sample): @@ -229,12 +436,20 @@ def test_get_sample_by_id(sample): response = client.get(f"/sample/{sample.id}") assert response.status_code == 200 data = response.json() - assert data == { - "id": sample.id, - "sample_date": sample.sample_date, - "sample_method": sample.sample_method, - "thing_id": sample.thing_id, - } + assert data["id"] == sample.id + assert data["thing_id"] == sample.thing_id + assert data["sample_type"] == sample.sample_type + assert data["field_sample_id"] == sample.field_sample_id + assert data["sample_date"] == sample.sample_date + assert data["release_status"] == sample.release_status + assert data["sampler_name"] == sample.sampler_name + assert data["qc_sample"] == sample.qc_sample + assert data["sensor_id"] == sample.sensor_id + assert data["sample_matrix"] == sample.sample_matrix + assert data["sample_method"] == sample.sample_method + assert data["duplicate_sample_number"] == sample.duplicate_sample_number + assert data["sample_top"] == sample.sample_top + assert data["sample_bottom"] == sample.sample_bottom def test_get_sample_by_id_404_not_found(sample): From c381a4d3341932c1c44b2a476c32982b820830b2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 10:58:14 -0600 Subject: [PATCH 09/14] fix: repair artifacts from merge conflict resolutions --- tests/test_sample.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sample.py b/tests/test_sample.py index a9cd7eb7f..31a3cb81b 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -84,7 +84,7 @@ def test_patch_sample(sample): assert data["id"] == sample.id assert data["sampler_name"] == sampler_name - assert data["sample_date"] == sample_date_patch.split("+")[0] + assert data["sample_date"] == sample_date_patch[:-1] assert data["sample_method"] == sample_method_patch assert data["thing_id"] == sample.thing_id @@ -157,7 +157,7 @@ def test_patch_sample_422_invalid_timestamp(sample): assert detail[0]["loc"] == ["body", "sample_date"] assert ( detail[0]["msg"] - == f"Value error, Sample date {bad_sample_date} cannot be in the future." + == f"Value error, Sample date {bad_sample_date[:-1].replace("T", " ")}+00:00 cannot be in the future." ) assert detail[0]["input"] == bad_sample_date assert detail[0]["ctx"] == {"error": {}} From 92bc9c27077d852a8f320e85ee008dfe2a4e5b84 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 14:44:18 -0600 Subject: [PATCH 10/14] fix: handle database errors at API layer for sample post and patch --- api/sample.py | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/api/sample.py b/api/sample.py index cee82c4a4..976749000 100644 --- a/api/sample.py +++ b/api/sample.py @@ -14,9 +14,10 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.orm import Session -from starlette.status import HTTP_201_CREATED +from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT from api.pagination import CustomPage from core.dependencies import session_dependency @@ -34,13 +35,41 @@ ) +def database_error_handler( + payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError +) -> None: + """ + Handle database integrity errors. + """ + error_message = error.orig.args[0]["M"] + if ( + error_message + == 'duplicate key value violates unique constraint "sample_field_sample_id_key"' + ): + detail = ( + f"Sample with field_sample_id {payload.field_sample_id} already exists." + ) + elif ( + error_message + == 'insert or update on table "sample" violates foreign key constraint "sample_thing_id_fkey"' + ): + detail = f"Thing with ID {payload.thing_id} does not exist." + + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail) + + # ============= Post ============================================= @router.post("", status_code=HTTP_201_CREATED) def add_sample(sample_data: CreateSample, session: session_dependency): """ Endpoint to add a sample. """ - return adder(session, Sample, sample_data) + try: + return adder(session, Sample, sample_data) + except IntegrityError as e: + database_error_handler(sample_data, e) + except ProgrammingError as e: + database_error_handler(sample_data, e) # ============= Update ============================================= @@ -66,8 +95,12 @@ def update_sample( This is handled by the `model_patcher` function, which excludes unset fields from the update. """ - - return model_patcher(session, Sample, sample_id, sample_data) + try: + return model_patcher(session, Sample, sample_id, sample_data) + except IntegrityError as e: + database_error_handler(sample_data, e) + except ProgrammingError as e: + database_error_handler(sample_data, e) # ============= Get ============================================= From fb0f53a6a1ab35c5d5a7c97fd04f758db372f02b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 14:45:22 -0600 Subject: [PATCH 11/14] refactor: address PR 62 requests and comments - test custom pydantic validations on their own - handle db errors, like foreign key violations, at the API level and test those cases - use model_validator for cross-field validations for sample top and sample bottom - test response against the payload, not response against response --- schemas_v2/sample.py | 84 ++------ tests/test_sample.py | 498 ++++++++++++++++--------------------------- 2 files changed, 194 insertions(+), 388 deletions(-) diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index e25c747ab..e520ade7d 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -14,10 +14,8 @@ # limitations under the License. # =============================================================================== from datetime import datetime, timezone -from pydantic import BaseModel, field_validator - -from db.engine import session_ctx -from db import Thing, Sensor, Sample +from pydantic import BaseModel, field_validator, model_validator +from typing_extensions import Self """ @@ -32,48 +30,6 @@ class ValidateSample(BaseModel): Validator for Sample data for Create and Update schemas. """ - @field_validator("field_sample_id", check_fields=False) - def validate_field_sample_id(cls, field_sample_id: str) -> str: - """ - Validate that the field_sample_id is unique. - """ - if field_sample_id is not None: - with session_ctx() as session: - existing_sample = ( - session.query(Sample) - .filter_by(field_sample_id=field_sample_id) - .first() - ) - if existing_sample: - raise ValueError( - f"Field sample ID {field_sample_id} already exists." - ) - return field_sample_id - - @field_validator("sensor_id", check_fields=False) - def validate_sensor_id(cls, sensor_id: int | None) -> int | None: - """ - Validate that the sensor_idexists in the database. - """ - if sensor_id is not None: - with session_ctx() as session: - sensor = session.get(Sensor, sensor_id) - if not sensor: - raise ValueError(f"Sensor with ID {sensor_id} does not exist.") - return sensor_id - - @field_validator("thing_id", check_fields=False) - def validate_thing_id_exists(cls, thing_id: int | None) -> int | None: - """ - Validate that the thing_id exists in the database. - """ - if thing_id is not None: - with session_ctx() as session: - thing = session.get(Thing, thing_id) - if not thing: - raise ValueError(f"Thing with ID {thing_id} does not exist.") - return thing_id - @field_validator("sample_date", check_fields=False) def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: """ @@ -101,31 +57,21 @@ def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: # REFACTOR TODO: fields are evaluated in the order in which they are defined. # are sample top/bottom really working as expected? - @field_validator("sample_top", check_fields=False) - def validate_sample_top(cls, sample_top: float | None, values) -> float | None: - """ - Validate that the sample_top is not less than sample_bottom. - """ - sample_bottom = values.data.get("sample_bottom") - if sample_bottom is None and sample_top is not None: - raise ValueError("Sample bottom must be defined if sample top is defined.") - elif sample_bottom is not None and sample_top is None: - raise ValueError("Sample top must be defined if sample bottom is defined.") - return sample_top - - @field_validator("sample_bottom", check_fields=False) - def validate_sample_bottom( - cls, sample_bottom: float | None, values - ) -> float | None: + @model_validator(mode="after") + def validate_top_and_bottom(self) -> Self: """ - Validate that the sample_bottom is defined if sample_top is defined and vice versa + Validate that sample_top and sample_bottom are both defined or both None. """ - sample_top = values.data.get("sample_top") - if sample_bottom is None and sample_top is not None: - raise ValueError("Sample bottom must be defined if sample top is defined.") - elif sample_bottom is not None and sample_top is None: - raise ValueError("Sample top must be defined if sample bottom is defined.") - return sample_bottom + sample_top = getattr(self, "sample_top", None) + sample_bottom = getattr(self, "sample_bottom", None) + + if (sample_top is not None and sample_bottom is None) or ( + sample_top is None and sample_bottom is not None + ): + raise ValueError( + "Sample top and bottom must both be defined or both must be None." + ) + return self # -------- CREATE ---------- diff --git a/tests/test_sample.py b/tests/test_sample.py index 31a3cb81b..254a35db7 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -13,47 +13,106 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from datetime import datetime -from copy import deepcopy +import pytest from db.engine import session_ctx from db.sample import Sample +from schemas_v2.sample import ValidateSample from tests import client +# ============= module & function fixtures ======================================= + + +@pytest.fixture(scope="function") +def second_sample(thing, sensor): + with session_ctx() as session: + sample = Sample( + thing_id=thing.id, + sample_type="groundwater", + field_sample_id="FS-9999999", + sample_date="2025-01-01T00:00:00Z", + release_status="draft", + sampler_name="Test Sampler", + qc_sample="Duplicate", + sensor_id=sensor.id, + sample_matrix="water", + sample_method="manual", + duplicate_sample_number=3, + sample_top=2, + sample_bottom=3, + ) + session.add(sample) + session.commit() + yield sample + session.delete(sample) + session.commit() + + +# ============== Custom validators ================================================= + + +def test_validate_sample_date(): + invalid_sample_date = "3500-01-01T00:00:00Z" + try: + invalid_sample = ValidateSample(sample_date=invalid_sample_date) + except ValueError as e: + assert str(e) == f"Sample date {invalid_sample_date} is not valid." + + +def test_validate_sample_top_and_bottom(): + for i in range(2): + sample_top = 10.0 if i == 0 else None + sample_bottom = 5.0 if i == 1 else None + try: + invalid_sample = ValidateSample( + sample_top=sample_top, sample_bottom=sample_bottom + ) + except ValueError as e: + assert ( + str(e) + == "Sample top and bottom must both be defined or both must be None." + ) + # ============= Post tests for samples ============================================= -def test_add_sample(thing): +def test_add_sample(thing, sensor): """ Test adding a sample to the collaborative network. """ + payload = { + "thing_id": thing.id, + "sample_type": "groundwater", + "field_sample_id": "FS-1234567", + "sample_date": "2025-01-01T00:00:00Z", + "release_status": "draft", + "sampler_name": "Test Sampler", + "qc_sample": "Duplicate", + "sensor_id": sensor.id, + "sample_matrix": "water", + "sample_method": "manual", + "duplicate_sample_number": 3, + "sample_top": 2, + "sample_bottom": 3, + } response = client.post( "/sample", - json={ - "thing_id": thing.id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler": "Test Sampler A", - "field_sample_id": "FS-12345", - "sampler_name": "Test Add Sampler", - }, + json=payload, ) data = response.json() - assert data["id"] == data["id"] - assert data["thing_id"] == data["thing_id"] - assert data["sample_type"] == data["sample_type"] - assert data["field_sample_id"] == data["field_sample_id"] - assert data["sample_date"] == data["sample_date"] - assert data["release_status"] == data["release_status"] - assert data["sampler_name"] == data["sampler_name"] - assert data["qc_sample"] == data["qc_sample"] - assert data["sensor_id"] == data["sensor_id"] - assert data["sample_matrix"] == data["sample_matrix"] - assert data["sample_method"] == data["sample_method"] - assert data["duplicate_sample_number"] == data["duplicate_sample_number"] - assert data["sample_top"] == data["sample_top"] - assert data["sample_bottom"] == data["sample_bottom"] + assert response.status_code == 201 + assert data["thing_id"] == payload["thing_id"] + assert data["sample_type"] == payload["sample_type"] + assert data["field_sample_id"] == payload["field_sample_id"] + assert data["sample_date"] == payload["sample_date"][:-1] + assert data["release_status"] == payload["release_status"] + assert data["sampler_name"] == payload["sampler_name"] + assert data["qc_sample"] == payload["qc_sample"] + assert data["sensor_id"] == payload["sensor_id"] + assert data["sample_matrix"] == payload["sample_matrix"] + assert data["sample_method"] == payload["sample_method"] + assert data["duplicate_sample_number"] == payload["duplicate_sample_number"] + assert data["sample_top"] == payload["sample_top"] + assert data["sample_bottom"] == payload["sample_bottom"] # cleanup after adding the sample with session_ctx() as session: @@ -61,39 +120,95 @@ def test_add_sample(thing): session.commit() +def test_409_add_sample_invalid_field_sample_id(sample, thing): + """ + Test adding a sample with an invalid field_sample_id. + """ + payload = { + "thing_id": thing.id, + "sample_type": "groundwater", + "field_sample_id": sample.field_sample_id, # This should already exist + "sample_date": "2025-01-01T00:00:00Z", + "release_status": "draft", + "sampler_name": "Test Sampler", + "qc_sample": "Duplicate", + "sensor_id": None, + "sample_matrix": "water", + "sample_method": "manual", + "duplicate_sample_number": 3, + "sample_top": 2, + "sample_bottom": 3, + } + response = client.post( + "/sample", + json=payload, + ) + data = response.json() + assert response.status_code == 409 + assert ( + data["detail"] + == f"Sample with field_sample_id {sample.field_sample_id} already exists." + ) + + +def test_409_add_sample_invalid_thing_id(): + """ + Test adding a sample with an invalid thing_id. + """ + payload = { + "thing_id": 9999999, + "sample_type": "groundwater", + "field_sample_id": "FS-9999999", + "sample_date": "2025-01-01T00:00:00Z", + "release_status": "draft", + "sampler_name": "Test Sampler", + "qc_sample": "Duplicate", + "sensor_id": None, + "sample_matrix": "water", + "sample_method": "manual", + "duplicate_sample_number": 3, + "sample_top": 2, + "sample_bottom": 3, + } + response = client.post( + "/sample", + json=payload, + ) + data = response.json() + assert response.status_code == 409 + assert data["detail"] == f"Thing with ID {payload['thing_id']} does not exist." + + # ============= Patch tests for samples ============================================= def test_patch_sample(sample): """ Test updating a sample in the collaborative network. """ - original_sample = deepcopy(sample) - - sampler_name = "Test Sampler B" - sample_method_patch = "continuous" - sample_date_patch = "2025-01-02T00:00:00Z" + new_sampler_name = "Test Sampler B" + new_sample_method = "continuous" + new_sample_date = "2025-01-02T00:00:00Z" response = client.patch( f"/sample/{sample.id}", json={ - "sampler_name": sampler_name, - "sample_method": sample_method_patch, - "sample_date": sample_date_patch, + "sampler_name": new_sampler_name, + "sample_method": new_sample_method, + "sample_date": new_sample_date, }, ) assert response.status_code == 200 data = response.json() assert data["id"] == sample.id - assert data["sampler_name"] == sampler_name - assert data["sample_date"] == sample_date_patch[:-1] - assert data["sample_method"] == sample_method_patch - assert data["thing_id"] == sample.thing_id + assert data["sampler_name"] == new_sampler_name + assert data["sample_date"] == new_sample_date[:-1] + assert data["sample_method"] == new_sample_method - # cleanup after patching the sample + # rollback after updating the sample with session_ctx() as session: - updated_sample = session.query(Sample).filter(Sample.id == sample.id).one() - updated_sample.sample_method = original_sample.sample_method - updated_sample.sample_date = original_sample.sample_date - updated_sample.sampler_name = original_sample.sampler_name + updated_sample = session.query(Sample).filter(Sample.id == sample.id).first() + updated_sample.sampler_name = sample.sampler_name + updated_sample.sample_method = sample.sample_method + updated_sample.sample_date = sample.sample_date session.commit() @@ -113,294 +228,39 @@ def test_patch_sample_404_not_found(sample): assert data["detail"] == "Sample with ID 999 not found." -def test_patch_sample_422_thing_id_not_found(sample): +def test_409_patch_sample_invalid_field_sample_id(sample, second_sample): """ - Test updating a sample with a thing_id that does not exist + Test updating a sample with an invalid field_sample_id. """ - bad_thing_id = 999 + payload = { + "field_sample_id": sample.field_sample_id, # This should already exist + } response = client.patch( - f"/sample/{sample.id}", - json={ - "thing_id": bad_thing_id, - }, + f"/sample/{second_sample.id}", + json=payload, ) - assert response.status_code == 422 data = response.json() - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - error = data["detail"][0] - assert error["type"] == "value_error" - assert error["loc"] == ["body", "thing_id"] - assert error["msg"] == f"Value error, Thing with ID {bad_thing_id} does not exist." - assert error["input"] == bad_thing_id - assert error["ctx"] == {"error": {}} + assert response.status_code == 409 + assert ( + data["detail"] + == f"Sample with field_sample_id {payload['field_sample_id']} already exists." + ) -def test_patch_sample_422_invalid_timestamp(sample): +def test_409_patch_sample_invalid_thing_id(sample): """ - Test updating a sample with an invalid collection timestamp. + Test updating a sample with an invalid thing_id. """ - bad_sample_date = "3500-01-01T00:00:00Z" + payload = { + "thing_id": 9999999, + } response = client.patch( f"/sample/{sample.id}", - json={ - "sample_date": bad_sample_date, # Invalid date - }, + json=payload, ) - assert response.status_code == 422 data = response.json() - assert "detail" in data - detail = data["detail"] - assert isinstance(detail, list) - assert len(detail) == 1 - assert detail[0]["type"] == "value_error" - assert detail[0]["loc"] == ["body", "sample_date"] - assert ( - detail[0]["msg"] - == f"Value error, Sample date {bad_sample_date[:-1].replace("T", " ")}+00:00 cannot be in the future." - ) - assert detail[0]["input"] == bad_sample_date - assert detail[0]["ctx"] == {"error": {}} - - -# ============== Custom validations for samples ============================================= - -""" -Development notes: - -Both POST and PATCH endpoints use the same custom validators, which is why -they are being tested together here. For true unit testing, though, these can be separated -into different POST and PATCH validation tests. -""" - - -def test_validate_thing_id(sample): - bad_thing_id = 999 - - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": bad_thing_id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": "FS-12345", - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "thing_id": bad_thing_id, - }, - ) - - data = response.json() - assert response.status_code == 422 - - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "thing_id"] - assert ( - detail["msg"] - == f"Value error, Thing with ID {bad_thing_id} does not exist." - ) - assert detail["input"] == bad_thing_id - assert detail["ctx"] == {"error": {}} - - -def test_validate_sample_date(sample): - bad_sample_date = "3500-01-01T00:00:00Z" - bad_sample_date_dt = datetime.fromisoformat(bad_sample_date.replace("Z", "+00:00")) - - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": sample.thing_id, - "sample_date": bad_sample_date, - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": "FS-12345", - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "sample_date": bad_sample_date, - }, - ) - - data = response.json() - assert response.status_code == 422 - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "sample_date"] - assert ( - detail["msg"] - == f"Value error, Sample date {bad_sample_date_dt} cannot be in the future." - ) - assert detail["input"] == bad_sample_date - assert detail["ctx"] == {"error": {}} - - -def test_validate_field_sample_id(sample): - bad_field_sample_id = sample.field_sample_id - - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": sample.thing_id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": bad_field_sample_id, - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "field_sample_id": bad_field_sample_id, - }, - ) - assert response.status_code == 422 - data = response.json() - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "field_sample_id"] - assert ( - detail["msg"] - == f"Value error, Field sample ID {bad_field_sample_id} already exists." - ) - - -def test_validate_sensor_id(sample): - sensor_id = 999999999 - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": sample.thing_id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": "FS-12345", - "sensor_id": sensor_id, - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "sensor_id": sensor_id, - }, - ) - assert response.status_code == 422 - data = response.json() - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "sensor_id"] - assert ( - detail["msg"] == f"Value error, Sensor with ID {sensor_id} does not exist." - ) - - -def test_validate_sample_bottom(sample): - sample_bottom = 10.0 - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": sample.thing_id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": "FS-12345", - "sample_bottom": sample_bottom, - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "sample_bottom": sample_bottom, - }, - ) - data = response.json() - assert response.status_code == 422 - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "sample_bottom"] - assert ( - detail["msg"] - == "Value error, Sample top must be defined if sample bottom is defined." - ) - - -def test_validate_sample_top(sample): - sample_top = 10.0 - for method in ["post", "patch"]: - if method == "post": - response = client.post( - "/sample", - json={ - "thing_id": sample.thing_id, - "sample_date": "2025-01-01T00:00:00Z", - "sample_method": "manual", - "release_status": "draft", - "sample_type": "groundwater", - "sampler_name": "Test Sampler", - "field_sample_id": "FS-12345", - "sample_top": sample_top, - }, - ) - else: - response = client.patch( - f"/sample/{sample.id}", - json={ - "sample_top": sample_top, - }, - ) - data = response.json() - assert response.status_code == 422 - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - detail = data["detail"][0] - assert detail["type"] == "value_error" - assert detail["loc"] == ["body", "sample_top"] - assert ( - detail["msg"] - == "Value error, Sample bottom must be defined if sample top is defined." - ) + assert response.status_code == 409 + assert data["detail"] == f"Thing with ID {payload['thing_id']} does not exist." # ============= Get tests for samples ============================================= From a0583b1e56f6e0ed88d3924d692de2f4a6379bb7 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 16:28:17 -0600 Subject: [PATCH 12/14] refactor: simplify db error handling for sample patch and post endpoints --- api/sample.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/api/sample.py b/api/sample.py index 976749000..8b69e71d5 100644 --- a/api/sample.py +++ b/api/sample.py @@ -39,7 +39,7 @@ def database_error_handler( payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError ) -> None: """ - Handle database integrity errors. + Handle errors raised by the database when adding or updating a sample. """ error_message = error.orig.args[0]["M"] if ( @@ -66,9 +66,7 @@ def add_sample(sample_data: CreateSample, session: session_dependency): """ try: return adder(session, Sample, sample_data) - except IntegrityError as e: - database_error_handler(sample_data, e) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(sample_data, e) @@ -97,9 +95,7 @@ def update_sample( """ try: return model_patcher(session, Sample, sample_id, sample_data) - except IntegrityError as e: - database_error_handler(sample_data, e) - except ProgrammingError as e: + except (IntegrityError, ProgrammingError) as e: database_error_handler(sample_data, e) From 6d94466dd8dffaf8249083fb25ccc5175d222e78 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 16:39:39 -0600 Subject: [PATCH 13/14] refactor: address PR 62 feedback --- tests/test_sample.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sample.py b/tests/test_sample.py index 254a35db7..aa5f3c41d 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -116,7 +116,7 @@ def test_add_sample(thing, sensor): # cleanup after adding the sample with session_ctx() as session: - session.query(Sample).where(Sample.id == data["id"]).delete() + session.delete(session.get(Sample, data["id"])) session.commit() @@ -205,7 +205,7 @@ def test_patch_sample(sample): # rollback after updating the sample with session_ctx() as session: - updated_sample = session.query(Sample).filter(Sample.id == sample.id).first() + updated_sample = session.get(Sample, sample.id) updated_sample.sampler_name = sample.sampler_name updated_sample.sample_method = sample.sample_method updated_sample.sample_date = sample.sample_date From c96d45cca6a30d60295495757bc1428b30b8520b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Tue, 5 Aug 2025 16:41:14 -0600 Subject: [PATCH 14/14] fix: remove erroneous autocomplete documentation --- tests/test_sample.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_sample.py b/tests/test_sample.py index aa5f3c41d..2d30e92d7 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -77,7 +77,7 @@ def test_validate_sample_top_and_bottom(): # ============= Post tests for samples ============================================= def test_add_sample(thing, sensor): """ - Test adding a sample to the collaborative network. + Test adding a sample. """ payload = { "thing_id": thing.id, @@ -182,7 +182,7 @@ def test_409_add_sample_invalid_thing_id(): # ============= Patch tests for samples ============================================= def test_patch_sample(sample): """ - Test updating a sample in the collaborative network. + Test updating a sample. """ new_sampler_name = "Test Sampler B" new_sample_method = "continuous" @@ -214,7 +214,7 @@ def test_patch_sample(sample): def test_patch_sample_404_not_found(sample): """ - Test updating a sample that does not exist in the collaborative network. + Test updating a sample that does not exist """ sample_method_patch = "continuous" response = client.patch( @@ -266,7 +266,7 @@ def test_409_patch_sample_invalid_thing_id(sample): # ============= Get tests for samples ============================================= def test_get_samples(sample): """ - Test retrieving samples from the collaborative network. + Test retrieving samples """ response = client.get("/sample") assert response.status_code == 200 @@ -290,7 +290,7 @@ def test_get_samples(sample): def test_get_sample_by_id(sample): """ - Test retrieving a sample from the collaborative network. + Test retrieving a sample by its ID. """ response = client.get(f"/sample/{sample.id}") assert response.status_code == 200 @@ -313,7 +313,7 @@ def test_get_sample_by_id(sample): def test_get_sample_by_id_404_not_found(sample): """ - Test retrieving a sample from the collaborative network. + Test retrieving a sample that does not exist. """ response = client.get("/sample/999") assert response.status_code == 404