diff --git a/api/sample.py b/api/sample.py index cee82c4a4..8b69e71d5 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,39 @@ ) +def database_error_handler( + payload: CreateSample | UpdateSample, error: IntegrityError | ProgrammingError +) -> None: + """ + Handle errors raised by the database when adding or updating a sample. + """ + 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, ProgrammingError) as e: + database_error_handler(sample_data, e) # ============= Update ============================================= @@ -66,8 +93,10 @@ 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, ProgrammingError) as e: + database_error_handler(sample_data, e) # ============= Get ============================================= diff --git a/db/sample.py b/db/sample.py index 70b7152b6..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( 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): diff --git a/schemas_v2/sample.py b/schemas_v2/sample.py index 37ac6d188..e520ade7d 100644 --- a/schemas_v2/sample.py +++ b/schemas_v2/sample.py @@ -14,93 +14,143 @@ # limitations under the License. # =============================================================================== from datetime import datetime, timezone -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator +from typing_extensions import Self -from db.engine import get_db_session, session_ctx -from db import Thing + +""" +REFACTOR TODO: can we use inheritance for commonly defined fields and then set them as optional +or not between Create, Update, and Response schemas? +""" + + +# -------- VALIDATE ---------- +class ValidateSample(BaseModel): + """ + Validator for Sample data for Create and Update schemas. + """ + + @field_validator("sample_date", check_fields=False) + def validate_sample_date(cls, sample_date: datetime | None) -> datetime | None: + """ + Validate that the sample_date is not 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 + + # REFACTOR TODO: fields are evaluated in the order in which they are defined. + # are sample top/bottom really working as expected? + + @model_validator(mode="after") + def validate_top_and_bottom(self) -> Self: + """ + Validate that sample_top and sample_bottom are both defined or both None. + """ + 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 ---------- -class CreateSample(BaseModel): +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 + setting = None makes the field optional, but if it is defined it must be of that type. + """ + 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 + ) -class CreateGeothermalSample(BaseModel): - """ - Represents a geothermal sample in the collaborative network. - """ + duplicate_sample_number: int | None = None - sample_id: int + # 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 | None = None + thing_id: int sample_type: str field_sample_id: str - sample_matrix: str | None = None - sampler_name: str | None = None - qc_sample: str | None = None - duplicate_sample_number: int | None = None - sample_top: float | None = None - sample_bottom: float | None = None - sensor_id: int | None = None + sample_date: datetime release_status: str - created_at: datetime - thing_id: int + sampler_name: str + qc_sample: str + sensor_id: int | None + sample_matrix: str | None + sample_method: str | None -# -------- UPDATE ---------- -class UpdateSample(BaseModel): - sample_date: datetime | None = None - sample_method: str | None = None - thing_id: int | None = None + duplicate_sample_number: int | 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 + sample_top: float | None + sample_bottom: float | None # ============= EOF ============================================= 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): 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 bb50b4d6e..2d30e92d7 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -14,110 +14,207 @@ # limitations under the License. # =============================================================================== import pytest -from datetime import datetime 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. + Test adding a sample. """ + 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", - }, + json=payload, ) data = response.json() assert response.status_code == 201 - assert data["id"] is not None - assert data["thing_id"] == thing.id + 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 - sample_id = data["id"] with session_ctx() as session: - session.query(Sample).where(Sample.id == sample_id).delete() + session.delete(session.get(Sample, data["id"])) session.commit() -@pytest.mark.skip(reason="Geochemical sample endpoint not implemented yet") -def test_add_geochemical_sample(): +def test_409_add_sample_invalid_field_sample_id(sample, thing): """ - Test adding a geochemical sample to the collaborative network. + 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/geochemical", - json={ - "sample_id": 1, - }, + "/sample", + json=payload, ) - assert response.status_code == 201 data = response.json() - assert data["id"] is not None - assert data["sample_id"] == 1 + assert response.status_code == 409 + assert ( + data["detail"] + == f"Sample with field_sample_id {sample.field_sample_id} already exists." + ) -@pytest.mark.skip(reason="Geothermal sample endpoint not implemented yet") -def test_add_geothermal_sample(): +def test_409_add_sample_invalid_thing_id(): """ - Test adding a geothermal sample to the collaborative network. + 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/geothermal", - json={ - "sample_id": 1, - }, + "/sample", + json=payload, ) - assert response.status_code == 201 data = response.json() - assert data["id"] is not None - assert data["sample_id"] == 1 + 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. + Test updating a sample. """ - original_method_patch = sample.sample_method - original_timestamp_patch = sample.sample_date - - 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={ - "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["sample_date"] == sample_date_patch[:-1] - assert data["sample_method"] == sample_method_patch + 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_method_patch - updated_sample.sample_date = original_timestamp_patch + 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 session.commit() 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( @@ -131,108 +228,92 @@ 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 "detail" in data - assert isinstance(data["detail"], list) - assert len(data["detail"]) == 1 - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert response.status_code == 409 assert ( - data["detail"][0]["msg"] - == f"Value error, Thing with ID {bad_thing_id} does not exist." + 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 response.status_code == 409 + assert data["detail"] == f"Thing with ID {payload['thing_id']} does not exist." # ============= 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 data = response.json() - assert "items" in data 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]["thing_id"] == sample.thing_id - - -@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 - - -@pytest.mark.skip(reason="Geothermal samples endpoint not implemented yet") -def test_get_geothermal_samples(): - """ - Test retrieving geothermal samples from the collaborative network. - """ - response = client.get("/sample/geothermal") - assert response.status_code == 200 - data = response.json() - assert "items" in data - assert len(data["items"]) > 0 + 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): """ - 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 data = response.json() 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["thing_id"] == sample.thing_id + 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): """ - 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