Skip to content

Commit a2c94bb

Browse files
committed
Add unit tests for core
1 parent 43a432f commit a2c94bb

File tree

9 files changed

+576
-18
lines changed

9 files changed

+576
-18
lines changed

openapi.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ components:
99
type: string
1010
storage_alias:
1111
description: S3 storage alias to use for uploads
12+
minLength: 1
1213
title: Storage Alias
1314
type: string
1415
title:
1516
description: Short meaningful name for the box
17+
minLength: 1
1618
title: Title
1719
type: string
1820
required:

src/uos/adapters/inbound/fastapi_/configure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from uos.adapters.inbound.fastapi_.routes import router
2626
from uos.config import Config
2727

28-
config = Config()
28+
config = Config() # type: ignore
2929

3030

3131
def get_openapi_schema(api) -> dict[str, Any]:

src/uos/adapters/inbound/fastapi_/routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ async def create_research_data_upload_box(
112112

113113
try:
114114
box_id = await upload_service.create_research_data_upload_box(
115-
request=request,
115+
title=request.title,
116+
description=request.description,
117+
storage_alias=request.storage_alias,
116118
user_id=UUID(auth_context.id),
117119
)
118120
return box_id

src/uos/core/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,13 @@ class ViewFileBoxWorkOrder(BaseWorkOrderToken):
140140
class CreateUploadBoxRequest(BaseModel):
141141
"""Request model for creating a new research data upload box."""
142142

143-
title: str = Field(..., description="Short meaningful name for the box")
143+
title: str = Field(
144+
..., description="Short meaningful name for the box", min_length=1
145+
)
144146
description: str = Field(..., description="Describes the upload box in more detail")
145-
storage_alias: str = Field(..., description="S3 storage alias to use for uploads")
147+
storage_alias: str = Field(
148+
..., description="S3 storage alias to use for uploads", min_length=1
149+
)
146150

147151

148152
class CreateUploadBoxResponse(BaseModel):

src/uos/core/orchestrator.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
from pydantic import UUID4
2626

2727
from uos.core.models import (
28-
CreateUploadBoxRequest,
2928
FileUploadBox,
3029
GrantAccessRequest,
3130
ResearchDataUploadBox,
@@ -51,16 +50,18 @@ def __init__(
5150
box_dao: BoxDao,
5251
audit_repository: AuditRepositoryPort,
5352
ucs_client: UCSClientPort,
54-
claims_client: AccessClientPort,
53+
access_client: AccessClientPort,
5554
):
5655
self._box_dao = box_dao
5756
self._audit_repository = audit_repository
5857
self._ucs_client = ucs_client
59-
self._access_client = claims_client
58+
self._access_client = access_client
6059

6160
async def create_research_data_upload_box(
6261
self,
63-
request: CreateUploadBoxRequest,
62+
title: str,
63+
description: str,
64+
storage_alias: str,
6465
user_id: UUID4,
6566
) -> UUID4:
6667
"""Create a new research data upload box.
@@ -78,18 +79,18 @@ async def create_research_data_upload_box(
7879
"""
7980
# Create FileUploadBox in UCS
8081
file_upload_box_id = await self._ucs_client.create_file_upload_box(
81-
storage_alias=request.storage_alias
82+
storage_alias=storage_alias
8283
)
8384

8485
# Create ResearchDataUploadBox
8586
box = ResearchDataUploadBox(
8687
state=ResearchDataUploadBoxState.OPEN,
87-
title=request.title,
88-
description=request.description,
88+
title=title,
89+
description=description,
8990
last_changed=now_utc_ms_prec(),
9091
changed_by=user_id,
9192
file_upload_box_id=file_upload_box_id,
92-
storage_alias=request.storage_alias,
93+
storage_alias=storage_alias,
9394
)
9495

9596
# Store in repository & create audit record
@@ -157,6 +158,7 @@ async def grant_upload_access(
157158
Raises:
158159
BoxNotFoundError: If the box doesn't exist.
159160
"""
161+
# TODO: Test this method after adding audit record
160162
# Verify the upload box exists
161163
await self._box_dao.get_by_id(request.box_id)
162164

@@ -166,7 +168,7 @@ async def grant_upload_access(
166168
iva_id=request.iva_id,
167169
box_id=request.box_id,
168170
)
169-
# TODO: Create audit record?
171+
# TODO: Create audit record
170172

171173
async def get_upload_box_files(
172174
self,
@@ -184,7 +186,10 @@ async def get_upload_box_files(
184186
UCSCallError: if there's a problem querying the UCS.
185187
"""
186188
# Verify access
187-
upload_box = await self._box_dao.get_by_id(box_id)
189+
try:
190+
upload_box = await self._box_dao.get_by_id(box_id)
191+
except ResourceNotFoundError as err:
192+
raise self.BoxNotFoundError(box_id=box_id) from err
188193

189194
is_data_steward = "data_steward" in auth_context.roles
190195
user_id = UUID(auth_context.id)
@@ -224,7 +229,7 @@ async def upsert_file_upload_box(self, file_upload_box: FileUploadBox) -> None:
224229

225230
# Conditionally update data
226231
if updated_model.model_dump() != research_data_upload_box.model_dump():
227-
await self._box_dao.update(research_data_upload_box)
232+
await self._box_dao.update(updated_model)
228233
except NoHitsFoundError:
229234
# This might happen during initial creation - ignore
230235
log.info(

src/uos/inject.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async def prepare_core(
102102
yield UploadOrchestrator(
103103
box_dao=box_dao,
104104
audit_repository=audit_repository,
105-
claims_client=claims_client,
105+
access_client=claims_client,
106106
ucs_client=ucs_client,
107107
)
108108

src/uos/ports/inbound/orchestrator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from pydantic import UUID4
2323

2424
from uos.core.models import (
25-
CreateUploadBoxRequest,
2625
FileUploadBox,
2726
GrantAccessRequest,
2827
ResearchDataUploadBox,
@@ -46,7 +45,9 @@ def __init__(self, *, box_id: UUID4):
4645
@abstractmethod
4746
async def create_research_data_upload_box(
4847
self,
49-
request: CreateUploadBoxRequest,
48+
title: str,
49+
description: str,
50+
storage_alias: str,
5051
user_id: UUID4,
5152
) -> UUID4:
5253
"""Create a new research data upload box.

tests/fixtures/in_mem_dao.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2+
# for the German Human Genome-Phenome Archive (GHGA)
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""A dummy DAO"""
17+
18+
from collections.abc import AsyncIterator, Mapping
19+
from copy import deepcopy
20+
from typing import Any, TypeVar
21+
from unittest.mock import AsyncMock, Mock
22+
23+
from hexkit.custom_types import ID
24+
from hexkit.protocols.dao import (
25+
MultipleHitsFoundError,
26+
NoHitsFoundError,
27+
ResourceAlreadyExistsError,
28+
ResourceNotFoundError,
29+
)
30+
from pydantic import BaseModel
31+
32+
from uos.core.models import ResearchDataUploadBox
33+
34+
DTO = TypeVar("DTO", bound=BaseModel)
35+
36+
37+
class BaseInMemDao[DTO: BaseModel]:
38+
"""Base class for dummy DAOs with proper typing and in-memory storage"""
39+
40+
_id_field: str
41+
publish_pending = AsyncMock()
42+
republish = AsyncMock()
43+
with_transaction = Mock()
44+
45+
def __init__(self) -> None:
46+
self.resources: list[DTO] = []
47+
48+
@property
49+
def latest(self) -> DTO:
50+
"""Return the most recently inserted resource"""
51+
return deepcopy(self.resources[-1])
52+
53+
async def get_by_id(self, id_: ID) -> DTO:
54+
"""Get the resource via ID."""
55+
for resource in self.resources:
56+
if id_ == getattr(resource, self._id_field):
57+
return deepcopy(resource)
58+
raise ResourceNotFoundError(id_=id_)
59+
60+
async def find_one(self, *, mapping: Mapping[str, Any]) -> DTO:
61+
"""Find the resource that matches the specified mapping."""
62+
hits = self.find_all(mapping=mapping)
63+
try:
64+
dto = await hits.__anext__()
65+
except StopAsyncIteration as error:
66+
raise NoHitsFoundError(mapping=mapping) from error
67+
68+
try:
69+
_ = await hits.__anext__()
70+
except StopAsyncIteration:
71+
# This is expected:
72+
return dto
73+
74+
raise MultipleHitsFoundError(mapping=mapping)
75+
76+
async def find_all(self, *, mapping: Mapping[str, Any]) -> AsyncIterator[DTO]:
77+
"""Find all resources that match the specified mapping."""
78+
for resource in self.resources:
79+
if all([getattr(resource, k) == v for k, v in mapping.items()]):
80+
yield deepcopy(resource)
81+
82+
async def insert(self, dto: DTO) -> None:
83+
"""Insert a resource"""
84+
dto_id = getattr(dto, self._id_field)
85+
for resource in self.resources:
86+
if getattr(resource, self._id_field) == dto_id:
87+
raise ResourceAlreadyExistsError(id_=dto_id)
88+
self.resources.append(deepcopy(dto))
89+
90+
async def update(self, dto: DTO) -> None:
91+
"""Update a resource"""
92+
for i, resource in enumerate(self.resources):
93+
if getattr(resource, self._id_field) == getattr(dto, self._id_field):
94+
self.resources[i] = deepcopy(dto)
95+
break
96+
else:
97+
raise ResourceNotFoundError(id_=getattr(dto, self._id_field))
98+
99+
async def delete(self, id_: ID) -> None:
100+
"""Delete a resource by ID"""
101+
for i, resource in enumerate(self.resources):
102+
if getattr(resource, self._id_field) == id_:
103+
del self.resources[i]
104+
break
105+
else:
106+
raise ResourceNotFoundError(id_=id_)
107+
108+
async def upsert(self, dto: DTO) -> None:
109+
"""Upsert a resource"""
110+
for i, resource in enumerate(self.resources):
111+
if getattr(resource, self._id_field) == getattr(dto, self._id_field):
112+
self.resources[i] = deepcopy(dto)
113+
break
114+
else:
115+
self.resources.append(deepcopy(dto))
116+
117+
118+
def get_dao[DTO: BaseModel](
119+
*, dto_model: type[DTO], id_field: str
120+
) -> type[BaseInMemDao[DTO]]:
121+
"""Produce a dummy DAO for the given DTO model and id field"""
122+
123+
class DummyDao(BaseInMemDao[DTO]):
124+
"""Dummy dao that stores data in memory"""
125+
126+
_id_field: str = id_field
127+
128+
return DummyDao
129+
130+
131+
InMemBoxDao = get_dao(dto_model=ResearchDataUploadBox, id_field="id")

0 commit comments

Comments
 (0)