From 04a1fb9e8e6d53b35a21cd6fccc8042a48d59e7e Mon Sep 17 00:00:00 2001 From: Brendan Foley Date: Wed, 19 Aug 2026 14:15:12 -0700 Subject: [PATCH 1/2] refactor(multiple): unified crud grammar for domains all domains now expose public `*_one` and `*_many` rather than including their domain in the method names. This allows for a similar method space regardless of domain --- .../domains/_shared/components.py | 15 +- .../domains/_shared/repository.py | 21 +- .../mpcontribs_api/domains/_shared/service.py | 14 +- .../domains/attachments/router.py | 2 +- .../domains/contributions/repository.py | 81 +-- .../domains/contributions/router.py | 21 +- .../domains/contributions/service.py | 92 ++-- .../domains/initiatives/repository.py | 12 +- .../domains/initiatives/router.py | 4 +- .../domains/project_groups/repository.py | 28 +- .../domains/project_groups/router.py | 6 +- .../domains/project_groups/service.py | 4 +- .../domains/projects/repository.py | 14 +- .../mpcontribs_api/domains/projects/router.py | 2 +- .../domains/structures/router.py | 4 +- .../mpcontribs_api/domains/tables/router.py | 4 +- .../db/test_initiatives_repository.py | 2 +- .../tests/integration/test_bulk_limits.py | 8 +- .../integration/test_component_routes.py | 36 +- .../tests/integration/test_contributions.py | 37 +- .../integration/test_contributions_routes.py | 68 +-- .../tests/integration/test_initiatives.py | 4 +- .../tests/integration/test_project_groups.py | 6 +- .../tests/integration/test_projects.py | 36 +- .../unit/domains/test_component_service.py | 18 +- .../unit/domains/test_contribution_service.py | 484 +++++++++--------- .../domains/test_project_group_service.py | 26 +- 27 files changed, 480 insertions(+), 569 deletions(-) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py index fd2841eb2..ba5bb3ff1 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/components.py @@ -36,7 +36,7 @@ async def _existing_by_md5( ).to_list() return {doc.md5: doc for doc in existing_docs} - async def insert_components( + async def insert_many( self, components: list[TIn], session: AsyncClientSession | None = None, @@ -79,21 +79,18 @@ async def insert_components( resolved = existing_by_md5 | new_by_md5 return [resolved[md5] for md5 in unique_md5s] - async def insert_component(self, component: TIn, *, session: AsyncClientSession | None = None) -> TDoc: - """Insert a single component. + async def insert_one(self, component: TIn, *, session: AsyncClientSession | None = None) -> TDoc: # pyright: ignore[reportIncompatibleMethodOverride] + """Insert a single component, deduplicated by content hash. Args: - component (TIn): the table to insert + component (TIn): the component to insert Returns: TDoc: the component actually in the database - - Raises: - AppError: If insert_one returns None, raises """ - return (await self.insert_components(components=[component], session=session))[0] + return (await self.insert_many(components=[component], session=session))[0] - async def delete_components( + async def delete_many( self, filter: TFilter, session: AsyncClientSession | None = None, diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py index baacda8e2..925be9b06 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/repository.py @@ -185,7 +185,7 @@ async def insert_one(self, in_resource: TIn) -> TDoc: ) from exc return document - async def delete(self, filter: TFilter, session: AsyncClientSession | None = None) -> DeleteResponse: + async def delete_many(self, filter: TFilter, session: AsyncClientSession | None = None) -> DeleteResponse: """Delete every scoped document matching an arbitrary ``filter``. This is the bulk path (e.g. "delete every ProjectGroup with owner == X"). It does not raise @@ -217,25 +217,6 @@ async def delete_one( raise NotFoundError(f"{self.document_model.__name__} not found", identifiers=identifiers) return DeleteResponse.from_delete_result(result) - async def delete_by_ids(self, ids: list[Any], session: AsyncClientSession | None = None) -> DeleteResponse: - """Delete multiple scoped documents by id. - - The user scope is injected so callers cannot delete documents they are not permitted to - see; out-of-scope ids simply match nothing and are reported as zero deletions. - - Args: - ids (list[Any]): list of ids to delete - session: the session to perform the deletes within - - Returns: - DeleteResponse: the result of the deletion - """ - docs = self.document_model.find(self._scope, In(self.document_model.id, ids), session=session) - delete_result = await docs.delete_many(session=session) - if not delete_result: - raise ValidationError("DeleteResult not returned internally") - return DeleteResponse.from_delete_result(delete_result) - def _patch_update_fields(self, update: TPatch) -> dict[str, Any]: """Map a patch model to the MongoDB ``$set`` field dict. diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py index 5f9f77e64..9fd3fab7c 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/service.py @@ -91,13 +91,13 @@ async def get_one(self, identifiers: dict[str, Any], fields: frozenset[str] | No return None return await self._components.get_one(identifiers, fields) - async def insert( + async def insert_many( self, components: list[TIn], session: AsyncClientSession | None = None, ) -> list[TDoc]: - """Bulk-insert components, deduplicated by content hash. See ``insert_components``.""" - return await self._components.insert_components(components=components, session=session) + """Bulk-insert components, deduplicated by content hash. See repository ``insert_many``.""" + return await self._components.insert_many(components=components, session=session) async def patch_one(self, identifiers: dict[str, Any], update: TPatch) -> TDoc: """Partially update a component matching ``identifiers``, gated by contribution reachability. @@ -136,7 +136,7 @@ async def download( restrict_ids=allowed, ) - async def delete(self, filter: TFilter) -> ComponentDeleteResponse: + async def delete_many(self, filter: TFilter) -> ComponentDeleteResponse: """Delete components matching ``filter`` that are reachable and globally unreferenced. Args: @@ -152,7 +152,11 @@ async def delete(self, filter: TFilter) -> ComponentDeleteResponse: return ComponentDeleteResponse(num_deleted=0) referenced = await self._contributions.referenced_component_ids(self._ref_field, list(reachable), scoped=False) deletable = [cid for cid in reachable if cid not in referenced] - num_deleted = (await self._components.delete_by_ids(deletable)).num_deleted if deletable else 0 + num_deleted = ( + (await self._components.delete_many(type(filter)(id__in=deletable))).num_deleted # pyright: ignore[reportCallIssue] + if deletable + else 0 + ) return ComponentDeleteResponse( num_deleted=num_deleted, num_skipped=len(referenced), diff --git a/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py b/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py index 2dda882c1..1dc965e88 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/attachments/router.py @@ -70,7 +70,7 @@ async def download_attachment( @router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) async def delete_attachments(service: AttachmentServiceDep, filter: AttachmentFilter = FilterDepends(AttachmentFilter)): - return await service.delete(filter=filter) + return await service.delete_many(filter=filter) @router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py index c09818149..0af02f0ab 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/repository.py @@ -6,7 +6,6 @@ from beanie.operators import Set from pymongo.asynchronous.client_session import AsyncClientSession from pymongo.errors import DuplicateKeyError -from pymongo.results import DeleteResult from types_aiobotocore_s3 import S3Client from mpcontribs_api.authz import User @@ -30,7 +29,6 @@ merge_contribution_columns, ) from mpcontribs_api.exceptions import ConflictError, NotFoundError, PermissionError -from mpcontribs_api.pagination import CursorParams # Sentinel for "leave unique_value untouched" on patch (distinct from a real None value). _UNSET: Any = object() @@ -92,15 +90,6 @@ async def count_contributions_for_project(self, project_name: str) -> int: """ return await self.document_model.find(self.document_model.project == project_name).count() - async def get_contributions( - self, - filter: ContributionFilter, - pagination: CursorParams | None = None, - fields: frozenset[str] | None = None, - ): - """Query the Contribution collection, scoped to the current user. See ``get_many``.""" - return await self.get_many(pagination=pagination, filter=filter, fields=fields) - async def patch_one( # pyright: ignore[reportIncompatibleMethodOverride] self, identifiers: dict[str, Any], @@ -145,18 +134,7 @@ async def patch_one( # pyright: ignore[reportIncompatibleMethodOverride] identifiers=identifiers, ) from err - async def delete_contributions( - self, - filter: ContributionFilter, - ) -> DeleteResult | None: - """Bulk deletion of Contributions described by the filter. - - Args: - filter (ContribtionFilter): the filter to use to identify contributions to delete - """ - return await filter.filter(self.document_model.find(self._scope)).delete_many() - - async def bulk_update( + async def patch_many( self, filter: ContributionFilter, fields: dict[str, Any], @@ -187,9 +165,10 @@ async def bulk_update( matched=result.matched_count, modified=result.modified_count, projects=sorted(projects) ) - async def get_contribution_ids( + async def list_ids( # pyright: ignore[reportIncompatibleMethodOverride] self, filter: ContributionFilter, + session: AsyncClientSession | None = None, ) -> list[PydanticObjectId]: """Return the ids of scoped rows matching ``filter``. @@ -199,6 +178,7 @@ async def get_contribution_ids( Args: filter: the caller-supplied query, applied on top of the user scope + session: unused; accepted to match the base ``list_ids`` signature """ criteria: list[Any] = [] if self._scope: @@ -207,7 +187,7 @@ async def get_contribution_ids( collection = self.document_model.get_pymongo_collection() return [doc["_id"] async for doc in collection.find(query, {"_id": 1})] - async def insert_many_contributions( + async def insert_many( # pyright: ignore[reportIncompatibleMethodOverride] self, docs: list[Contribution], session: AsyncClientSession | None = None, @@ -220,7 +200,7 @@ async def insert_many_contributions( """ return await self.document_model.insert_many(docs, ordered=False, session=session) - async def insert_contribution( + async def insert_one( # pyright: ignore[reportIncompatibleMethodOverride] self, doc: Contribution, session: AsyncClientSession | None = None, @@ -328,26 +308,23 @@ async def aggregate_project_stats(self, project_id: str) -> ProjectAggregate: agg.columns = finalize_columns(acc) return agg - async def upsert_one( + async def upsert_one( # pyright: ignore[reportIncompatibleMethodOverride] self, identifiers: dict[str, Any], contribution: ContributionIn, + unique_value: Scalar | None = _UNSET, session: AsyncClientSession | None = None, ) -> Contribution: - """Atomically upsert a Contribution by its full identity. - - Relies on the unique index over (project, material_id, chemical_system_id, formula, - unique_value, condition_key) so that concurrent requests targeting the same identity cannot both win the - insert branch. Fields the caller did not set are not touched (partial update). On insert a - fresh Contribution document is written with ``is_public=False``. + """Atomically upsert a single Contribution, keyed by the shape of ``identifiers``. - Args: - identifiers: the identity dict ContributionIn.identity_dict(unique_value) returns - contribution: the input payload to upsert - - Returns: - Contribution: the document as it stands after the operation + Relies on the unique index over the identity fields as the concurrency tiebreaker. + On insert a fresh document is written with ``is_public=False``. """ + if identifiers.keys() == {"id"}: + return await self._upsert_by_id( + identifiers["id"], contribution, None if unique_value is _UNSET else unique_value + ) + project = str(identifiers["project"]) # Make sure the user is allowed to upsert a contribution under the provided project if not self._user.can_write(project): @@ -373,31 +350,13 @@ async def upsert_one( result = await query # pyright: ignore[reportGeneralTypeIssues] # beanie UpdateQuery is awaitable, but pyright doesn't see it return cast(Contribution, result) # upsert always returns the resulting document - async def upsert_contribution_by_id( + async def _upsert_by_id( self, id: str, contribution: ContributionIn, unique_value: Scalar | None = None, - ): - """Upserts a single Contribution by its Mongo ``_id``. - - If a Contribution with this id exists it is updated, otherwise inserted. ``unique_value`` is - server-resolved by the service from the project's ``unique_column`` and stamped on the doc so - the identity index stays correct. Because it is server-owned it is forced into the ``$set`` - (bypassing ``exclude_none``), so re-resolving to ``None`` clears a previously-stored value on - update rather than leaving it stale. - - Args: - id (str): the id of the Contribution to upsert - contribution (ContributionIn): the Contribution to be upserted - unique_value: the resolved identity value to stamp on the document - - Returns: - Contribution: the upserted document - - Raises: - PermissionError: if the caller is not authorized to write to ``contribution.project`` - """ + ) -> Contribution: + """Upsert a single Contribution keyed on its Mongo ``_id`` (see :meth:`upsert_one`).""" if not self._user.can_write(contribution.project): raise PermissionError(f"not authorized to write to project '{contribution.project}'") @@ -412,7 +371,7 @@ async def upsert_contribution_by_id( try: query = self.document_model.find_one( self._scope, - self.document_model.id == self._convert_object_id(id), + self.document_model.id == oid, ).upsert( Set(update_data), on_insert=doc, diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py index 7ac123ea3..a62e42592 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/router.py @@ -58,7 +58,7 @@ async def get_contributions( fields: FieldSelector = None, ): selected = ContributionOut.parse_fields(fields) - return await repo.get_contributions(pagination=pagination, filter=filter, fields=selected) + return await repo.get_many(pagination=pagination, filter=filter, fields=selected) @router.delete("", response_model=DeleteResponse, dependencies=[Depends(require_user)]) @@ -66,10 +66,7 @@ async def delete_contributions( repo: ContributionDep, filter: ContributionFilter = FilterDepends(ContributionFilter), ) -> DeleteResponse: - # The repository returns a raw pymongo DeleteResult (or None when the filter matched nothing); - # convert it to the typed DeleteResponse so the endpoint has a stable, serializable contract. - result = await repo.delete_contributions(filter=filter) - return DeleteResponse.from_delete_result(result) if result is not None else DeleteResponse(num_deleted=0) + return await repo.delete_many(filter=filter) @router.patch("", dependencies=[Depends(require_user)]) @@ -87,7 +84,7 @@ async def patch_contributions( ``data`` deep-merges into each row's stored ``data`` by default Pass ``?replace_data=true`` to overwrite the whole ``data`` dict instead. """ - return await service.bulk_update(filter=filter, update=body, replace_data=replace_data) + return await service.patch_many(filter=filter, update=body, replace_data=replace_data) # TODO: Might want to take contributions in from request body and run model_validate_json on it (much faster) @@ -97,7 +94,7 @@ async def insert_contributions( contributions: list[ContributionIn], ): _enforce_bulk_limit(contributions) - return await service.insert_contributions(contributions=contributions) + return await service.insert_many(contributions=contributions) @router.put("", response_model=BulkWriteSummary[Contribution], dependencies=[Depends(require_user)]) @@ -106,7 +103,7 @@ async def upsert_contributions( contributions: list[ContributionIn], ): _enforce_bulk_limit(contributions) - return await service.upsert_contributions(contributions=contributions) + return await service.upsert_many(contributions=contributions) @router.get("/download/{short_mime}") @@ -158,13 +155,13 @@ async def get_one( @router.put("/{id}", dependencies=[Depends(require_user)]) async def upsert_one(service: ContributionServiceDep, id: str, contribution: ContributionIn): # The by-id upsert resolves the server-owned ``unique_value`` and enforces the unapproved quota - # (see ``ContributionService.upsert_contribution_by_id``), which the generic identity upsert does not. - return await service.upsert_contribution_by_id(id, contribution) + # (see ``ContributionService.upsert_one``), which the generic identity upsert does not. + return await service.upsert_one({"id": id}, contribution) @router.patch("/{id}", dependencies=[Depends(require_user)]) async def patch_one(service: ContributionServiceDep, id: str, update: ContributionPatch, replace_data: bool = False): # The by-id patch re-resolves ``unique_value`` and validates the identifier hierarchy against the - # merged state (see ``ContributionService.patch_contribution_by_id``); ``?replace_data=true`` + # merged state (see ``ContributionService.patch_one``); ``?replace_data=true`` # overwrites the whole ``data`` dict instead of deep-merging. - return await service.patch_contribution_by_id(id, update=update, replace_data=replace_data) + return await service.patch_one({"id": id}, update=update, replace_data=replace_data) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py b/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py index f68773a7f..cc16bb719 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/contributions/service.py @@ -21,6 +21,7 @@ ) from mpcontribs_api.domains._shared.repository import MongoDbRepository from mpcontribs_api.domains._shared.units import QuantityLeaf +from mpcontribs_api.domains.attachments.models import AttachmentFilter from mpcontribs_api.domains.attachments.repository import MongoDbAttachmentRepository from mpcontribs_api.domains.consumers.models import ConsumerSettings from mpcontribs_api.domains.contributions.data import validate_contribution_data @@ -38,9 +39,9 @@ from mpcontribs_api.domains.contributions.repository import MongoDbContributionRepository from mpcontribs_api.domains.projects.models import Column, Stats from mpcontribs_api.domains.projects.repository import MongoDbProjectRepository -from mpcontribs_api.domains.structures.models import Structure +from mpcontribs_api.domains.structures.models import Structure, StructureFilter from mpcontribs_api.domains.structures.repository import MongoDbStructureRepository -from mpcontribs_api.domains.tables.models import Table +from mpcontribs_api.domains.tables.models import Table, TableFilter from mpcontribs_api.domains.tables.repository import MongoDbTableRepository from mpcontribs_api.exceptions import AppError, ConflictError, NotFoundError, PermissionError, ValidationError from mpcontribs_api.pagination import CursorParams @@ -111,20 +112,12 @@ async def get_one( """ return await self._contributions.get_one(self._contributions.coerce_identifiers(identifiers), fields) - async def patch_one(self, identifiers: dict[str, Any], update: ContributionPatch) -> Contribution: - """Partially update the single scoped contribution matching ``identifiers``.""" - return await self._contributions.patch_one(self._contributions.coerce_identifiers(identifiers), update) - - async def upsert_one(self, identifiers: dict[str, Any], contribution: ContributionIn) -> Contribution: - """Upsert the single scoped contribution matching ``identifiers``. See repository ``upsert_one``.""" - return await self._contributions.upsert_one(self._contributions.coerce_identifiers(identifiers), contribution) - async def delete_one(self, identifiers: dict[str, Any]) -> BulkDeleteSummary: """Delete a single contribution and its child components, matching ``identifiers``. Accepts either the bare ``{"id": ...}`` form or the semantic ``{"project", "identifier", "version"}`` set. Cascades component deletion via - :meth:`delete_contributions` so children are never orphaned; a missing target is a zero-count + :meth:`delete_many` so children are never orphaned; a missing target is a zero-count result (mirroring the bulk delete path, which does not 404). """ identifiers = self._contributions.coerce_identifiers(identifiers) @@ -135,7 +128,7 @@ async def delete_one(self, identifiers: dict[str, Any]) -> BulkDeleteSummary: if existing is None: return BulkDeleteSummary(num_deleted=0, num_children_deleted=0) filter = ContributionFilter(id=existing.id) - return await self.delete_contributions(filter) + return await self.delete_many(filter) async def _unapproved_stored_count(self, project_id: str) -> int | None: """Contributions already stored for an unapproved ``project_id``, else ``None``. @@ -151,7 +144,7 @@ async def _unapproved_stored_count(self, project_id: str) -> int | None: # same project can overshoot the cap by a bounded amount. Acceptable for an anti-abuse quota. return await self._contributions.count_contributions_for_project(project_id) - async def insert_contributions( + async def insert_many( self, contributions: list[ContributionIn], ) -> BulkWriteSummary[Contribution]: @@ -534,7 +527,7 @@ async def _insert_no_components( doc.condition_key = item.condition_key docs.append(doc) try: - await self._contributions.insert_many_contributions(docs) + await self._contributions.insert_many(docs) return [(item.index, doc) for item, doc in zip(items, docs, strict=True)], [] except BulkWriteError as exc: write_errors = exc.details.get("writeErrors", []) if exc.details else [] @@ -619,8 +612,8 @@ async def _txn(s: AsyncClientSession) -> list[Contribution]: async def _do_insert_group(self, group: list[PreparedWrite], session: AsyncClientSession) -> list[Contribution]: """Perform the insert of Contributions and their components within a single session.""" template = group[0].contribution - structures = await self._structures.insert_components(template.structures or [], session=session) - tables = await self._tables.insert_components(template.tables or [], session=session) + structures = await self._structures.insert_many(template.structures or [], session=session) + tables = await self._tables.insert_many(template.tables or [], session=session) struct_links = cast(list[Link[Structure]] | None, structures or None) table_links = cast(list[Link[Table]] | None, tables or None) inserted: list[Contribution] = [] @@ -630,11 +623,11 @@ async def _do_insert_group(self, group: list[PreparedWrite], session: AsyncClien doc.condition_key = item.condition_key doc.structures = struct_links doc.tables = table_links - inserted.append(await self._contributions.insert_contribution(doc, session=session)) + inserted.append(await self._contributions.insert_one(doc, session=session)) return inserted # TODO: Allow components to be upserted - async def upsert_contributions(self, contributions: list[ContributionIn]) -> BulkWriteSummary[Contribution]: + async def upsert_many(self, contributions: list[ContributionIn]) -> BulkWriteSummary[Contribution]: """Upsert contributions by their identifying fields, reporting per-item outcomes. Components (structures, tables, attachments) must be managed via their respective @@ -646,7 +639,7 @@ async def upsert_contributions(self, contributions: list[ContributionIn]) -> Bul race past the find branch — the unique index over those fields is the tiebreaker. Concurrent upserts within a batch are bounded by ``settings.mongo.max_concurrent_transactions``. A single item failing does not fail the batch: it is reported in ``failed`` while the others - still commit (mirroring ``insert_contributions``). + still commit (mirroring ``insert_many``). Args: contributions: contributions to upsert; must not include nested components @@ -687,7 +680,7 @@ async def _bounded_upsert(item: PreparedWrite) -> Contribution | BulkFailure: await self.update_project({doc.project for doc in succeeded}) return BulkWriteSummary[Contribution](total=len(contributions), succeeded=succeeded, failed=failed) - async def bulk_update( + async def patch_many( self, filter: ContributionFilter, update: ContributionPatch, @@ -703,7 +696,7 @@ async def bulk_update( - **Fast path** — the patch touches no identity input. A single ``$set`` is applied to every matched row in one ``update_many``. - **Per-row path** — the patch changes an Identifer field (or ``data``). Each matched row is - patched individually via ``patch_contribution_by_id`` and any per-row conflict is reported + patched individually via ``patch_one`` and any per-row conflict is reported in ``failed``. A ``data`` patch deep-merges into each row's stored ``data`` by default (unmentioned leaves @@ -730,7 +723,7 @@ async def bulk_update( touches_identity = bool(ContributionIdentity.model_fields() & fields.keys()) or "data" in fields if not touches_identity: # No identity/unique_value recompute needed, so a uniform $set is safe. - summary = await self._contributions.bulk_update(filter, fields) + summary = await self._contributions.patch_many(filter, fields) await self.update_project(project_ids=summary.projects) return summary @@ -747,7 +740,7 @@ async def _bulk_patch_per_row( Concurrently validates reach contribution's identity, reporting failures in ``BulkUpdateSummary.failed``. """ - ids = await self._contributions.get_contribution_ids(filter) + ids = await self._contributions.list_ids(filter) if not ids: return BulkUpdateSummary(matched=0, modified=0, projects=[]) @@ -756,7 +749,7 @@ async def _bulk_patch_per_row( async def _patch_one(index: int, oid: PydanticObjectId) -> Contribution | BulkFailure: async with sem: try: - return await self.patch_contribution_by_id(str(oid), update, replace_data=replace_data) + return await self.patch_one({"id": str(oid)}, update, replace_data=replace_data) except Exception as exc: logger.info("bulk_patch_item_failed", id=str(oid)) return bulk_failure_from_exception(index, {"id": str(oid)}, exc) @@ -781,11 +774,17 @@ async def _resolve_unique_value(self, project: str, data: dict | None) -> Scalar return None return extract_unique_value(data, unique_column) - async def upsert_contribution_by_id(self, id: str, contribution: ContributionIn) -> Contribution: - """Upsert a single contribution by Mongo id, resolving its server-owned ``unique_value``.""" + async def upsert_one(self, identifiers: dict[str, Any], contribution: ContributionIn) -> Contribution: + """Upsert the single scoped contribution matching ``identifiers``, resolving ``unique_value``. + + The router upserts by ``{"id": ...}``. The server-owned ``unique_value`` (from the project's + ``unique_column``) is resolved and stamped so the identity index stays correct, and an + unapproved project's contribution cap is enforced when the upsert would insert a new document. + """ if not self._user.can_write(contribution.project): raise PermissionError(f"not authorized to write to project '{contribution.project}'") - existing = await self._contributions.get_one(self._contributions.coerce_identifiers({"id": id}), None) + identifiers = self._contributions.coerce_identifiers(identifiers) + existing = await self._contributions.get_one(identifiers, None) if existing is None: stored = await self._unapproved_stored_count(contribution.project) cap = self._limits.max_unapproved_contributions_per_project @@ -796,12 +795,12 @@ async def upsert_contribution_by_id(self, id: str, contribution: ContributionIn) max_allowed=cap, ) unique_value = await self._resolve_unique_value(contribution.project, contribution.data) - return await self._contributions.upsert_contribution_by_id(id, contribution, unique_value) + return await self._contributions.upsert_one(identifiers, contribution, unique_value) - async def patch_contribution_by_id( - self, id: str, update: ContributionPatch, *, replace_data: bool = False + async def patch_one( + self, identifiers: dict[str, Any], update: ContributionPatch, *, replace_data: bool = False ) -> Contribution: - """Patch a single contribution by id. + """Partially update the single scoped contribution matching ``identifiers``. Re-reads the existing document when the patch touches identity inputs, to (a) recompute ``unique_value`` when ``data``/``project`` change and (b) validate the identifier hierarchy @@ -815,19 +814,20 @@ async def patch_contribution_by_id( re-validated strictly (the permissive patch validator allows leaf fragments a full doc may not). ``unique_value`` is resolved against the same post-write view the repository will persist. """ + identifiers = self._contributions.coerce_identifiers(identifiers) set_fields = update.model_dump(exclude_unset=True) touches_unique = "data" in set_fields or "project" in set_fields touches_identity = bool(ContributionIdentity.HIERARCHY_FIELDS & set_fields.keys()) if not touches_unique and not touches_identity: - return await self._contributions.patch_one(self._contributions.coerce_identifiers({"id": id}), update) + return await self._contributions.patch_one(identifiers, update) if replace_data and set_fields.get("data") is not None: # A whole-dict overwrite must satisfy the strict insert-path rules (no leaf fragments). validate_contribution_data(set_fields["data"]) - existing = await self._contributions.get_one(self._contributions.coerce_identifiers({"id": id}), None) + existing = await self._contributions.get_one(identifiers, None) if existing is None or existing.project is None: - raise NotFoundError(f"contribution '{id}' not found") + raise NotFoundError("contribution not found", identifiers=identifiers) if touches_identity: self._validate_identifier_hierarchy_merged( @@ -837,7 +837,7 @@ async def patch_contribution_by_id( existing_formula=existing.formula, ) if not touches_unique: - return await self._contributions.patch_one(self._contributions.coerce_identifiers({"id": id}), update) + return await self._contributions.patch_one(identifiers, update) project = set_fields.get("project") or existing.project # Resolve unique_value against the data the write will actually leave behind: the merged view @@ -850,7 +850,7 @@ async def patch_contribution_by_id( data = QuantityLeaf.merge_data(existing.data, set_fields["data"]) unique_value = await self._resolve_unique_value(project, data) return await self._contributions.patch_one( - self._contributions.coerce_identifiers({"id": id}), + identifiers, update, unique_value=unique_value, replace_data=replace_data, @@ -877,8 +877,16 @@ def _validate_identifier_hierarchy_merged( formula = set_fields["formula"] if "formula" in set_fields else existing_formula ContributionIdentity.check_hierarchy(material_id, chemical_system_id, formula) - async def delete_contributions(self, filter: ContributionFilter) -> BulkDeleteSummary: - """Delete a contribution and all of its child components + # Filter type per child component field, used to delete a batch of child ids through the + # canonical ``delete_many(filter)`` path (id-list deletes go through ``id__in``). + _CHILD_FILTERS: dict[str, type] = { + "structures": StructureFilter, + "tables": TableFilter, + "attachments": AttachmentFilter, + } + + async def delete_many(self, filter: ContributionFilter) -> BulkDeleteSummary: + """Delete contributions matching ``filter`` and all of their child components. Doesn't guarantee complete atomicity, but prevents orphaned children by deleting components first. @@ -896,7 +904,7 @@ async def delete_contributions(self, filter: ContributionFilter) -> BulkDeleteSu # Loop through cursor rather than materialize arbitrary number of Contributions while True: # Since we are deleting everything matching filter, we can continuously get the 1st page - page = await self._contributions.get_contributions( + page = await self._contributions.get_many( pagination=CursorParams(cursor=None, limit=100), filter=filter, ) @@ -906,15 +914,15 @@ async def delete_contributions(self, filter: ContributionFilter) -> BulkDeleteSu for field, repo in self._children.items(): ids = [link.ref.id for c in page.items for link in (getattr(c, field) or [])] if ids: - deleted_components = await repo.delete_by_ids(ids) + deleted_components = await repo.delete_many(self._CHILD_FILTERS[field](id__in=ids)) num_deleted_components += deleted_components.num_deleted if deleted_components else 0 # Delete Contributions in this batch by ID # need to make a new filter so we don't eagerly delete all contributions before their components are deleted - deleted_contribs = await self._contributions.delete_contributions( + deleted_contribs = await self._contributions.delete_many( ContributionFilter(id__in=[cast(PydanticObjectId, c.id) for c in page.items]) ) - num_deleted_contributions += deleted_contribs.deleted_count if deleted_contribs else 0 + num_deleted_contributions += deleted_contribs.num_deleted if deleted_contribs else 0 if not page.items: break await self.update_project(affected_projects) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/initiatives/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/initiatives/repository.py index 8c3d8933b..a2b137cd7 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/initiatives/repository.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/initiatives/repository.py @@ -15,7 +15,6 @@ InitiativePatch, ) from mpcontribs_api.exceptions import ConflictError, NotFoundError, PermissionError, ValidationError -from mpcontribs_api.pagination import CursorParams, Page class InitiativeRepository( @@ -41,16 +40,7 @@ def _build_scope(user: User) -> dict[str, Any]: ors.append({"slug": {"$in": sorted(slugs)}}) return {"$or": ors} - async def get_initiatives( - self, - pagination: CursorParams, - filter: InitiativeFilter, - fields: frozenset[str] | None, - ) -> Page[InitiativeOut]: - """Return a scoped, filtered, paginated page of initiatives. See ``get_many``.""" - return await self.get_many(pagination=pagination, filter=filter, fields=fields) - - async def insert_initiative(self, data: InitiativeIn) -> Initiative: + async def insert_one(self, data: InitiativeIn) -> Initiative: # pyright: ignore[reportIncompatibleMethodOverride] """Create an initiative owned by the caller, enforcing the per-owner unapproved quota. ``owner`` is forced to the caller and the initiative starts unapproved and private. A diff --git a/mpcontribs-api/src/mpcontribs_api/domains/initiatives/router.py b/mpcontribs-api/src/mpcontribs_api/domains/initiatives/router.py index 8a41815ae..9daf7b2dd 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/initiatives/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/initiatives/router.py @@ -26,7 +26,7 @@ async def get_initiatives( ): """Return paginated initiatives matching a filter, scoped to the caller.""" selected = InitiativeOut.parse_fields(fields) - return await repo.get_initiatives(pagination=pagination, filter=filter, fields=selected) + return await repo.get_many(pagination=pagination, filter=filter, fields=selected) @router.get("/{slug}") @@ -52,7 +52,7 @@ async def insert_initiative( Starts unapproved and private. Rejected with 409 if the caller already owns the maximum number of unapproved initiatives, or if the slug is already taken. """ - return await repo.insert_initiative(data=initiative) + return await repo.insert_one(data=initiative) @router.patch("/{slug}", response_model=InitiativeOut, dependencies=[Depends(require_user)]) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/repository.py index 31ed1f129..f36418d6c 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/repository.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/repository.py @@ -18,7 +18,6 @@ ProjectGroupPatch, ) from mpcontribs_api.exceptions import NotFoundError, PermissionError -from mpcontribs_api.pagination import CursorParams, Page class ProjectGroupRepository( @@ -45,24 +44,6 @@ def _build_scope(user: User) -> dict[str, Any]: ors.append({"_id": {"$in": sorted(granted)}}) return {"$or": ors} - async def get_project_groups( - self, - pagination: CursorParams, - filter: ProjectGroupFilter, - fields: frozenset[str] | None, - ) -> Page[ProjectGroupOut]: - """Return paginated project groups matching a filter. - - Args: - pagination (CursorParams): arguments for cursor-based pagination - filter (ProjectGroupFilter): optional filters to select ProjectGroups - fields (frozenset[str] | None): the fields to return to a user - """ - return await self.get_many(pagination=pagination, filter=filter, fields=fields) - - async def insert_project_group(self, project_group: ProjectGroupIn) -> ProjectGroup: - return await self.insert_one(in_resource=project_group) - async def delete_one( self, identifiers: dict[str, Any], session: AsyncClientSession | None = None ) -> DeleteResponse: @@ -81,15 +62,18 @@ async def delete_one( raise PermissionError(required_role="owner-or-admin") return await super().delete_one(identifiers, session=session) - async def delete_project_groups(self, filter: ProjectGroupFilter) -> DeleteResponse: + async def delete_many( # pyright: ignore[reportIncompatibleMethodOverride] + self, filter: ProjectGroupFilter, session: AsyncClientSession | None = None + ) -> DeleteResponse: """Bulk-delete project groups matching ``filter``, restricted to the caller's own. A non-admin's bulk delete is scoped to their own groups (overriding any ``owner`` in the - filter) so it can never remove public groups belonging to others. See ``delete``. + filter) so it can never remove public groups belonging to others. The write is delegated to + the base :meth:`MongoDbRepository.delete_many`. """ if not self._user.is_admin: filter.owner = self._user.username - return await self.delete(filter) + return await super().delete_many(filter, session=session) async def add_project_refs( self, diff --git a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/router.py b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/router.py index 5854180d9..219215630 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/router.py @@ -36,7 +36,7 @@ async def get_project_groups( fields (FieldSelector): the fields to return to a user """ selected = ProjectGroupOut.parse_fields(fields) - return await repo.get_project_groups(pagination=pagination, filter=filter, fields=selected) + return await repo.get_many(pagination=pagination, filter=filter, fields=selected) @router.get("/item") @@ -74,7 +74,7 @@ async def insert_project_group( service (ProjectGroupServiceDep): the project group service we depend on project_group (ProjectGroupIn): the project group to insert """ - return await service.insert(project_group=project_group) + return await service.insert_one(project_group=project_group) @router.patch("/item", response_model=ProjectGroupOut, dependencies=[Depends(require_user)]) @@ -125,7 +125,7 @@ async def delete_project_groups( repo (ProjectGroupDep): the project group repo we depend on filter (ProjectGroupFilter): the query selecting which project groups to delete """ - return await repo.delete_project_groups(filter=filter) + return await repo.delete_many(filter=filter) @router.post("/item/projects", response_model=BulkWriteSummary[str], dependencies=[Depends(require_user)]) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/service.py b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/service.py index be62035cd..87d1b7aae 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/project_groups/service.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/project_groups/service.py @@ -35,7 +35,7 @@ async def _project_exists(self, project_id: ShortStr) -> bool: """Whether a project with ``project_id`` exists and is visible to the caller.""" return await self._projects.get_one({"id": project_id}, fields=frozenset({"id"})) is not None - async def insert(self, project_group: ProjectGroupIn) -> ProjectGroup: + async def insert_one(self, project_group: ProjectGroupIn) -> ProjectGroup: """Insert a new group after verifying every referenced project exists and is visible. Non-admins are set as owner automatically, while admins can specify owners. @@ -46,7 +46,7 @@ async def insert(self, project_group: ProjectGroupIn) -> ProjectGroup: missing = [pid for pid in project_group.projects if not await self._project_exists(pid)] if missing: raise NotFoundError("One or more projects not found or not visible", ids=missing) - return await self._groups.insert_project_group(project_group) + return await self._groups.insert_one(in_resource=project_group) async def get_one(self, identifiers: dict[str, Any], fields: frozenset[str] | None) -> ProjectGroupOut | None: """Return the single group matching ``identifiers`` (``{"name", "owner"}`` or ``{"id"}``).""" diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py index 073d05cd5..de31d1d84 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/repository.py @@ -18,7 +18,6 @@ Stats, ) from mpcontribs_api.exceptions import ConflictError, NotFoundError, PermissionError, ValidationError -from mpcontribs_api.pagination import CursorParams class MongoDbProjectRepository(MongoDbRepository[Project, ProjectIn, ProjectOut, ProjectFilter, ProjectPatch]): @@ -67,15 +66,6 @@ async def _check_num_projects(self, owner: str): num_projects=result, ) - async def get_projects( - self, - filter: ProjectFilter, - pagination: CursorParams, - fields: frozenset[str] | None, - ): - """Query the Project collection, scoped to the current user. See ``get_many``.""" - return await self.get_many(pagination=pagination, filter=filter, fields=fields) - async def unique_columns_by_id(self, ids: list[str]) -> dict[str, str | None]: """Return ``{project_id: unique_column}`` for the given project ids, scoped to the user. @@ -129,11 +119,11 @@ async def set_stats_and_columns(self, updates: dict[str, tuple[Stats, list[Colum ] await self.document_model.get_pymongo_collection().bulk_write(ops, ordered=False) - async def insert_project(self, id: str, project: ProjectIn) -> Project: + async def insert_one(self, id: str, project: ProjectIn) -> Project: # pyright: ignore[reportIncompatibleMethodOverride] """Insert a new project under ``id`` (supplied by the caller), rejecting a duplicate id. Projects carry a meaningful ``ShortStr`` id that is not part of the input body, so — unlike - the generic ``insert_one`` — the id is passed explicitly and stamped onto the document here. + the generic base ``insert_one`` — the id is passed explicitly and stamped onto the document here. """ await self._check_num_projects(project.owner) document = Project.from_input_model(project, id=id) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py b/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py index 4d4c8be8e..09f7c6bb6 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/projects/router.py @@ -37,7 +37,7 @@ async def get_projects( list[ProjectSummary]: a list of smaller project payloads """ selected = ProjectOut.parse_fields(fields) - return await repo.get_projects(filter=filter, pagination=pagination, fields=selected) + return await repo.get_many(filter=filter, pagination=pagination, fields=selected) @router.get("/{id}") diff --git a/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py b/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py index 998021ebc..f9f83c69b 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/structures/router.py @@ -74,12 +74,12 @@ async def insert_structures( service: StructureServiceDep, structures: list[StructureIn], ): - return await service.insert(components=structures) + return await service.insert_many(components=structures) @router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) async def delete_structures(service: StructureServiceDep, filter: StructureFilter = FilterDepends(StructureFilter)): - return await service.delete(filter=filter) + return await service.delete_many(filter=filter) @router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py b/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py index a9d40f243..3f2bdff6d 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/tables/router.py @@ -74,12 +74,12 @@ async def insert_tables( service: TableServiceDep, tables: list[TableIn], ): - return await service.insert(components=tables) + return await service.insert_many(components=tables) @router.delete("", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) async def delete_tables(service: TableServiceDep, filter: TableFilter = FilterDepends(TableFilter)): - return await service.delete(filter=filter) + return await service.delete_many(filter=filter) @router.delete("/{id}", response_model=ComponentDeleteResponse, dependencies=[Depends(require_user)]) diff --git a/mpcontribs-api/tests/integration/db/test_initiatives_repository.py b/mpcontribs-api/tests/integration/db/test_initiatives_repository.py index 5881002c0..d5151ba94 100644 --- a/mpcontribs-api/tests/integration/db/test_initiatives_repository.py +++ b/mpcontribs-api/tests/integration/db/test_initiatives_repository.py @@ -39,7 +39,7 @@ def _collaborator(slug: str, username: str = BOB_EMAIL) -> User: async def _insert(slug: str, owner_user: User = ALICE, name: str = "An Initiative") -> Initiative: - return await _repo(owner_user).insert_initiative(InitiativeIn(slug=slug, name=name)) + return await _repo(owner_user).insert_one(InitiativeIn(slug=slug, name=name)) async def _approve(slug: str) -> Initiative: diff --git a/mpcontribs-api/tests/integration/test_bulk_limits.py b/mpcontribs-api/tests/integration/test_bulk_limits.py index f7e7b0eb9..4cb877734 100644 --- a/mpcontribs-api/tests/integration/test_bulk_limits.py +++ b/mpcontribs-api/tests/integration/test_bulk_limits.py @@ -45,20 +45,20 @@ def test_over_limit_post_returns_422(self, client, contribution_service): r = client.post("/api/v1/contributions", json=body) assert r.status_code == 422 assert r.json()["error"]["code"] == "validation_error" - contribution_service.insert_contributions.assert_not_called() + contribution_service.insert_many.assert_not_called() def test_at_limit_post_passes(self, client, contribution_service): - contribution_service.insert_contributions.return_value = BulkWriteSummary(total=2, succeeded=[], failed=[]) + contribution_service.insert_many.return_value = BulkWriteSummary(total=2, succeeded=[], failed=[]) body = [_valid_contribution_body() for _ in range(2)] r = client.post("/api/v1/contributions", json=body) assert r.status_code == 200 - contribution_service.insert_contributions.assert_called_once() + contribution_service.insert_many.assert_called_once() def test_over_limit_put_returns_422(self, client, contribution_service): body = [_valid_contribution_body() for _ in range(3)] r = client.put("/api/v1/contributions", json=body) assert r.status_code == 422 - contribution_service.upsert_contributions.assert_not_called() + contribution_service.upsert_many.assert_not_called() class TestLimitsEndpoint: diff --git a/mpcontribs-api/tests/integration/test_component_routes.py b/mpcontribs-api/tests/integration/test_component_routes.py index 6452a4c1a..7a17db299 100644 --- a/mpcontribs-api/tests/integration/test_component_routes.py +++ b/mpcontribs-api/tests/integration/test_component_routes.py @@ -101,28 +101,28 @@ def test_content_fields_are_selectable(self, client, structure_service): class TestStructuresDelete: def test_batch_delete_returns_200(self, client, structure_service): - structure_service.delete.return_value = ComponentDeleteResponse(num_deleted=3) + structure_service.delete_many.return_value = ComponentDeleteResponse(num_deleted=3) r = client.delete("/api/v1/structures") assert r.status_code == 200 assert r.json() == {"num_deleted": 3, "num_skipped": 0, "referenced_ids": []} def test_service_delete_called(self, client, structure_service): - structure_service.delete.return_value = ComponentDeleteResponse(num_deleted=0) + structure_service.delete_many.return_value = ComponentDeleteResponse(num_deleted=0) client.delete("/api/v1/structures") - structure_service.delete.assert_awaited_once() + structure_service.delete_many.assert_awaited_once() class TestStructuresInsert: def test_post_route_exists(self, client, structure_service): # Empty body -> handler invoked; service returns a summary-shaped object. - structure_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + structure_service.insert_many.return_value = {"total": 0, "succeeded": [], "failed": []} r = client.post("/api/v1/structures", json=[]) assert r.status_code != 404 def test_post_forwards_to_service(self, client, structure_service): - structure_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + structure_service.insert_many.return_value = {"total": 0, "succeeded": [], "failed": []} client.post("/api/v1/structures", json=[]) - structure_service.insert.assert_awaited_once() + structure_service.insert_many.assert_awaited_once() class TestStructuresByIdRouting: @@ -169,7 +169,7 @@ def test_default_fields_accepted(self, client, table_service): class TestTablesDelete: def test_batch_delete_returns_200(self, client, table_service): - table_service.delete.return_value = ComponentDeleteResponse(num_deleted=2) + table_service.delete_many.return_value = ComponentDeleteResponse(num_deleted=2) assert client.delete("/api/v1/tables").json() == { "num_deleted": 2, "num_skipped": 0, @@ -179,9 +179,9 @@ def test_batch_delete_returns_200(self, client, table_service): class TestTablesInsert: def test_post_forwards_to_service(self, client, table_service): - table_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + table_service.insert_many.return_value = {"total": 0, "succeeded": [], "failed": []} client.post("/api/v1/tables", json=[]) - table_service.insert.assert_awaited_once() + table_service.insert_many.assert_awaited_once() class TestTablesByIdRouting: @@ -222,9 +222,9 @@ def test_delete_by_id_calls_attachment_service(self, client, attachment_service) attachment_service.delete_one.assert_awaited_once() def test_batch_delete_calls_attachment_service(self, client, attachment_service): - attachment_service.delete.return_value = ComponentDeleteResponse(num_deleted=0) + attachment_service.delete_many.return_value = ComponentDeleteResponse(num_deleted=0) client.delete("/api/v1/attachments") - attachment_service.delete.assert_awaited_once() + attachment_service.delete_many.assert_awaited_once() # =========================================================================== @@ -300,12 +300,12 @@ class TestComponentMutationsRequireAuth: def test_structures_post_anon_401(self, client, structure_service): r = client.post("/api/v1/structures", json=[], headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - structure_service.insert.assert_not_called() + structure_service.insert_many.assert_not_called() def test_structures_delete_anon_401(self, client, structure_service): r = client.delete("/api/v1/structures", headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - structure_service.delete.assert_not_called() + structure_service.delete_many.assert_not_called() def test_structure_delete_by_id_anon_401(self, client, structure_service): r = client.delete(f"/api/v1/structures/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) @@ -320,7 +320,7 @@ def test_structure_patch_by_id_anon_401(self, client, structure_service): def test_tables_delete_anon_401(self, client, table_service): r = client.delete("/api/v1/tables", headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - table_service.delete.assert_not_called() + table_service.delete_many.assert_not_called() def test_attachment_delete_by_id_anon_401(self, client, attachment_service): r = client.delete(f"/api/v1/attachments/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) @@ -350,16 +350,16 @@ class TestComponentInsertRequiresWriter: def test_structures_post_non_writer_403(self, client, structure_service): r = client.post("/api/v1/structures", json=[], headers=NON_WRITER_HEADERS) assert r.status_code == 403 - structure_service.insert.assert_not_called() + structure_service.insert_many.assert_not_called() def test_tables_post_non_writer_403(self, client, table_service): r = client.post("/api/v1/tables", json=[], headers=NON_WRITER_HEADERS) assert r.status_code == 403 - table_service.insert.assert_not_called() + table_service.insert_many.assert_not_called() def test_structures_post_writer_allowed(self, client, structure_service): # The default AUTHED_HEADERS identity carries the mp-team group -> writer. - structure_service.insert.return_value = {"total": 0, "succeeded": [], "failed": []} + structure_service.insert_many.return_value = {"total": 0, "succeeded": [], "failed": []} r = client.post("/api/v1/structures", json=[]) assert r.status_code == 200 - structure_service.insert.assert_awaited_once() + structure_service.insert_many.assert_awaited_once() diff --git a/mpcontribs-api/tests/integration/test_contributions.py b/mpcontribs-api/tests/integration/test_contributions.py index 88c476724..645a3d0ce 100644 --- a/mpcontribs-api/tests/integration/test_contributions.py +++ b/mpcontribs-api/tests/integration/test_contributions.py @@ -1,5 +1,6 @@ import pytest +from mpcontribs_api.domains._shared.models import DeleteResponse from mpcontribs_api.domains.contributions.dependencies import get_scoped_contributions from mpcontribs_api.domains.contributions.models import ContributionOut from mpcontribs_api.pagination import Page @@ -34,48 +35,48 @@ def contribution_repo(test_app, mock_contribution_repo): class TestListContributions: def test_empty_page_returns_200(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/contributions", headers=AUTHED_HEADERS) assert r.status_code == 200 def test_response_has_page_shape(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) body = client.get("/api/v1/contributions", headers=AUTHED_HEADERS).json() assert "items" in body assert "next_cursor" in body def test_items_in_response(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[SAMPLE_CONTRIBUTION], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[SAMPLE_CONTRIBUTION], next_cursor=None) body = client.get("/api/v1/contributions", headers=AUTHED_HEADERS).json() assert len(body["items"]) == 1 def test_repo_called_with_pagination(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/contributions", params={"limit": 10}, headers=AUTHED_HEADERS) - _, kwargs = contribution_repo.get_contributions.call_args + _, kwargs = contribution_repo.get_many.call_args assert kwargs["pagination"].limit == 10 def test_fields_forwarded(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/contributions", params={"_fields": ["formula"]}, headers=AUTHED_HEADERS) - _, kwargs = contribution_repo.get_contributions.call_args + _, kwargs = contribution_repo.get_many.call_args assert kwargs["fields"] is not None assert "formula" in kwargs["fields"] def test_invalid_fields_returns_422(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/contributions", params={"_fields": "bad_field"}, headers=AUTHED_HEADERS) assert r.status_code == 422 def test_anonymous_can_list(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/contributions", headers=ANON_HEADERS) assert r.status_code == 200 def test_filter_param_forwarded_to_repo(self, client, contribution_repo): - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/contributions", params={"formula": "Fe2O3"}, headers=AUTHED_HEADERS) - contribution_repo.get_contributions.assert_called_once() + contribution_repo.get_many.assert_called_once() # --------------------------------------------------------------------------- @@ -85,19 +86,19 @@ def test_filter_param_forwarded_to_repo(self, client, contribution_repo): class TestDeleteContributions: def test_batch_delete_returns_200(self, client, contribution_repo): - contribution_repo.delete_contributions.return_value = None + contribution_repo.delete_many.return_value = DeleteResponse(num_deleted=0) r = client.delete("/api/v1/contributions", headers=AUTHED_HEADERS) assert r.status_code == 200 def test_repo_delete_called(self, client, contribution_repo): - contribution_repo.delete_contributions.return_value = None + contribution_repo.delete_many.return_value = None client.delete("/api/v1/contributions", headers=AUTHED_HEADERS) - contribution_repo.delete_contributions.assert_called_once() + contribution_repo.delete_many.assert_called_once() def test_filter_forwarded_to_repo(self, client, contribution_repo): - contribution_repo.delete_contributions.return_value = None + contribution_repo.delete_many.return_value = None client.delete("/api/v1/contributions", params={"is_public": "true"}, headers=AUTHED_HEADERS) - _, kwargs = contribution_repo.delete_contributions.call_args + _, kwargs = contribution_repo.delete_many.call_args assert kwargs["filter"] is not None @@ -110,14 +111,14 @@ class TestStubEndpoints: """These endpoints are stubs in the repo but the routes must be wired.""" def test_post_contributions_route_exists(self, client, contribution_repo): - contribution_repo.insert_contributions.return_value = None + contribution_repo.insert_many.return_value = None r = client.post("/api/v1/contributions", json=[], headers=AUTHED_HEADERS) # Should reach the route (not 404/405) even if the handler is a stub assert r.status_code != 404 assert r.status_code != 405 def test_put_contributions_route_exists(self, client, contribution_repo): - contribution_repo.upsert_contributions.return_value = None + contribution_repo.upsert_many.return_value = None r = client.put("/api/v1/contributions", json=[], headers=AUTHED_HEADERS) assert r.status_code != 404 assert r.status_code != 405 diff --git a/mpcontribs-api/tests/integration/test_contributions_routes.py b/mpcontribs-api/tests/integration/test_contributions_routes.py index 4df2d4c38..9e8060fcb 100644 --- a/mpcontribs-api/tests/integration/test_contributions_routes.py +++ b/mpcontribs-api/tests/integration/test_contributions_routes.py @@ -66,25 +66,25 @@ def _valid_contribution_body(**overrides) -> dict: class TestInsertContributions: def test_empty_list_returns_200(self, client, contribution_service): - contribution_service.insert_contributions.return_value = BulkWriteSummary( + contribution_service.insert_many.return_value = BulkWriteSummary( total=0, succeeded=[], failed=[] ) r = client.post("/api/v1/contributions", json=[]) assert r.status_code == 200 def test_response_has_summary_shape(self, client, contribution_service): - contribution_service.insert_contributions.return_value = BulkWriteSummary( + contribution_service.insert_many.return_value = BulkWriteSummary( total=0, succeeded=[], failed=[] ) body = client.post("/api/v1/contributions", json=[]).json() assert set(body) == {"total", "succeeded", "failed"} def test_service_receives_parsed_contributions(self, client, contribution_service): - contribution_service.insert_contributions.return_value = BulkWriteSummary( + contribution_service.insert_many.return_value = BulkWriteSummary( total=1, succeeded=[], failed=[] ) client.post("/api/v1/contributions", json=[_valid_contribution_body()]) - contributions = contribution_service.insert_contributions.call_args.kwargs["contributions"] + contributions = contribution_service.insert_many.call_args.kwargs["contributions"] assert len(contributions) == 1 assert contributions[0].project == "test-project" @@ -95,7 +95,7 @@ def test_malformed_body_returns_422(self, client, contribution_service): json=[{"_id": str(PydanticObjectId()), "project": "p", "material_id": "mp-1"}], ) assert r.status_code == 422 - contribution_service.insert_contributions.assert_not_called() + contribution_service.insert_many.assert_not_called() def test_non_list_body_returns_422(self, client, contribution_service): r = client.post("/api/v1/contributions", json=_valid_contribution_body()) @@ -109,15 +109,15 @@ def test_non_list_body_returns_422(self, client, contribution_service): class TestUpsertContributions: def test_empty_list_returns_200(self, client, contribution_service): - contribution_service.upsert_contributions.return_value = BulkWriteSummary(total=0, succeeded=[], failed=[]) + contribution_service.upsert_many.return_value = BulkWriteSummary(total=0, succeeded=[], failed=[]) r = client.put("/api/v1/contributions", json=[]) assert r.status_code == 200 assert set(r.json()) == {"total", "succeeded", "failed"} def test_service_receives_parsed_contributions(self, client, contribution_service): - contribution_service.upsert_contributions.return_value = BulkWriteSummary(total=1, succeeded=[], failed=[]) + contribution_service.upsert_many.return_value = BulkWriteSummary(total=1, succeeded=[], failed=[]) client.put("/api/v1/contributions", json=[_valid_contribution_body()]) - contributions = contribution_service.upsert_contributions.call_args.kwargs["contributions"] + contributions = contribution_service.upsert_many.call_args.kwargs["contributions"] assert contributions[0].material_id == "mp-1234" def test_malformed_body_returns_422(self, client, contribution_service): @@ -126,7 +126,7 @@ def test_malformed_body_returns_422(self, client, contribution_service): json=[{"_id": str(PydanticObjectId()), "project": "p"}], ) assert r.status_code == 422 - contribution_service.upsert_contributions.assert_not_called() + contribution_service.upsert_many.assert_not_called() # =========================================================================== @@ -142,12 +142,12 @@ def test_get_by_id_conventional_path(self, client, contribution_service): assert client.get(f"/api/v1/contributions/{PydanticObjectId()}").status_code == 200 def test_patch_by_id_conventional_path(self, client, contribution_service): - contribution_service.patch_contribution_by_id.return_value = SAMPLE_OUT + contribution_service.patch_one.return_value = SAMPLE_OUT r = client.patch(f"/api/v1/contributions/{PydanticObjectId()}", json={"formula": "H2O"}) assert r.status_code == 200 def test_put_by_id_conventional_path(self, client, contribution_service): - contribution_service.upsert_contribution_by_id.return_value = SAMPLE_OUT + contribution_service.upsert_one.return_value = SAMPLE_OUT r = client.put(f"/api/v1/contributions/{PydanticObjectId()}", json=_valid_contribution_body()) assert r.status_code == 200 @@ -252,7 +252,7 @@ def test_repo_error_surfaces_as_uniform_json(self, client, contribution_repo): class TestBulkUpdateContributions: def test_publish_returns_200_and_summary(self, client, contribution_service): - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=3, modified=2, projects=["mp-team"]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=3, modified=2, projects=["mp-team"]) r = client.patch("/api/v1/contributions", json={"is_public": True}) assert r.status_code == 200 assert r.json() == {"matched": 3, "modified": 2, "projects": ["mp-team"], "failed": []} @@ -260,57 +260,57 @@ def test_publish_returns_200_and_summary(self, client, contribution_service): def test_forwards_update_and_filter(self, client, contribution_service): from mpcontribs_api.domains.contributions.models import ContributionFilter - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) client.patch("/api/v1/contributions?project=mp-team", json={"is_public": True}) - contribution_service.bulk_update.assert_awaited_once() - kwargs = contribution_service.bulk_update.call_args.kwargs + contribution_service.patch_many.assert_awaited_once() + kwargs = contribution_service.patch_many.call_args.kwargs assert kwargs["update"].is_public is True assert isinstance(kwargs["filter"], ContributionFilter) def test_forwards_full_patch_body(self, client, contribution_service): # The bulk patch now accepts the same fields as the single-item patch (not just is_public). - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=1, modified=1, projects=["mp-team"]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=1, modified=1, projects=["mp-team"]) r = client.patch("/api/v1/contributions", json={"formula": "Fe2O3", "data": {"y": 9.0}}) assert r.status_code == 200 - update = contribution_service.bulk_update.call_args.kwargs["update"] + update = contribution_service.patch_many.call_args.kwargs["update"] assert update.formula == "Fe2O3" assert update.data == {"y": 9.0} def test_empty_patch_is_accepted_as_noop(self, client, contribution_service): # An empty patch is a valid no-op (parity with the single-item patch), no longer a 422. - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) r = client.patch("/api/v1/contributions", json={}) assert r.status_code == 200 - contribution_service.bulk_update.assert_awaited_once() + contribution_service.patch_many.assert_awaited_once() def test_replace_data_query_param_forwarded(self, client, contribution_service): # ?replace_data=true opts a data patch out of the additive-merge default (whole-dict overwrite). - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) client.patch("/api/v1/contributions?replace_data=true", json={"data": {"y": 9.0}}) - assert contribution_service.bulk_update.call_args.kwargs["replace_data"] is True + assert contribution_service.patch_many.call_args.kwargs["replace_data"] is True def test_replace_data_defaults_to_false(self, client, contribution_service): # Omitting the flag keeps the additive-merge default. - contribution_service.bulk_update.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) + contribution_service.patch_many.return_value = BulkUpdateSummary(matched=0, modified=0, projects=[]) client.patch("/api/v1/contributions", json={"data": {"y": 9.0}}) - assert contribution_service.bulk_update.call_args.kwargs["replace_data"] is False + assert contribution_service.patch_many.call_args.kwargs["replace_data"] is False def test_patch_by_id_forwards_is_public(self, client, contribution_service): # The single-contribution publish path: {"is_public": true} reaches the service patch. - contribution_service.patch_contribution_by_id.return_value = SAMPLE_OUT + contribution_service.patch_one.return_value = SAMPLE_OUT r = client.patch(f"/api/v1/contributions/{PydanticObjectId()}", json={"is_public": True}) assert r.status_code == 200 - update = contribution_service.patch_contribution_by_id.call_args.kwargs["update"] + update = contribution_service.patch_one.call_args.kwargs["update"] assert update.is_public is True def test_patch_by_id_forwards_replace_data(self, client, contribution_service): # The single-item path exposes the same overwrite opt-out as the bulk path. - contribution_service.patch_contribution_by_id.return_value = SAMPLE_OUT + contribution_service.patch_one.return_value = SAMPLE_OUT r = client.patch( f"/api/v1/contributions/{PydanticObjectId()}?replace_data=true", json={"data": {"y": 9.0}} ) assert r.status_code == 200 - assert contribution_service.patch_contribution_by_id.call_args.kwargs["replace_data"] is True + assert contribution_service.patch_one.call_args.kwargs["replace_data"] is True class TestContributionMutationsRequireAuth: @@ -318,22 +318,22 @@ def test_post_anon_401(self, client, contribution_service): r = client.post("/api/v1/contributions", json=[], headers=FORCE_ANON_HEADERS) assert r.status_code == 401 assert r.json()["error"]["code"] == "authentication_error" - contribution_service.insert_contributions.assert_not_called() + contribution_service.insert_many.assert_not_called() def test_put_collection_anon_401(self, client, contribution_service): r = client.put("/api/v1/contributions", json=[], headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - contribution_service.upsert_contributions.assert_not_called() + contribution_service.upsert_many.assert_not_called() def test_delete_collection_anon_401(self, client, contribution_repo): r = client.delete("/api/v1/contributions", headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - contribution_repo.delete_contributions.assert_not_called() + contribution_repo.delete_many.assert_not_called() def test_patch_collection_anon_401(self, client, contribution_service): r = client.patch("/api/v1/contributions", json={"is_public": True}, headers=FORCE_ANON_HEADERS) assert r.status_code == 401 - contribution_service.bulk_update.assert_not_called() + contribution_service.patch_many.assert_not_called() def test_delete_by_id_anon_401(self, client, contribution_service): r = client.delete(f"/api/v1/contributions/{PydanticObjectId()}", headers=FORCE_ANON_HEADERS) @@ -346,18 +346,18 @@ def test_put_by_id_anon_401(self, client, contribution_service): headers=FORCE_ANON_HEADERS, ) assert r.status_code == 401 - contribution_service.upsert_contribution_by_id.assert_not_called() + contribution_service.upsert_one.assert_not_called() def test_patch_by_id_anon_401(self, client, contribution_service): r = client.patch( f"/api/v1/contributions/{PydanticObjectId()}", json={"formula": "H2O"}, headers=FORCE_ANON_HEADERS ) assert r.status_code == 401 - contribution_service.patch_contribution_by_id.assert_not_called() + contribution_service.patch_one.assert_not_called() def test_get_collection_still_open_to_anon(self, client, contribution_repo): from mpcontribs_api.pagination import Page - contribution_repo.get_contributions.return_value = Page(items=[], next_cursor=None) + contribution_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/contributions", headers=FORCE_ANON_HEADERS) assert r.status_code == 200 diff --git a/mpcontribs-api/tests/integration/test_initiatives.py b/mpcontribs-api/tests/integration/test_initiatives.py index 516bb5b46..c55941841 100644 --- a/mpcontribs-api/tests/integration/test_initiatives.py +++ b/mpcontribs-api/tests/integration/test_initiatives.py @@ -40,7 +40,7 @@ def _stored(**overrides): class TestInsert: def test_returns_201_and_echoes_id(self, client, initiative_repo): - initiative_repo.insert_initiative.return_value = _stored() + initiative_repo.insert_one.return_value = _stored() r = client.post( "/api/v1/initiatives", json={"slug": "battery-genome", "name": "Battery Genome"}, @@ -73,7 +73,7 @@ def test_invalid_slug_returns_422(self, client, initiative_repo): class TestGet: def test_list_returns_200(self, client, initiative_repo): - initiative_repo.get_initiatives.return_value = Page(items=[], next_cursor=None) + initiative_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/initiatives", headers=AUTHED_HEADERS) assert r.status_code == 200 diff --git a/mpcontribs-api/tests/integration/test_project_groups.py b/mpcontribs-api/tests/integration/test_project_groups.py index e12ff146f..18c216da7 100644 --- a/mpcontribs-api/tests/integration/test_project_groups.py +++ b/mpcontribs-api/tests/integration/test_project_groups.py @@ -54,17 +54,17 @@ def _inserted(self, **overrides): return SimpleNamespace(**attrs) def test_returns_201(self, client, group_service): - group_service.insert.return_value = self._inserted() + group_service.insert_one.return_value = self._inserted() r = client.post("/api/v1/project_groups", json=self._body(), headers=AUTHED_HEADERS) assert r.status_code == 201 def test_response_includes_generated_id(self, client, group_service): - group_service.insert.return_value = self._inserted() + group_service.insert_one.return_value = self._inserted() body = client.post("/api/v1/project_groups", json=self._body(), headers=AUTHED_HEADERS).json() assert body["id"] == SAMPLE_OID def test_response_echoes_full_document(self, client, group_service): - group_service.insert.return_value = self._inserted(name="echo-group", is_public=True) + group_service.insert_one.return_value = self._inserted(name="echo-group", is_public=True) body = client.post( "/api/v1/project_groups", json=self._body(name="echo-group", is_public=True), diff --git a/mpcontribs-api/tests/integration/test_projects.py b/mpcontribs-api/tests/integration/test_projects.py index 48c74a382..1172afb3e 100644 --- a/mpcontribs-api/tests/integration/test_projects.py +++ b/mpcontribs-api/tests/integration/test_projects.py @@ -55,18 +55,18 @@ def project_service(test_app): class TestListProjects: def test_empty_page_returns_200(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/projects", headers=AUTHED_HEADERS) assert r.status_code == 200 def test_response_has_items_and_cursor(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() assert "items" in body assert "next_cursor" in body def test_items_returned_in_response(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=None) + project_repo.get_many.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=None) body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() assert len(body["items"]) == 1 @@ -74,29 +74,29 @@ def test_next_cursor_set_when_more_pages(self, client, project_repo): from mpcontribs_api.pagination import encode_cursor cursor = encode_cursor("mp-sample") - project_repo.get_projects.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=cursor) + project_repo.get_many.return_value = Page(items=[SAMPLE_PROJECT], next_cursor=cursor) body = client.get("/api/v1/projects", headers=AUTHED_HEADERS).json() assert body["next_cursor"] == cursor def test_repo_get_project_called(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", headers=AUTHED_HEADERS) - project_repo.get_projects.assert_called_once() + project_repo.get_many.assert_called_once() def test_anonymous_user_reaches_route(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/projects", headers=ANON_HEADERS) assert r.status_code == 200 def test_invalid_fields_param_returns_422(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) r = client.get("/api/v1/projects", params={"_fields": "nonexistent_field"}, headers=AUTHED_HEADERS) assert r.status_code == 422 def test_limit_param_forwarded(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", params={"limit": 5}, headers=AUTHED_HEADERS) - _, kwargs = project_repo.get_projects.call_args + _, kwargs = project_repo.get_many.call_args assert kwargs["pagination"].limit == 5 def test_limit_above_max_returns_422(self, client, project_repo): @@ -104,9 +104,9 @@ def test_limit_above_max_returns_422(self, client, project_repo): assert r.status_code == 422 def test_valid_fields_param_forwarded(self, client, project_repo): - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", params=[("_fields", "title"), ("_fields", "authors")], headers=AUTHED_HEADERS) - _, kwargs = project_repo.get_projects.call_args + _, kwargs = project_repo.get_many.call_args assert kwargs["fields"] is not None assert "title" in kwargs["fields"] @@ -119,23 +119,23 @@ def test_valid_fields_param_forwarded(self, client, project_repo): class TestFieldSelectionSemantics: def test_omitted_fields_forwards_route_defaults(self, client, project_repo): # No _fields query param -> the route's default_fields() (identity + summary columns). - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", headers=AUTHED_HEADERS) - _, kwargs = project_repo.get_projects.call_args + _, kwargs = project_repo.get_many.call_args assert kwargs["fields"] == frozenset(ProjectOut.default_fields()) def test_empty_fields_forwards_identity_only(self, client, project_repo): # `?_fields=` (present but empty) -> only the identity field, the cheap "just ids" call. - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", params={"_fields": ""}, headers=AUTHED_HEADERS) - _, kwargs = project_repo.get_projects.call_args + _, kwargs = project_repo.get_many.call_args assert kwargs["fields"] == frozenset({"id"}) def test_all_sentinel_forwards_none(self, client, project_repo): # `?_fields=_all` -> None, i.e. project every field. - project_repo.get_projects.return_value = Page(items=[], next_cursor=None) + project_repo.get_many.return_value = Page(items=[], next_cursor=None) client.get("/api/v1/projects", params={"_fields": "_all"}, headers=AUTHED_HEADERS) - _, kwargs = project_repo.get_projects.call_args + _, kwargs = project_repo.get_many.call_args assert kwargs["fields"] is None def test_empty_fields_detail_forwards_identity_only(self, client, project_service): diff --git a/mpcontribs-api/tests/unit/domains/test_component_service.py b/mpcontribs-api/tests/unit/domains/test_component_service.py index c9f981e37..0333f9f28 100644 --- a/mpcontribs-api/tests/unit/domains/test_component_service.py +++ b/mpcontribs-api/tests/unit/domains/test_component_service.py @@ -35,7 +35,7 @@ def _make_service( """ components = AsyncMock(name="components") components.list_ids = AsyncMock(return_value=candidate_ids) - components.delete_by_ids = AsyncMock(side_effect=lambda ids: DeleteResponse(num_deleted=len(ids))) + components.delete_many = AsyncMock(side_effect=lambda filter: DeleteResponse(num_deleted=len(filter.id__in))) components.delete_one = AsyncMock(return_value=DeleteResponse(num_deleted=1)) components.coerce_identifiers = MagicMock(side_effect=_coerce_identifiers) @@ -62,33 +62,33 @@ async def test_delete_reachable_and_unreferenced_deletes_all(): candidate_ids=[a, b], reachable={a, b}, referenced=set() ) - result = await svc.delete(AttachmentFilter()) + result = await svc.delete_many(AttachmentFilter()) assert isinstance(result, ComponentDeleteResponse) assert result.num_deleted == 2 assert result.num_skipped == 0 assert result.referenced_ids == [] - components.delete_by_ids.assert_awaited_once() - assert set(components.delete_by_ids.await_args.args[0]) == {a, b} + components.delete_many.assert_awaited_once() + assert set(components.delete_many.await_args.args[0].id__in) == {a, b} async def test_delete_skips_globally_referenced(): a, b = _oid(), _oid() svc, components, _ = _make_service(candidate_ids=[a, b], reachable={a, b}, referenced={b}) - result = await svc.delete(AttachmentFilter()) + result = await svc.delete_many(AttachmentFilter()) assert result.num_deleted == 1 assert result.num_skipped == 1 assert result.referenced_ids == [b] - assert components.delete_by_ids.await_args.args[0] == [a] + assert components.delete_many.await_args.args[0].id__in == [a] async def test_delete_not_reachable_deletes_nothing(): a = _oid() svc, components, contributions = _make_service(candidate_ids=[a], reachable=set(), referenced={a}) - result = await svc.delete(AttachmentFilter()) + result = await svc.delete_many(AttachmentFilter()) assert result.num_deleted == 0 assert result.num_skipped == 0 @@ -101,7 +101,7 @@ async def test_delete_not_reachable_deletes_nothing(): async def test_delete_empty_candidate_set(): svc, components, _ = _make_service(candidate_ids=[], reachable=set(), referenced=set()) - result = await svc.delete(AttachmentFilter()) + result = await svc.delete_many(AttachmentFilter()) assert result.num_deleted == 0 components.delete_by_ids.assert_not_awaited() @@ -111,7 +111,7 @@ async def test_delete_checks_scoped_before_global(): a = _oid() svc, _, contributions = _make_service(candidate_ids=[a], reachable={a}, referenced=set()) - await svc.delete(AttachmentFilter()) + await svc.delete_many(AttachmentFilter()) scoped_flags = [c.kwargs["scoped"] for c in contributions.referenced_component_ids.await_args_list] assert scoped_flags == [True, False] diff --git a/mpcontribs-api/tests/unit/domains/test_contribution_service.py b/mpcontribs-api/tests/unit/domains/test_contribution_service.py index b62d99aa0..e02cf5c64 100644 --- a/mpcontribs-api/tests/unit/domains/test_contribution_service.py +++ b/mpcontribs-api/tests/unit/domains/test_contribution_service.py @@ -256,7 +256,7 @@ def _fake_attachment() -> Attachment: # --------------------------------------------------------------------------- -# insert_contributions — pre-checks (cheap, no DB) +# insert_many — pre-checks (cheap, no DB) # --------------------------------------------------------------------------- @@ -264,55 +264,55 @@ class TestInsertContributionsPreChecks: async def test_empty_batch_returns_empty_summary_no_db(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() - summary = await svc.insert_contributions([]) + summary = await svc.insert_many([]) assert summary.total == 0 assert summary.succeeded == [] assert summary.failed == [] - contrib_repo.insert_many_contributions.assert_not_called() - contrib_repo.insert_contribution.assert_not_called() + contrib_repo.insert_many.assert_not_called() + contrib_repo.insert_one.assert_not_called() client.start_session.assert_not_called() async def test_duplicate_identity_in_one_batch_conflicts_later_item(self): """A repeated identity within one batch does not fail the whole request: the first occurrence is inserted; later intra-batch duplicates are per-item conflict failures.""" svc, contrib_repo, _, _, _, client = _make_service() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [ _contrib_in(project="prj", identifier="dup"), _contrib_in(project="prj", identifier="dup"), ] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert summary.total == 2 assert len(summary.succeeded) == 1 assert [f.index for f in summary.failed] == [1] assert summary.failed[0].error_code == "conflict" # Index 0 still reached Mongo (one doc inserted) - assert len(contrib_repo.insert_many_contributions.call_args[0][0]) == 1 + assert len(contrib_repo.insert_many.call_args[0][0]) == 1 async def test_oversize_contribution_goes_to_failures_without_db(self): settings = _make_mongo_settings(max_components_per_contribution=1) svc, contrib_repo, struct_repo, _, _, client = _make_service(settings=settings) - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None good = _contrib_in(identifier="ok") oversize = _contrib_in(identifier="big", structures=[_structure_in(), _structure_in()]) - summary = await svc.insert_contributions([good, oversize]) + summary = await svc.insert_many([good, oversize]) assert summary.total == 2 assert len(summary.failed) == 1 assert summary.failed[0].index == 1 assert summary.failed[0].error_code == "validation_error" # Oversize never reached the component repo - struct_repo.insert_components.assert_not_called() + struct_repo.insert_many.assert_not_called() # And the in-pool contribution did go through the no-component fast path - contrib_repo.insert_many_contributions.assert_called_once() + contrib_repo.insert_many.assert_called_once() # --------------------------------------------------------------------------- -# insert_contributions — unapproved-contribution quota +# insert_many — unapproved-contribution quota # --------------------------------------------------------------------------- @@ -332,44 +332,44 @@ class TestInsertContributionsUnapprovedQuota: async def test_unapproved_project_at_capacity_fails_all(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 2) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 5 # already over cap svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - summary = await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) + summary = await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) assert summary.total == 3 assert [f.index for f in summary.failed] == [0, 1, 2] assert all(f.error_code == "permission_denied" for f in summary.failed) assert summary.succeeded == [] - contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_many.assert_not_called() async def test_batch_trimmed_to_remaining_capacity(self, monkeypatch): # cap 5, 3 already stored -> remaining = 5 - 3 = 2 slots for this batch monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 5) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 3 svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - summary = await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(4)]) + summary = await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(4)]) assert summary.total == 4 assert len(summary.succeeded) == 2 assert [f.index for f in summary.failed] == [2, 3] # Only the two accepted contributions reached the database - inserted = contrib_repo.insert_many_contributions.call_args[0][0] + inserted = contrib_repo.insert_many.call_args[0][0] assert len(inserted) == 2 async def test_batch_may_fill_project_to_exactly_cap(self, monkeypatch): # cap 3, 2 stored -> exactly one slot; the batch fills the project to the cap, not past it. monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 3) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 2 svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - summary = await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(2)]) + summary = await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(2)]) assert summary.total == 2 assert len(summary.succeeded) == 1 @@ -379,23 +379,23 @@ async def test_batch_at_exactly_cap_rejects_all_new(self, monkeypatch): # cap 3, 3 stored -> zero remaining slots; a project already at the cap admits nothing new. monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 3) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 3 svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - summary = await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(2)]) + summary = await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(2)]) assert [f.index for f in summary.failed] == [0, 1] assert summary.succeeded == [] - contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_many.assert_not_called() async def test_approved_project_is_unlimited(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 1) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None svc, *_ = _make_service(contributions=contrib_repo, projects=_approved_projects_repo()) - summary = await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) + summary = await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) assert len(summary.succeeded) == 3 assert summary.failed == [] @@ -405,7 +405,7 @@ async def test_approved_project_is_unlimited(self, monkeypatch): async def test_quota_evaluated_per_project(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 2) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 99 # only consulted for the unapproved one projects = _projects_repo_by_approval({"ok": True, "bad": False}) svc, *_ = _make_service(contributions=contrib_repo, projects=projects) @@ -415,22 +415,22 @@ async def test_quota_evaluated_per_project(self, monkeypatch): _contrib_in(project="bad", identifier="b"), _contrib_in(project="ok", identifier="c"), ] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert [f.index for f in summary.failed] == [1] assert len(summary.succeeded) == 2 - inserted_ids = {d.material_id for d in contrib_repo.insert_many_contributions.call_args[0][0]} + inserted_ids = {d.material_id for d in contrib_repo.insert_many.call_args[0][0]} assert inserted_ids == {_mp_id_for("a"), _mp_id_for("c")} async def test_breach_emits_structured_audit_log(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 2) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.count_contributions_for_project.return_value = 5 svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) with patch.object(service_module.logger, "warning") as warn: - await svc.insert_contributions([_contrib_in(project="p", identifier=f"mp-{i}") for i in range(3)]) + await svc.insert_many([_contrib_in(project="p", identifier=f"mp-{i}") for i in range(3)]) warn.assert_called_once() event, kwargs = warn.call_args.args[0], warn.call_args.kwargs @@ -447,17 +447,17 @@ async def test_breach_emits_structured_audit_log(self, monkeypatch): async def test_approved_project_emits_no_audit_log(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 1) contrib_repo = AsyncMock() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None svc, *_ = _make_service(contributions=contrib_repo, projects=_approved_projects_repo()) with patch.object(service_module.logger, "warning") as warn: - await svc.insert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) + await svc.insert_many([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) warn.assert_not_called() # --------------------------------------------------------------------------- -# upsert_contributions — unapproved-contribution quota (new docs only) +# upsert_many — unapproved-contribution quota (new docs only) # --------------------------------------------------------------------------- @@ -481,7 +481,7 @@ async def test_only_new_documents_count_against_cap(self, monkeypatch): _contrib_in(identifier="b"), # new -> consumes the one remaining slot _contrib_in(identifier="c"), # new -> over cap, rejected ] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert len(summary.succeeded) == 2 assert [f.index for f in summary.failed] == [2] @@ -503,7 +503,7 @@ async def test_pure_updates_are_never_capped(self, monkeypatch): for i in range(3) } - summary = await svc.upsert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) + summary = await svc.upsert_many([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) assert len(summary.succeeded) == 3 assert summary.failed == [] @@ -515,7 +515,7 @@ async def test_approved_project_skips_quota(self, monkeypatch): contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution, project="proj") svc, *_ = _make_service(contributions=contrib_repo, projects=_approved_projects_repo()) - summary = await svc.upsert_contributions([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) + summary = await svc.upsert_many([_contrib_in(identifier=f"mp-{i}") for i in range(3)]) assert len(summary.succeeded) == 3 assert summary.failed == [] @@ -524,7 +524,7 @@ async def test_approved_project_skips_quota(self, monkeypatch): # --------------------------------------------------------------------------- -# upsert_contribution_by_id — single-record quota +# upsert_one — single-record quota # --------------------------------------------------------------------------- @@ -534,12 +534,12 @@ async def test_update_existing_allowed_even_over_cap(self, monkeypatch): contrib_repo = AsyncMock() contrib_repo.get_one.return_value = MagicMock(spec=Contribution) # id exists -> update contrib_repo.count_contributions_for_project.return_value = 99 - contrib_repo.upsert_contribution_by_id.return_value = MagicMock(spec=Contribution) + contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution) svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - await svc.upsert_contribution_by_id("someid", _contrib_in()) + await svc.upsert_one("someid", _contrib_in()) - contrib_repo.upsert_contribution_by_id.assert_called_once() + contrib_repo.upsert_one.assert_called_once() contrib_repo.count_contributions_for_project.assert_not_called() async def test_new_insert_over_cap_rejected(self, monkeypatch): @@ -550,21 +550,21 @@ async def test_new_insert_over_cap_rejected(self, monkeypatch): svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) with pytest.raises(PermissionError): - await svc.upsert_contribution_by_id("someid", _contrib_in()) + await svc.upsert_one("someid", _contrib_in()) - contrib_repo.upsert_contribution_by_id.assert_not_called() + contrib_repo.upsert_one.assert_not_called() async def test_new_insert_under_cap_allowed(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 5) contrib_repo = AsyncMock() contrib_repo.get_one.return_value = None contrib_repo.count_contributions_for_project.return_value = 1 - contrib_repo.upsert_contribution_by_id.return_value = MagicMock(spec=Contribution) + contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution) svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) - await svc.upsert_contribution_by_id("someid", _contrib_in()) + await svc.upsert_one("someid", _contrib_in()) - contrib_repo.upsert_contribution_by_id.assert_called_once() + contrib_repo.upsert_one.assert_called_once() async def test_new_insert_at_exactly_cap_rejected(self, monkeypatch): # stored == cap: the project is full, so a brand-new document is rejected (no cap+1 slack). @@ -575,51 +575,51 @@ async def test_new_insert_at_exactly_cap_rejected(self, monkeypatch): svc, *_ = _make_service(contributions=contrib_repo, projects=_unapproved_projects_repo()) with pytest.raises(PermissionError): - await svc.upsert_contribution_by_id("someid", _contrib_in()) + await svc.upsert_one("someid", _contrib_in()) - contrib_repo.upsert_contribution_by_id.assert_not_called() + contrib_repo.upsert_one.assert_not_called() async def test_new_insert_approved_project_unlimited(self, monkeypatch): monkeypatch.setattr(get_settings().consumer, "max_unapproved_contributions_per_project", 1) contrib_repo = AsyncMock() contrib_repo.get_one.return_value = None - contrib_repo.upsert_contribution_by_id.return_value = MagicMock(spec=Contribution) + contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution) svc, *_ = _make_service(contributions=contrib_repo, projects=_approved_projects_repo()) - await svc.upsert_contribution_by_id("someid", _contrib_in()) + await svc.upsert_one("someid", _contrib_in()) - contrib_repo.upsert_contribution_by_id.assert_called_once() + contrib_repo.upsert_one.assert_called_once() contrib_repo.count_contributions_for_project.assert_not_called() # --------------------------------------------------------------------------- -# insert_contributions — no-component fast path +# insert_many — no-component fast path # --------------------------------------------------------------------------- class TestInsertContributionsNoComponentPath: async def test_all_no_components_uses_single_insert_many(self): svc, contrib_repo, _, _, _, client = _make_service() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) - contrib_repo.insert_many_contributions.assert_called_once() + contrib_repo.insert_many.assert_called_once() # Zero transactions opened client.start_session.assert_not_called() - contrib_repo.insert_contribution.assert_not_called() + contrib_repo.insert_one.assert_not_called() assert summary.total == 3 assert len(summary.succeeded) == 3 assert summary.failed == [] async def test_is_public_forced_false_on_inserted_docs(self): svc, contrib_repo, _, _, _, _ = _make_service() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None - await svc.insert_contributions([_contrib_in()]) + await svc.insert_many([_contrib_in()]) - docs = contrib_repo.insert_many_contributions.call_args[0][0] + docs = contrib_repo.insert_many.call_args[0][0] assert all(d.is_public is False for d in docs) async def test_bulk_write_error_partitions_succeeded_and_failed(self): @@ -632,10 +632,10 @@ async def test_bulk_write_error_partitions_succeeded_and_failed(self): {"index": 5, "code": 11000, "errmsg": "duplicate key"}, ] }) - contrib_repo.insert_many_contributions.side_effect = bulk_err + contrib_repo.insert_many.side_effect = bulk_err contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(6)] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert summary.total == 6 assert sorted(f.index for f in summary.failed) == [2, 5] @@ -645,7 +645,7 @@ async def test_bulk_write_error_partitions_succeeded_and_failed(self): # --------------------------------------------------------------------------- -# insert_contributions — per-contribution transaction path +# insert_many — per-contribution transaction path # --------------------------------------------------------------------------- @@ -653,17 +653,17 @@ class TestInsertContributionsTransactionPath: async def test_with_components_opens_session_per_contribution(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() - struct_repo.insert_components.return_value = [_fake_structure()] - table_repo.insert_components.return_value = [] - attach_repo.insert_components.return_value = [] + struct_repo.insert_many.return_value = [_fake_structure()] + table_repo.insert_many.return_value = [] + attach_repo.insert_many.return_value = [] async def _insert(doc, session=None): return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert contribs = [_contrib_in(identifier=f"c{i}", structures=[_structure_in()]) for i in range(3)] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert client.start_session.call_count == 3 assert summary.total == 3 @@ -674,30 +674,30 @@ async def test_session_threaded_to_all_repo_calls(self): client, session = _make_fake_client() svc, contrib_repo, struct_repo, table_repo, _, _ = _make_service(client=client) - struct_repo.insert_components.return_value = [_fake_structure()] - table_repo.insert_components.return_value = [_fake_table()] + struct_repo.insert_many.return_value = [_fake_structure()] + table_repo.insert_many.return_value = [_fake_table()] async def _insert(doc, session=None): return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert contrib = _contrib_in( structures=[_structure_in()], tables=[_table_in()], attachments=[_attachment_in()], ) - await svc.insert_contributions([contrib]) - assert struct_repo.insert_components.call_args.kwargs["session"] is session - assert table_repo.insert_components.call_args.kwargs["session"] is session - assert contrib_repo.insert_contribution.call_args.kwargs["session"] is session + await svc.insert_many([contrib]) + assert struct_repo.insert_many.call_args.kwargs["session"] is session + assert table_repo.insert_many.call_args.kwargs["session"] is session + assert contrib_repo.insert_one.call_args.kwargs["session"] is session async def test_failure_on_second_of_three_yields_summary(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() - struct_repo.insert_components.return_value = [_fake_structure()] - table_repo.insert_components.return_value = [] - attach_repo.insert_components.return_value = [] + struct_repo.insert_many.return_value = [_fake_structure()] + table_repo.insert_many.return_value = [] + attach_repo.insert_many.return_value = [] async def _insert(doc, session=None): # Fail the second contribution by inspecting the doc's material_id @@ -705,14 +705,14 @@ async def _insert(doc, session=None): raise ConflictError("conflict on insert") return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert contribs = [ _contrib_in(identifier="ok-1", structures=[_structure_in()]), _contrib_in(identifier="fail", structures=[_structure_in()]), _contrib_in(identifier="ok-2", structures=[_structure_in()]), ] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert summary.total == 3 assert len(summary.succeeded) == 2 @@ -725,9 +725,9 @@ async def test_component_links_wired_per_contribution(self): struct_a, struct_b = _fake_structure(), _fake_structure() struct_calls = iter([[struct_a], [struct_b]]) - struct_repo.insert_components.side_effect = lambda *_args, **_kwargs: next(struct_calls) - table_repo.insert_components.return_value = [] - attach_repo.insert_components.return_value = [] + struct_repo.insert_many.side_effect = lambda *_args, **_kwargs: next(struct_calls) + table_repo.insert_many.return_value = [] + attach_repo.insert_many.return_value = [] captured: list[Contribution] = [] @@ -735,25 +735,25 @@ async def _insert(doc, session=None): captured.append(doc) return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert contribs = [ _contrib_in(identifier="a", structures=[_structure_in()]), _contrib_in(identifier="b", structures=[_structure_in()]), ] - await svc.insert_contributions(contribs) + await svc.insert_many(contribs) - captured_by_id = {c.material_id: c for c in captured} - assert captured_by_id[_mp_id_for("a")].structures == [struct_a] - assert captured_by_id[_mp_id_for("b")].structures == [struct_b] + captured = {c.material_id: c for c in captured} + assert captured[_mp_id_for("a")].structures == [struct_a] + assert captured[_mp_id_for("b")].structures == [struct_b] async def test_pivoting_submission_shares_components_across_rows(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() shared = _fake_structure() - struct_repo.insert_components.return_value = [shared] - table_repo.insert_components.return_value = [] - attach_repo.insert_components.return_value = [] + struct_repo.insert_many.return_value = [shared] + table_repo.insert_many.return_value = [] + attach_repo.insert_many.return_value = [] captured: list[Contribution] = [] @@ -761,7 +761,7 @@ async def _insert(doc, session=None): captured.append(doc) return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert # One submission that pivots into two rows (T=300K / T=400K) and carries a structure. contrib = _contrib_in( @@ -769,12 +769,12 @@ async def _insert(doc, session=None): data={"x (eV, T=300K)": 1, "x (eV, T=400K)": 2}, structures=[_structure_in()], ) - summary = await svc.insert_contributions([contrib]) + summary = await svc.insert_many([contrib]) # Components inserted once for the whole submission; both rows written in one transaction. - assert struct_repo.insert_components.call_count == 1 + assert struct_repo.insert_many.call_count == 1 assert client.start_session.call_count == 1 - assert contrib_repo.insert_contribution.call_count == 2 + assert contrib_repo.insert_one.call_count == 2 # Both pivoted rows link to the same shared structure and carry distinct condition keys. assert len(captured) == 2 assert all(doc.structures == [shared] for doc in captured) @@ -786,7 +786,7 @@ async def _insert(doc, session=None): # --------------------------------------------------------------------------- -# insert_contributions — mixed batch (partitioned across paths) +# insert_many — mixed batch (partitioned across paths) # --------------------------------------------------------------------------- @@ -794,15 +794,15 @@ class TestInsertContributionsMixedBatch: async def test_mixed_batch_routes_correctly(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, client = _make_service() - struct_repo.insert_components.return_value = [_fake_structure()] - table_repo.insert_components.return_value = [] - attach_repo.insert_components.return_value = [] - contrib_repo.insert_many_contributions.return_value = None + struct_repo.insert_many.return_value = [_fake_structure()] + table_repo.insert_many.return_value = [] + attach_repo.insert_many.return_value = [] + contrib_repo.insert_many.return_value = None async def _insert(doc, session=None): return doc - contrib_repo.insert_contribution.side_effect = _insert + contrib_repo.insert_one.side_effect = _insert contribs = [ _contrib_in(identifier="bare-1"), @@ -810,14 +810,14 @@ async def _insert(doc, session=None): _contrib_in(identifier="bare-2"), _contrib_in(identifier="with-2", structures=[_structure_in()]), ] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) # No-component path: single batched call - contrib_repo.insert_many_contributions.assert_called_once() - assert len(contrib_repo.insert_many_contributions.call_args[0][0]) == 2 + contrib_repo.insert_many.assert_called_once() + assert len(contrib_repo.insert_many.call_args[0][0]) == 2 # With-component path: one session per item assert client.start_session.call_count == 2 - assert contrib_repo.insert_contribution.call_count == 2 + assert contrib_repo.insert_one.call_count == 2 assert summary.total == 4 assert len(summary.succeeded) == 4 assert summary.failed == [] @@ -842,64 +842,64 @@ def _projects_mock(unique_map: dict[str, str | None]) -> AsyncMock: class TestContributionIdentity: async def test_insert_existing_identity_conflicts(self): svc, contrib_repo, *_ = _make_service() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contrib_repo.existing_identities.return_value = { ContributionIdentity(project="proj", material_id="mp-1", chemical_system_id="Fe-O", formula="Fe2O3") } - summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + summary = await svc.insert_many([_contrib_in(identifier="mp-1")]) assert summary.total == 1 assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["conflict"] - contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_many.assert_not_called() async def test_insert_no_existing_identity_succeeds(self): svc, contrib_repo, *_ = _make_service() - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None - summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + summary = await svc.insert_many([_contrib_in(identifier="mp-1")]) assert len(summary.succeeded) == 1 - docs = contrib_repo.insert_many_contributions.call_args[0][0] + docs = contrib_repo.insert_many.call_args[0][0] assert docs[0].unique_value is None async def test_insert_unique_column_promotes_value_to_unique_value(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None - summary = await svc.insert_contributions([_contrib_in(data={"sample_id": "A"})]) + summary = await svc.insert_many([_contrib_in(data={"sample_id": "A"})]) assert len(summary.succeeded) == 1 - docs = contrib_repo.insert_many_contributions.call_args[0][0] + docs = contrib_repo.insert_many.call_args[0][0] assert docs[0].unique_value == "A" async def test_insert_same_triple_distinct_unique_value_both_succeed(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [_contrib_in(data={"sample_id": "A"}), _contrib_in(data={"sample_id": "B"})] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert len(summary.succeeded) == 2 - docs = contrib_repo.insert_many_contributions.call_args[0][0] + docs = contrib_repo.insert_many.call_args[0][0] assert sorted(d.unique_value for d in docs) == ["A", "B"] async def test_insert_missing_unique_column_value_is_validation_failure(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None - summary = await svc.insert_contributions([_contrib_in(data={"other": 1})]) + summary = await svc.insert_many([_contrib_in(data={"other": 1})]) assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["validation_error"] - contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_many.assert_not_called() async def test_insert_non_scalar_unique_column_value_is_validation_failure(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None - summary = await svc.insert_contributions([_contrib_in(data={"sample_id": {"nested": 1}})]) + summary = await svc.insert_many([_contrib_in(data={"sample_id": {"nested": 1}})]) assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["validation_error"] @@ -907,10 +907,10 @@ async def test_insert_non_scalar_unique_column_value_is_validation_failure(self) async def test_insert_empty_unique_column_dup_triple_conflicts(self): """With no unique_column, two contributions sharing the fixed-field triple collide.""" svc, contrib_repo, *_ = _make_service() # unique_column=None - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [_contrib_in(identifier="mp-1"), _contrib_in(identifier="mp-1")] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert len(summary.succeeded) == 1 assert [f.error_code for f in summary.failed] == ["conflict"] @@ -918,18 +918,18 @@ async def test_insert_empty_unique_column_dup_triple_conflicts(self): async def test_insert_project_not_found_is_validation_failure(self): svc, contrib_repo, *_ = _make_service(projects=_projects_mock({})) # no projects known - summary = await svc.insert_contributions([_contrib_in(identifier="mp-1")]) + summary = await svc.insert_many([_contrib_in(identifier="mp-1")]) assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["validation_error"] - contrib_repo.insert_many_contributions.assert_not_called() + contrib_repo.insert_many.assert_not_called() async def test_upsert_does_not_conflict_on_existing_identity(self): """Upsert targets an existing identity (update), so it must not pre-reject as a conflict.""" svc, contrib_repo, *_ = _make_service() contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution, project="proj") - await svc.upsert_contributions([_contrib_in(identifier="mp-1")]) + await svc.upsert_many([_contrib_in(identifier="mp-1")]) # existing_identities is not consulted on the upsert path contrib_repo.existing_identities.assert_not_called() @@ -939,7 +939,7 @@ async def test_upsert_passes_resolved_unique_value_in_identifiers(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution, project="proj") - await svc.upsert_contributions([_contrib_in(data={"sample_id": "A"})]) + await svc.upsert_many([_contrib_in(data={"sample_id": "A"})]) identifiers = contrib_repo.upsert_one.call_args.args[0] assert identifiers["unique_value"] == "A" @@ -947,7 +947,7 @@ async def test_upsert_passes_resolved_unique_value_in_identifiers(self): async def test_upsert_missing_unique_column_value_is_validation_failure(self): svc, contrib_repo, *_ = _make_service(unique_column="sample_id") - summary = await svc.upsert_contributions([_contrib_in(data={"other": 1})]) + summary = await svc.upsert_many([_contrib_in(data={"other": 1})]) assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["validation_error"] @@ -956,7 +956,7 @@ async def test_upsert_missing_unique_column_value_is_validation_failure(self): # --------------------------------------------------------------------------- -# upsert_contributions — guard clause +# upsert_many — guard clause # --------------------------------------------------------------------------- @@ -965,26 +965,26 @@ async def test_raises_validation_error_when_any_contrib_has_structures(self): svc, *_ = _make_service() contrib = _contrib_in(structures=[_structure_in()]) with pytest.raises(ValidationError): - await svc.upsert_contributions([contrib]) + await svc.upsert_many([contrib]) async def test_raises_validation_error_when_any_contrib_has_tables(self): svc, *_ = _make_service() contrib = _contrib_in(tables=[_table_in()]) with pytest.raises(ValidationError): - await svc.upsert_contributions([contrib]) + await svc.upsert_many([contrib]) async def test_raises_validation_error_when_any_contrib_has_attachments(self): svc, *_ = _make_service() contrib = _contrib_in(attachments=[_attachment_in()]) with pytest.raises(ValidationError): - await svc.upsert_contributions([contrib]) + await svc.upsert_many([contrib]) async def test_error_reports_indices_of_offending_contribs(self): svc, *_ = _make_service() clean = _contrib_in(identifier="clean") dirty = _contrib_in(identifier="dirty", structures=[_structure_in()]) with pytest.raises(ValidationError) as exc_info: - await svc.upsert_contributions([clean, dirty]) + await svc.upsert_many([clean, dirty]) assert exc_info.value.context.get("contribution_indices") == [1] async def test_multiple_offenders_all_indices_reported(self): @@ -995,20 +995,20 @@ async def test_multiple_offenders_all_indices_reported(self): _contrib_in(identifier="c2", tables=[_table_in()]), ] with pytest.raises(ValidationError) as exc_info: - await svc.upsert_contributions(contribs) + await svc.upsert_many(contribs) assert exc_info.value.context.get("contribution_indices") == [0, 2] async def test_raises_before_any_db_write(self): svc, contrib_repo, *_ = _make_service() dirty = _contrib_in(structures=[_structure_in()]) with pytest.raises(ValidationError): - await svc.upsert_contributions([dirty]) + await svc.upsert_many([dirty]) contrib_repo.upsert_one.assert_not_called() - contrib_repo.insert_contribution.assert_not_called() + contrib_repo.insert_one.assert_not_called() # --------------------------------------------------------------------------- -# upsert_contributions — atomic dispatch +# upsert_many — atomic dispatch # --------------------------------------------------------------------------- @@ -1018,21 +1018,21 @@ async def test_calls_atomic_repo_method_once_per_item(self): contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution, project="proj") contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert summary.total == 3 assert len(summary.succeeded) == 3 assert summary.failed == [] assert contrib_repo.upsert_one.call_count == 3 # The atomic upsert path is used, not the bulk insert path. - contrib_repo.insert_contribution.assert_not_called() + contrib_repo.insert_one.assert_not_called() async def test_passes_identifiers_dict_and_input_to_repo(self): svc, contrib_repo, *_ = _make_service() contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution, project="proj") contrib = _contrib_in(project="my-proj", material_id="mp-99") - await svc.upsert_contributions([contrib]) + await svc.upsert_many([contrib]) call = contrib_repo.upsert_one.call_args assert call.args[0] == { @@ -1060,13 +1060,13 @@ async def _upsert(identifiers, contrib): contrib_repo.upsert_one.side_effect = _upsert contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert summary.succeeded == [returned["mp-0"], returned["mp-1"], returned["mp-2"]] async def test_empty_batch_returns_empty_summary(self): svc, contrib_repo, *_ = _make_service() - summary = await svc.upsert_contributions([]) + summary = await svc.upsert_many([]) assert summary.total == 0 assert summary.succeeded == [] assert summary.failed == [] @@ -1084,7 +1084,7 @@ async def test_same_key_concurrent_upserts_both_go_through_atomic_call(self): _contrib_in(project="prj", identifier="same"), _contrib_in(project="prj", identifier="same"), ] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert len(summary.succeeded) == 2 assert contrib_repo.upsert_one.call_count == 2 @@ -1100,7 +1100,7 @@ async def _upsert(identifiers, contrib): contrib_repo.upsert_one.side_effect = _upsert contribs = [_contrib_in(identifier=f"mp-{i}") for i in range(3)] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert summary.total == 3 assert len(summary.succeeded) == 2 @@ -1121,13 +1121,13 @@ def _member_user(*projects: str) -> User: class TestWriteAuthorization: async def test_insert_rejects_unauthorized_project_per_item(self): svc, contrib_repo, struct_repo, _, _, client = _make_service(user=_member_user("allowed")) - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [ _contrib_in(project="allowed", identifier="ok"), _contrib_in(project="forbidden", identifier="nope"), ] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert summary.total == 2 assert len(summary.succeeded) == 1 @@ -1135,14 +1135,14 @@ async def test_insert_rejects_unauthorized_project_per_item(self): assert summary.failed[0].error_code == "permission_denied" assert "forbidden" in summary.failed[0].message # Only the authorized item reached Mongo - contrib_repo.insert_many_contributions.assert_called_once() + contrib_repo.insert_many.assert_called_once() async def test_insert_admin_bypasses_authorization(self): svc, contrib_repo, *_ = _make_service() # default user is admin - contrib_repo.insert_many_contributions.return_value = None + contrib_repo.insert_many.return_value = None contribs = [_contrib_in(project="anything", identifier=f"mp-{i}") for i in range(2)] - summary = await svc.insert_contributions(contribs) + summary = await svc.insert_many(contribs) assert summary.total == 2 assert len(summary.succeeded) == 2 @@ -1155,13 +1155,13 @@ async def test_insert_unauthorized_and_oversize_yield_single_failure(self): svc, contrib_repo, struct_repo, _, _, _ = _make_service(user=_member_user("allowed"), settings=settings) bad = _contrib_in(project="forbidden", identifier="big", structures=[_structure_in(), _structure_in()]) - summary = await svc.insert_contributions([bad]) + summary = await svc.insert_many([bad]) assert summary.total == 1 assert len(summary.failed) == 1 assert summary.failed[0].index == 0 assert summary.failed[0].error_code == "permission_denied" - struct_repo.insert_components.assert_not_called() + struct_repo.insert_many.assert_not_called() async def test_upsert_rejects_unauthorized_project_per_item(self): svc, contrib_repo, *_ = _make_service(user=_member_user("allowed")) @@ -1171,7 +1171,7 @@ async def test_upsert_rejects_unauthorized_project_per_item(self): _contrib_in(project="allowed", identifier="ok"), _contrib_in(project="forbidden", identifier="nope"), ] - summary = await svc.upsert_contributions(contribs) + summary = await svc.upsert_many(contribs) assert summary.total == 2 assert len(summary.succeeded) == 1 @@ -1184,26 +1184,26 @@ async def test_upsert_rejects_unauthorized_project_per_item(self): async def test_upsert_anonymous_authorized_for_nothing(self): svc, contrib_repo, *_ = _make_service(user=User()) # anonymous: no username, no groups - summary = await svc.upsert_contributions([_contrib_in(project="any", identifier="x")]) + summary = await svc.upsert_many([_contrib_in(project="any", identifier="x")]) assert summary.total == 1 assert summary.succeeded == [] assert [f.error_code for f in summary.failed] == ["permission_denied"] contrib_repo.upsert_one.assert_not_called() - async def test_upsert_by_id_rejects_unauthorized_project(self): + async def test_upsert_rejects_unauthorized_project(self): # A member of "allowed" cannot write "forbidden" through the by-id endpoint. The check runs # before any DB access, so neither the existence read nor the write is attempted — closing # the gap where the upsert's unscoped insert branch would create the contribution anyway. svc, contrib_repo, *_ = _make_service(user=_member_user("allowed")) with pytest.raises(PermissionError, match="forbidden"): - await svc.upsert_contribution_by_id("someid", _contrib_in(project="forbidden")) + await svc.upsert_one("someid", _contrib_in(project="forbidden")) contrib_repo.get_one.assert_not_called() - contrib_repo.upsert_contribution_by_id.assert_not_called() + contrib_repo.upsert_one.assert_not_called() - async def test_upsert_by_id_unauthorized_cannot_overwrite_public_contribution(self): + async def test_upsert_unauthorized_cannot_overwrite_public_contribution(self): # Defense against overwriting a project's public contribution you don't own: even though the # repository read scope would admit a public row, authorization is enforced up front. svc, contrib_repo, *_ = _make_service(user=_member_user("allowed")) @@ -1211,46 +1211,46 @@ async def test_upsert_by_id_unauthorized_cannot_overwrite_public_contribution(se contrib_repo.get_one.return_value = MagicMock(spec=Contribution) with pytest.raises(PermissionError, match="forbidden"): - await svc.upsert_contribution_by_id("someid", _contrib_in(project="forbidden")) + await svc.upsert_one("someid", _contrib_in(project="forbidden")) - contrib_repo.upsert_contribution_by_id.assert_not_called() + contrib_repo.upsert_one.assert_not_called() - async def test_upsert_by_id_anonymous_authorized_for_nothing(self): + async def test_upsert_anonymous_authorized_for_nothing(self): svc, contrib_repo, *_ = _make_service(user=User()) # anonymous: no username, no groups with pytest.raises(PermissionError): - await svc.upsert_contribution_by_id("someid", _contrib_in(project="any")) + await svc.upsert_one("someid", _contrib_in(project="any")) contrib_repo.get_one.assert_not_called() - contrib_repo.upsert_contribution_by_id.assert_not_called() + contrib_repo.upsert_one.assert_not_called() - async def test_upsert_by_id_authorized_member_proceeds(self): + async def test_upsert_authorized_member_proceeds(self): # A member writing to their own project passes authorization; updating an existing row is # not gated by the quota, so the write goes through. contrib_repo = AsyncMock() contrib_repo.get_one.return_value = MagicMock(spec=Contribution) # exists -> update - contrib_repo.upsert_contribution_by_id.return_value = MagicMock(spec=Contribution) + contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution) svc, *_ = _make_service( contributions=contrib_repo, projects=_unapproved_projects_repo(), user=_member_user("allowed") ) - await svc.upsert_contribution_by_id("someid", _contrib_in(project="allowed")) + await svc.upsert_one("someid", _contrib_in(project="allowed")) - contrib_repo.upsert_contribution_by_id.assert_called_once() + contrib_repo.upsert_one.assert_called_once() - async def test_upsert_by_id_admin_bypasses_authorization(self): + async def test_upsert_admin_bypasses_authorization(self): contrib_repo = AsyncMock() contrib_repo.get_one.return_value = MagicMock(spec=Contribution) - contrib_repo.upsert_contribution_by_id.return_value = MagicMock(spec=Contribution) + contrib_repo.upsert_one.return_value = MagicMock(spec=Contribution) svc, *_ = _make_service(contributions=contrib_repo, projects=_approved_projects_repo()) # admin default - await svc.upsert_contribution_by_id("someid", _contrib_in(project="anything")) + await svc.upsert_one("someid", _contrib_in(project="anything")) - contrib_repo.upsert_contribution_by_id.assert_called_once() + contrib_repo.upsert_one.assert_called_once() # --------------------------------------------------------------------------- -# delete_contributions — cascade delete (components-first), cursor loop +# delete_many — cascade delete (components-first), cursor loop # --------------------------------------------------------------------------- from types import SimpleNamespace # noqa: E402 @@ -1266,7 +1266,7 @@ def _link(ref_id: PydanticObjectId) -> SimpleNamespace: def _contrib_doc(structures=None, attachments=None, tables=None, id_=None, project="proj") -> SimpleNamespace: - """A contribution page item exposing the attributes delete_contributions reads.""" + """A contribution page item exposing the attributes delete_many reads.""" return SimpleNamespace( id=id_ or _oid(), project=project, @@ -1281,8 +1281,8 @@ def _page(items) -> Page: def _delete_result(n: int) -> SimpleNamespace: - """Stand-in for pymongo DeleteResult (only ``.deleted_count`` is read).""" - return SimpleNamespace(deleted_count=n) + """Stand-in for the delete result (only ``.num_deleted`` is read).""" + return SimpleNamespace(num_deleted=n) def _noop_filter() -> ContributionFilter: @@ -1292,33 +1292,33 @@ def _noop_filter() -> ContributionFilter: class TestDeleteContributionsEmpty: async def test_empty_match_returns_zero_summary(self): svc, contrib_repo, *_ = _make_service() - contrib_repo.get_contributions.return_value = _page([]) - contrib_repo.delete_contributions.return_value = _delete_result(0) + contrib_repo.get_many.return_value = _page([]) + contrib_repo.delete_many.return_value = _delete_result(0) - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_deleted == 0 assert summary.num_children_deleted == 0 async def test_empty_match_does_not_call_child_repos(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() - contrib_repo.get_contributions.return_value = _page([]) - contrib_repo.delete_contributions.return_value = _delete_result(0) + contrib_repo.get_many.return_value = _page([]) + contrib_repo.delete_many.return_value = _delete_result(0) - await svc.delete_contributions(_noop_filter()) + await svc.delete_many(_noop_filter()) - struct_repo.delete_by_ids.assert_not_called() - table_repo.delete_by_ids.assert_not_called() - attach_repo.delete_by_ids.assert_not_called() + struct_repo.delete_many.assert_not_called() + table_repo.delete_many.assert_not_called() + attach_repo.delete_many.assert_not_called() async def test_empty_match_terminates_after_one_page(self): svc, contrib_repo, *_ = _make_service() - contrib_repo.get_contributions.return_value = _page([]) - contrib_repo.delete_contributions.return_value = _delete_result(0) + contrib_repo.get_many.return_value = _page([]) + contrib_repo.delete_many.return_value = _delete_result(0) - await svc.delete_contributions(_noop_filter()) + await svc.delete_many(_noop_filter()) - assert contrib_repo.get_contributions.await_count == 1 + assert contrib_repo.get_many.await_count == 1 class TestDeleteContributionsSinglePage: @@ -1326,23 +1326,23 @@ async def test_deletes_contributions_then_terminates(self): svc, contrib_repo, *_ = _make_service() docs = [_contrib_doc() for _ in range(3)] # First call returns the page; second returns empty so the loop ends. - contrib_repo.get_contributions.side_effect = [_page(docs), _page([])] - contrib_repo.delete_contributions.side_effect = [_delete_result(3), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page(docs), _page([])] + contrib_repo.delete_many.side_effect = [_delete_result(3), _delete_result(0)] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_deleted == 3 async def test_no_components_means_no_child_deletes(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() - contrib_repo.get_contributions.side_effect = [_page([_contrib_doc()]), _page([])] - contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page([_contrib_doc()]), _page([])] + contrib_repo.delete_many.side_effect = [_delete_result(1), _delete_result(0)] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) - struct_repo.delete_by_ids.assert_not_called() - table_repo.delete_by_ids.assert_not_called() - attach_repo.delete_by_ids.assert_not_called() + struct_repo.delete_many.assert_not_called() + table_repo.delete_many.assert_not_called() + attach_repo.delete_many.assert_not_called() assert summary.num_children_deleted == 0 async def test_components_deleted_before_contributions(self): @@ -1351,7 +1351,7 @@ async def test_components_deleted_before_contributions(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() doc = _contrib_doc(structures=[_oid()], tables=[_oid()], attachments=[_oid()]) - contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] + contrib_repo.get_many.side_effect = [_page([doc]), _page([])] def _make_child_recorder(name): async def _record(ids, *a, **k): @@ -1360,17 +1360,17 @@ async def _record(ids, *a, **k): return _record - struct_repo.delete_by_ids.side_effect = _make_child_recorder("structures") - table_repo.delete_by_ids.side_effect = _make_child_recorder("tables") - attach_repo.delete_by_ids.side_effect = _make_child_recorder("attachments") + struct_repo.delete_many.side_effect = _make_child_recorder("structures") + table_repo.delete_many.side_effect = _make_child_recorder("tables") + attach_repo.delete_many.side_effect = _make_child_recorder("attachments") async def _record_contrib(_filter, *a, **k): order.append("contributions") return _delete_result(1) - contrib_repo.delete_contributions.side_effect = _record_contrib + contrib_repo.delete_many.side_effect = _record_contrib - await svc.delete_contributions(_noop_filter()) + await svc.delete_many(_noop_filter()) # The loop makes a final pass on the empty page that still issues one # (no-op) contribution delete before breaking, so there are two @@ -1383,78 +1383,78 @@ async def test_child_ids_collected_from_links(self): svc, contrib_repo, struct_repo, *_ = _make_service() s1, s2 = _oid(), _oid() doc = _contrib_doc(structures=[s1, s2]) - contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] - struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=2) - contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page([doc]), _page([])] + struct_repo.delete_many.return_value = DeleteResponse(num_deleted=2) + contrib_repo.delete_many.side_effect = [_delete_result(1), _delete_result(0)] - await svc.delete_contributions(_noop_filter()) + await svc.delete_many(_noop_filter()) - called_ids = struct_repo.delete_by_ids.await_args.args[0] - assert set(called_ids) == {s1, s2} + called_filter = struct_repo.delete_many.await_args.args[0] + assert set(called_filter.id__in) == {s1, s2} async def test_child_counts_accumulated_across_types(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() doc = _contrib_doc(structures=[_oid()], tables=[_oid(), _oid()], attachments=[_oid()]) - contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] - struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) - table_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=2) - attach_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) - contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page([doc]), _page([])] + struct_repo.delete_many.return_value = DeleteResponse(num_deleted=1) + table_repo.delete_many.return_value = DeleteResponse(num_deleted=2) + attach_repo.delete_many.return_value = DeleteResponse(num_deleted=1) + contrib_repo.delete_many.side_effect = [_delete_result(1), _delete_result(0)] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_children_deleted == 4 - async def test_contributions_deleted_by_id_in_of_page(self): + async def test_contributions_deleted_in_of_page(self): svc, contrib_repo, *_ = _make_service() ids = [_oid(), _oid()] docs = [_contrib_doc(id_=i) for i in ids] - contrib_repo.get_contributions.side_effect = [_page(docs), _page([])] - contrib_repo.delete_contributions.side_effect = [_delete_result(2), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page(docs), _page([])] + contrib_repo.delete_many.side_effect = [_delete_result(2), _delete_result(0)] - await svc.delete_contributions(_noop_filter()) + await svc.delete_many(_noop_filter()) - first_call_filter = contrib_repo.delete_contributions.await_args_list[0].args[0] + first_call_filter = contrib_repo.delete_many.await_args_list[0].args[0] assert set(first_call_filter.id__in) == set(ids) class TestDeleteContributionsMultiPage: async def test_loops_until_page_empty(self): svc, contrib_repo, *_ = _make_service() - contrib_repo.get_contributions.side_effect = [ + contrib_repo.get_many.side_effect = [ _page([_contrib_doc() for _ in range(2)]), _page([_contrib_doc()]), _page([]), ] - contrib_repo.delete_contributions.side_effect = [ + contrib_repo.delete_many.side_effect = [ _delete_result(2), _delete_result(1), _delete_result(0), ] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_deleted == 3 - assert contrib_repo.get_contributions.await_count == 3 + assert contrib_repo.get_many.await_count == 3 async def test_children_accumulate_across_pages(self): svc, contrib_repo, struct_repo, *_ = _make_service() - contrib_repo.get_contributions.side_effect = [ + contrib_repo.get_many.side_effect = [ _page([_contrib_doc(structures=[_oid()])]), _page([_contrib_doc(structures=[_oid()])]), _page([]), ] - struct_repo.delete_by_ids.return_value = DeleteResponse(num_deleted=1) - contrib_repo.delete_contributions.side_effect = [ + struct_repo.delete_many.return_value = DeleteResponse(num_deleted=1) + contrib_repo.delete_many.side_effect = [ _delete_result(1), _delete_result(1), _delete_result(0), ] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_children_deleted == 2 - assert struct_repo.delete_by_ids.await_count == 2 + assert struct_repo.delete_many.await_count == 2 class TestDeleteContributionsNoneComponents: @@ -1466,20 +1466,20 @@ class TestDeleteContributionsNoneComponents: async def test_none_component_fields_do_not_raise(self): svc, contrib_repo, struct_repo, table_repo, attach_repo, _ = _make_service() doc = SimpleNamespace(id=_oid(), project="proj", structures=None, tables=None, attachments=None) - contrib_repo.get_contributions.side_effect = [_page([doc]), _page([])] - contrib_repo.delete_contributions.side_effect = [_delete_result(1), _delete_result(0)] + contrib_repo.get_many.side_effect = [_page([doc]), _page([])] + contrib_repo.delete_many.side_effect = [_delete_result(1), _delete_result(0)] - summary = await svc.delete_contributions(_noop_filter()) + summary = await svc.delete_many(_noop_filter()) assert summary.num_deleted == 1 assert summary.num_children_deleted == 0 - struct_repo.delete_by_ids.assert_not_called() - table_repo.delete_by_ids.assert_not_called() - attach_repo.delete_by_ids.assert_not_called() + struct_repo.delete_many.assert_not_called() + table_repo.delete_many.assert_not_called() + attach_repo.delete_many.assert_not_called() # --------------------------------------------------------------------------- -# patch_contribution_by_id — identifier hierarchy on the merged state +# patch_one — identifier hierarchy on the merged state # --------------------------------------------------------------------------- @@ -1502,7 +1502,7 @@ async def test_patch_material_id_onto_doc_without_formula_raises(self): contrib_repo.get_one.return_value = existing with pytest.raises(ValidationError, match="formula is required when material_id"): - await svc.patch_contribution_by_id(str(existing.id), ContributionPatch(material_id="mp-1")) + await svc.patch_one(str(existing.id), ContributionPatch(material_id="mp-1")) # Rejected before any write. contrib_repo.patch_one.assert_not_called() @@ -1513,7 +1513,7 @@ async def test_patch_material_id_when_existing_has_formula_ok(self): contrib_repo.get_one.return_value = existing contrib_repo.patch_one.return_value = MagicMock(spec=Contribution) - await svc.patch_contribution_by_id(str(existing.id), ContributionPatch(material_id="mp-1")) + await svc.patch_one(str(existing.id), ContributionPatch(material_id="mp-1")) contrib_repo.patch_one.assert_called_once() @@ -1521,7 +1521,7 @@ async def test_metadata_only_patch_skips_existing_read(self): svc, contrib_repo, *_ = _make_service() contrib_repo.patch_one.return_value = MagicMock(spec=Contribution) - await svc.patch_contribution_by_id("some-id", ContributionPatch(is_public=True)) + await svc.patch_one("some-id", ContributionPatch(is_public=True)) # No identity/unique inputs touched -> no re-read, straight to the plain patch. contrib_repo.get_one.assert_not_called() @@ -1529,7 +1529,7 @@ async def test_metadata_only_patch_skips_existing_read(self): # --------------------------------------------------------------------------- -# patch_contribution_by_id — data merge vs replace + unique_value resolution +# patch_one — data merge vs replace + unique_value resolution # --------------------------------------------------------------------------- @@ -1541,7 +1541,7 @@ async def test_data_patch_defaults_to_merge_and_forwards_replace_false(self): contrib_repo.get_one.return_value = existing contrib_repo.patch_one.return_value = MagicMock(spec=Contribution) - await svc.patch_contribution_by_id(str(existing.id), ContributionPatch(data={"y": 9.0})) + await svc.patch_one(str(existing.id), ContributionPatch(data={"y": 9.0})) # The repo performs the actual dotted-$set merge; the service just forwards replace_data=False. assert contrib_repo.patch_one.call_args.kwargs["replace_data"] is False @@ -1553,7 +1553,7 @@ async def test_replace_data_flag_forwarded_to_repo(self): contrib_repo.get_one.return_value = existing contrib_repo.patch_one.return_value = MagicMock(spec=Contribution) - await svc.patch_contribution_by_id( + await svc.patch_one( str(existing.id), ContributionPatch(data={"y": 9.0}), replace_data=True ) @@ -1568,7 +1568,7 @@ async def test_merge_resolves_unique_value_from_merged_state(self): contrib_repo.get_one.return_value = existing contrib_repo.patch_one.return_value = MagicMock(spec=Contribution) - await svc.patch_contribution_by_id(str(existing.id), ContributionPatch(data={"y": 9.0})) + await svc.patch_one(str(existing.id), ContributionPatch(data={"y": 9.0})) # Resolved from {sample_id:42, x:1, y:9}, so the untouched unique_value survives the merge. assert contrib_repo.patch_one.call_args.kwargs["unique_value"] == 42 @@ -1582,7 +1582,7 @@ async def test_replace_resolves_unique_value_from_patch_data_only(self): contrib_repo.get_one.return_value = existing with pytest.raises(ValidationError, match="unique_column"): - await svc.patch_contribution_by_id( + await svc.patch_one( str(existing.id), ContributionPatch(data={"y": 9.0}), replace_data=True ) contrib_repo.patch_one.assert_not_called() diff --git a/mpcontribs-api/tests/unit/domains/test_project_group_service.py b/mpcontribs-api/tests/unit/domains/test_project_group_service.py index 52a90d68b..c4a512766 100644 --- a/mpcontribs-api/tests/unit/domains/test_project_group_service.py +++ b/mpcontribs-api/tests/unit/domains/test_project_group_service.py @@ -28,7 +28,7 @@ def _make_service(group: ProjectGroupOut | None, *, visible_projects: set[str] | visible = visible_projects or set() groups = AsyncMock() projects = AsyncMock() - # insert() forces owner to the caller for non-admins; give the stub an admin user so these + #.insert_one() forces owner to the caller for non-admins; give the stub an admin user so these # payload-identity assertions exercise the pass-through path (owner-forcing is covered end-to-end # in the db service test). groups._user = User(username="google:admin@example.com", groups=frozenset({"admin"})) @@ -66,35 +66,35 @@ def _group(project_ids: list[str] | None = None) -> ProjectGroupOut: # --------------------------------------------------------------------------- -# insert +#.insert_one # --------------------------------------------------------------------------- -class TestInsert: +class TestInsert_one: def _payload(self, projects: list[str]) -> ProjectGroupIn: return ProjectGroupIn(name="g", owner="google:a@b.com", description="d", projects=projects) - async def test_all_projects_valid_inserts(self): + async def test_all_projects_valid_insert_ones(self): service, groups, _ = _make_service(None, visible_projects={"mp-1", "mp-2"}) - groups.insert_project_group.return_value = "stored" + groups.insert_one.return_value = "stored" payload = self._payload(["mp-1", "mp-2"]) - result = await service.insert(payload) + result = await service.insert_one(payload) assert result == "stored" - groups.insert_project_group.assert_awaited_once_with(payload) + groups.insert_one.assert_awaited_once_with(in_resource=payload) - async def test_missing_project_raises_not_found_and_skips_insert(self): + async def test_missing_project_raises_not_found_and_skips_insert_one(self): service, groups, _ = _make_service(None, visible_projects={"mp-1"}) with pytest.raises(NotFoundError) as exc: - await service.insert(self._payload(["mp-1", "ghost"])) + await service.insert_one(self._payload(["mp-1", "ghost"])) assert exc.value.context["ids"] == ["ghost"] - groups.insert_project_group.assert_not_awaited() + groups.insert_one.assert_not_awaited() - async def test_empty_projects_inserts_without_validation(self): + async def test_empty_projects_insert_ones_without_validation(self): service, groups, projects = _make_service(None) payload = self._payload([]) - await service.insert(payload) + await service.insert_one(payload) projects.get_one.assert_not_awaited() - groups.insert_project_group.assert_awaited_once_with(payload) + groups.insert_one.assert_awaited_once_with(in_resource=payload) # --------------------------------------------------------------------------- From b5cc22bb05fa119acf0b2904c0dcb45e7d0a1bb6 Mon Sep 17 00:00:00 2001 From: Brendan Foley Date: Wed, 19 Aug 2026 14:41:43 -0700 Subject: [PATCH 2/2] docs(tests): formatting --- .../mpcontribs_api/domains/_shared/filters.py | 4 +- .../db/test_components_repository.py | 48 +++++++++--------- .../db/test_contributions_repository.py | 50 +++++++++---------- .../db/test_initiatives_repository.py | 10 ++-- .../db/test_initiatives_service.py | 4 +- .../db/test_project_groups_repository.py | 12 ++--- .../db/test_project_groups_service.py | 4 +- .../db/test_projects_repository.py | 48 +++++++++--------- .../integration/db/test_stats_recompute.py | 14 +++--- 9 files changed, 97 insertions(+), 97 deletions(-) diff --git a/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py b/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py index c2d4e7f8c..46963d5a5 100644 --- a/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py +++ b/mpcontribs-api/src/mpcontribs_api/domains/_shared/filters.py @@ -5,11 +5,11 @@ from fastapi_filter.contrib.beanie.filter import _odm_operator_transformer from pydantic import ValidationInfo, field_validator +from mpcontribs_api.domains._shared.types import nfc_normalize + # Register a custom __contains filter suffix to search where lists are a superset of a provided list _odm_operator_transformer.setdefault("contains", lambda value: {"$all": value}) -from mpcontribs_api.domains._shared.types import nfc_normalize - def _normalize_query_values(value: Any) -> Any: """Recursively NFC-normalize every string in a built query condition value. diff --git a/mpcontribs-api/tests/integration/db/test_components_repository.py b/mpcontribs-api/tests/integration/db/test_components_repository.py index d48b025a0..bc2085b1b 100644 --- a/mpcontribs-api/tests/integration/db/test_components_repository.py +++ b/mpcontribs-api/tests/integration/db/test_components_repository.py @@ -39,38 +39,38 @@ async def _count() -> int: # --------------------------------------------------------------------------- -# insert_components: md5 dedupe +# insert_many: md5 dedupe # --------------------------------------------------------------------------- class TestInsertComponentsDedupe: async def test_duplicate_content_in_batch_inserted_once(self, db): # Two inputs share content (-> same md5); only one document should be written. - await _repo().insert_components([_attachment(1), _attachment(1), _attachment(2)]) + await _repo().insert_many([_attachment(1), _attachment(1), _attachment(2)]) assert await _count() == 2 async def test_returns_one_doc_per_unique_md5(self, db): - result = await _repo().insert_components([_attachment(1), _attachment(1)]) + result = await _repo().insert_many([_attachment(1), _attachment(1)]) assert len(result) == 1 async def test_existing_md5_not_reinserted(self, db): - await _repo().insert_components([_attachment(1)]) + await _repo().insert_many([_attachment(1)]) # Re-submit the existing content alongside new content. - await _repo().insert_components([_attachment(1), _attachment(2)]) + await _repo().insert_many([_attachment(1), _attachment(2)]) assert await _count() == 2 async def test_existing_doc_returned_with_original_id(self, db): - first = await _repo().insert_components([_attachment(1)]) - again = await _repo().insert_components([_attachment(1)]) + first = await _repo().insert_many([_attachment(1)]) + again = await _repo().insert_many([_attachment(1)]) assert again[0].id == first[0].id async def test_inserted_docs_have_ids(self, db): - result = await _repo().insert_components([_attachment(1), _attachment(2)]) + result = await _repo().insert_many([_attachment(1), _attachment(2)]) assert all(doc.id is not None for doc in result) # --------------------------------------------------------------------------- -# insert_components: chunking +# insert_many: chunking # --------------------------------------------------------------------------- @@ -80,19 +80,19 @@ async def test_all_docs_persisted_across_multiple_chunks(self, db, monkeypatch): monkeypatch.setattr(get_settings().mongo, "component_insert_chunk_size", 2) # Distinct content -> distinct md5 so all five survive dedup. attachments = [_attachment(i) for i in range(5)] - result = await _repo().insert_components(attachments) + result = await _repo().insert_many(attachments) assert len(result) == 5 assert await _count() == 5 # --------------------------------------------------------------------------- -# insert_component (single) +# insert_one (single) # --------------------------------------------------------------------------- class TestInsertComponent: async def test_single_insert_persists(self, db): - doc = await _repo().insert_component(_attachment(3)) + doc = await _repo().insert_one(_attachment(3)) found = await Attachment.find_one(Attachment.id == doc.id) assert found is not None assert found.md5 == doc.md5 @@ -100,21 +100,21 @@ async def test_single_insert_persists(self, db): # --------------------------------------------------------------------------- -# delete_components / delete_one +# delete_many / delete_one # --------------------------------------------------------------------------- class TestDeleteComponents: async def test_filtered_delete_removes_only_matches(self, db): - keep, drop = await _repo().insert_components([_attachment(1), _attachment(2)]) - result = await _repo().delete_components(AttachmentFilter(md5=drop.md5)) + keep, drop = await _repo().insert_many([_attachment(1), _attachment(2)]) + result = await _repo().delete_many(AttachmentFilter(md5=drop.md5)) assert result.num_deleted == 1 remaining = {doc.md5 async for doc in Attachment.find_all()} assert remaining == {keep.md5} async def test_delete_by_id_removes_one(self, db): """The inherited base delete_one removes a single component by its primary key.""" - [doc] = await _repo().insert_components([_attachment(1)]) + [doc] = await _repo().insert_many([_attachment(1)]) result = await _repo().delete_one({"id": doc.id}) assert result.num_deleted == 1 assert await _count() == 0 @@ -127,7 +127,7 @@ async def test_delete_by_unknown_id_raises(self, db): async def test_delete_by_md5_removes_one(self, db): """A component is addressable by its content md5 (its declared identifier) as well as by id.""" - [doc] = await _repo().insert_components([_attachment(1)]) + [doc] = await _repo().insert_many([_attachment(1)]) result = await _repo().delete_one({"md5": doc.md5}) assert result.num_deleted == 1 assert await _count() == 0 @@ -140,14 +140,14 @@ async def test_delete_by_md5_removes_one(self, db): class TestAddressComponentByMd5: async def test_get_one_by_md5(self, db): - [doc] = await _repo().insert_components([_attachment(1)]) + [doc] = await _repo().insert_many([_attachment(1)]) by_md5 = await _repo().get_one({"md5": doc.md5}, fields=None) by_id = await _repo().get_one({"id": doc.id}, fields=None) assert by_md5 is not None assert by_md5.id == by_id.id == doc.id async def test_patch_one_by_md5(self, db): - [doc] = await _repo().insert_components([_attachment(1, name="data.csv")]) + [doc] = await _repo().insert_many([_attachment(1, name="data.csv")]) updated = await _repo().patch_one({"md5": doc.md5}, AttachmentPatch(name="renamed.png")) assert updated.name == "renamed.png" @@ -159,18 +159,18 @@ async def test_patch_one_by_md5(self, db): class TestPatchComponent: async def test_patch_updates_field(self, db): - [doc] = await _repo().insert_components([_attachment(1, name="data.csv")]) + [doc] = await _repo().insert_many([_attachment(1, name="data.csv")]) updated = await _repo().patch_one({"id": doc.id}, AttachmentPatch(name="renamed.png")) assert updated.name == "renamed.png" async def test_empty_patch_returns_existing(self, db): - [doc] = await _repo().insert_components([_attachment(1, name="data.csv")]) + [doc] = await _repo().insert_many([_attachment(1, name="data.csv")]) updated = await _repo().patch_one({"id": doc.id}, AttachmentPatch()) assert updated.id == doc.id async def test_patch_content_recomputes_md5(self, db): # name is not a hash field, so renaming must NOT change md5. - [doc] = await _repo().insert_components([_attachment(1)]) + [doc] = await _repo().insert_many([_attachment(1)]) renamed = await _repo().patch_one({"id": doc.id}, AttachmentPatch(name="renamed.png")) assert renamed.md5 == doc.md5 # content IS a hash field, so changing it must recompute md5. @@ -188,7 +188,7 @@ async def test_patch_content_recomputes_md5(self, db): class TestComponentDownload: async def test_jsonl_download_round_trips(self, db): """Component downloads stream a decompressable gzip of all rows.""" - await _repo().insert_components([_attachment(1), _attachment(2)]) + await _repo().insert_many([_attachment(1), _attachment(2)]) stream = _repo().download( format=DownloadFormat.JSONL, short_mime=ShortMimeFormat.GZ, @@ -231,7 +231,7 @@ async def test_table_frame_round_trips_via_storage_shape(self, db): attrs={"title": "g", "labels": {"index": "T [K]", "value": "σ", "variable": "doping"}}, data=frame, ) - [doc] = await repo.insert_components([tin]) + [doc] = await repo.insert_many([tin]) # Stored in the canonical MongoDB shape: index/columns/data as strings. raw = await db["tables"].find_one({"_id": doc.id}) diff --git a/mpcontribs-api/tests/integration/db/test_contributions_repository.py b/mpcontribs-api/tests/integration/db/test_contributions_repository.py index 031adf5d1..0c23cdd14 100644 --- a/mpcontribs-api/tests/integration/db/test_contributions_repository.py +++ b/mpcontribs-api/tests/integration/db/test_contributions_repository.py @@ -103,7 +103,7 @@ def _identity( # --------------------------------------------------------------------------- -# insert_contribution (single) +# insert_one (single) # --------------------------------------------------------------------------- @@ -129,7 +129,7 @@ async def test_fields_are_persisted(self, db): async def test_insert_via_repo(self, db): ci = _contrib_in(identifier="mp-4001") doc = Contribution.from_input_model(ci) - result = await _repo().insert_contribution(doc) + result = await _repo().insert_one(doc) found = await Contribution.find_one(Contribution.id == result.id) assert found is not None assert found.material_id == "mp-4001" @@ -145,7 +145,7 @@ class TestNullableIdentifierHierarchy: async def test_chemical_system_only_persists_with_null_identifiers(self, db): ci = _contrib_in(project="chem-only", material_id=None, formula=None) doc = Contribution.from_input_model(ci) - result = await _repo().insert_contribution(doc) + result = await _repo().insert_one(doc) found = await Contribution.find_one(Contribution.id == result.id) assert found is not None assert found.chemical_system_id == "Fe-O" @@ -155,7 +155,7 @@ async def test_chemical_system_only_persists_with_null_identifiers(self, db): async def test_existing_identities_matches_null_identity(self, db): ci = _contrib_in(project="chem-only", material_id=None, formula=None) - await _repo().insert_contribution(Contribution.from_input_model(ci)) + await _repo().insert_one(Contribution.from_input_model(ci)) key = ci.identity() found = await _repo().existing_identities([key]) assert key in found @@ -164,12 +164,12 @@ async def test_duplicate_chemical_system_only_collides_on_unique_index(self, db) from pymongo.errors import DuplicateKeyError ci = _contrib_in(project="chem-only", material_id=None, formula=None) - await _repo().insert_contribution(Contribution.from_input_model(ci)) + await _repo().insert_one(Contribution.from_input_model(ci)) dup = _contrib_in(project="chem-only", material_id=None, formula=None) # Same (project, chemical_system_id) with null material_id/formula/unique_value is one # unique key — the second insert must be rejected. with pytest.raises(DuplicateKeyError): - await _repo().insert_contribution(Contribution.from_input_model(dup)) + await _repo().insert_one(Contribution.from_input_model(dup)) # --------------------------------------------------------------------------- @@ -198,7 +198,7 @@ async def test_empty_list_raises_type_error(self, db): # --------------------------------------------------------------------------- -# get_contributions (scoped list + pagination + projection) +# get_many (scoped list + pagination + projection) # --------------------------------------------------------------------------- @@ -206,7 +206,7 @@ class TestGetContributions: async def test_admin_sees_private_and_public(self, db): p = await _insert(identifier="ga-pub", is_public=True) pr = await _insert(identifier="ga-priv", is_public=False) - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=_noop_filter(), fields=None ) ids = {str(c.id) for c in page.items} @@ -216,7 +216,7 @@ async def test_admin_sees_private_and_public(self, db): async def test_anonymous_sees_only_public(self, db): pub = await _insert(identifier="anon-pub", is_public=True) priv = await _insert(identifier="anon-priv", is_public=False) - page = await _repo(ANON).get_contributions( + page = await _repo(ANON).get_many( pagination=CursorParams(), filter=_noop_filter(), fields=None ) ids = {str(c.id) for c in page.items} @@ -226,7 +226,7 @@ async def test_anonymous_sees_only_public(self, db): async def test_authenticated_non_admin_sees_public(self, db): pub = await _insert(identifier="alice-pub", is_public=True) priv = await _insert(identifier="alice-priv", is_public=False) - page = await _repo(ALICE).get_contributions( + page = await _repo(ALICE).get_many( pagination=CursorParams(), filter=_noop_filter(), fields=None ) ids = {str(c.id) for c in page.items} @@ -235,7 +235,7 @@ async def test_authenticated_non_admin_sees_public(self, db): async def test_response_is_page_shape(self, db): await _insert(identifier="pg-shape") - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=_noop_filter(), fields=None ) assert hasattr(page, "items") @@ -244,7 +244,7 @@ async def test_response_is_page_shape(self, db): async def test_limit_respected(self, db): for i in range(5): await _insert(identifier=f"lim-{i:02d}", is_public=True) - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(limit=3), filter=_noop_filter(), fields=None ) assert len(page.items) <= 3 @@ -252,11 +252,11 @@ async def test_limit_respected(self, db): async def test_cursor_paginates_forward(self, db): for i in range(4): await _insert(identifier=f"cur-{i:02d}", is_public=True) - p1 = await _repo(ADMIN).get_contributions( + p1 = await _repo(ADMIN).get_many( pagination=CursorParams(limit=2), filter=_noop_filter(), fields=None ) assert p1.next_cursor is not None - p2 = await _repo(ADMIN).get_contributions( + p2 = await _repo(ADMIN).get_many( pagination=CursorParams(limit=2, cursor=p1.next_cursor), filter=_noop_filter(), fields=None ) ids1 = {str(c.id) for c in p1.items} @@ -269,7 +269,7 @@ async def test_all_items_covered_across_pages(self, db): identifiers: set[str] = set() cursor = None while True: - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(limit=2, cursor=cursor), filter=_noop_filter(), fields=None ) identifiers.update(c.material_id for c in page.items if c.material_id) @@ -281,7 +281,7 @@ async def test_all_items_covered_across_pages(self, db): async def test_next_cursor_none_on_last_page(self, db): for i in range(2): await _insert(identifier=f"last-pg-{i:02d}", is_public=True) - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(limit=100), filter=_noop_filter(), fields=None ) assert page.next_cursor is None @@ -289,7 +289,7 @@ async def test_next_cursor_none_on_last_page(self, db): async def test_projection_returns_only_requested_fields(self, db): await _insert(identifier="proj-fields", is_public=True) fields = ContributionOut.parse_fields(["formula"]) - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=_noop_filter(), fields=fields ) assert len(page.items) >= 1 @@ -301,7 +301,7 @@ async def test_filter_by_formula(self, db): await _insert(identifier="flt-fe", formula="Fe2O3", is_public=True) await _insert(identifier="flt-li", formula="Li2O", is_public=True) f = ContributionFilter(formula="Fe2O3") - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=f, fields=None ) formulas = {c.formula for c in page.items} @@ -311,7 +311,7 @@ async def test_filter_by_material_id_ilike(self, db): await _insert(identifier="ilike-abc", is_public=True) await _insert(identifier="ilike-xyz", is_public=True) f = ContributionFilter(material_id__ilike="ilike-a") - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=f, fields=None ) identifiers = {c.material_id for c in page.items} @@ -322,7 +322,7 @@ async def test_filter_by_is_public(self, db): await _insert(identifier="pub-only-pub", is_public=True) await _insert(identifier="pub-only-priv", is_public=False) f = ContributionFilter(is_public=True) - page = await _repo(ADMIN).get_contributions( + page = await _repo(ADMIN).get_many( pagination=CursorParams(), filter=f, fields=None ) assert all(c.is_public is True for c in page.items) @@ -497,7 +497,7 @@ async def test_anon_cannot_delete_private_doc(self, db): # --------------------------------------------------------------------------- -# delete_contributions (bulk with filter) +# delete_many (bulk with filter) # --------------------------------------------------------------------------- @@ -505,7 +505,7 @@ class TestDeleteContributions: async def test_bulk_delete_all(self, db): for i in range(3): await _insert(identifier=f"bdel-{i:02d}") - await _repo(ADMIN).delete_contributions(_noop_filter()) + await _repo(ADMIN).delete_many(_noop_filter()) remaining = await Contribution.find().to_list() assert len(remaining) == 0 @@ -513,18 +513,18 @@ async def test_bulk_delete_with_filter(self, db): await _insert(identifier="bdel-keep", formula="Li2O") await _insert(identifier="bdel-drop", formula="Fe2O3") f = ContributionFilter(formula="Fe2O3") - await _repo(ADMIN).delete_contributions(f) + await _repo(ADMIN).delete_many(f) remaining = await Contribution.find().to_list() assert len(remaining) == 1 assert remaining[0].material_id == "bdel-keep" async def test_bulk_delete_empty_collection_is_silent(self, db): - await _repo(ADMIN).delete_contributions(_noop_filter()) + await _repo(ADMIN).delete_many(_noop_filter()) async def test_scope_limits_what_anon_can_delete(self, db): await _insert(identifier="bdel-scope-pub", is_public=True) await _insert(identifier="bdel-scope-priv", is_public=False) - await _repo(ANON).delete_contributions(_noop_filter()) + await _repo(ANON).delete_many(_noop_filter()) # Anonymous scope: only public visible, so only the public doc is deleted. remaining = await Contribution.find().to_list() identifiers = {d.material_id for d in remaining} diff --git a/mpcontribs-api/tests/integration/db/test_initiatives_repository.py b/mpcontribs-api/tests/integration/db/test_initiatives_repository.py index d5151ba94..0177a2ca9 100644 --- a/mpcontribs-api/tests/integration/db/test_initiatives_repository.py +++ b/mpcontribs-api/tests/integration/db/test_initiatives_repository.py @@ -65,7 +65,7 @@ async def test_duplicate_slug_is_conflict(self, db): async def test_anonymous_cannot_create(self, db): with pytest.raises(PermissionError): - await _repo(ANON).insert_initiative(InitiativeIn(slug="anon-init", name="x")) + await _repo(ANON).insert_one(InitiativeIn(slug="anon-init", name="x")) async def test_invalid_slug_rejected(self, db): with pytest.raises(ValidationError): @@ -91,7 +91,7 @@ async def test_approved_do_not_count_against_quota(self, db): async def test_admin_is_exempt(self, db): limit = get_settings().domain.initiatives.max_unapproved_per_owner for i in range(limit + 2): - await _repo(ADMIN).insert_initiative(InitiativeIn(slug=f"admin-{i}", name="x")) + await _repo(ADMIN).insert_one(InitiativeIn(slug=f"admin-{i}", name="x")) # --------------------------------------------------------------------------- @@ -195,7 +195,7 @@ class TestListAndFilter: async def test_list_scoped_to_caller(self, db): await _insert("mine-1", ALICE) await _insert("bobs-1", BOB) # Bob's private initiative, invisible to Alice - page = await _repo(ALICE).get_initiatives(CursorParams(), InitiativeFilter(), fields=None) + page = await _repo(ALICE).get_many(CursorParams(), InitiativeFilter(), fields=None) slugs = {i.slug for i in page.items} assert "mine-1" in slugs assert "bobs-1" not in slugs @@ -204,7 +204,7 @@ async def test_filter_by_is_approved(self, db): await _insert("appr-1", ALICE) await _insert("unappr-1", ALICE) await _approve("appr-1") - page = await _repo(ADMIN).get_initiatives(CursorParams(), InitiativeFilter(is_approved=True), fields=None) + page = await _repo(ADMIN).get_many(CursorParams(), InitiativeFilter(is_approved=True), fields=None) slugs = {i.slug for i in page.items} assert "appr-1" in slugs assert "unappr-1" not in slugs @@ -212,7 +212,7 @@ async def test_filter_by_is_approved(self, db): async def test_filter_by_owner(self, db): await _insert("owned-alice", ALICE) await _insert("owned-bob", BOB) - page = await _repo(ADMIN).get_initiatives(CursorParams(), InitiativeFilter(owner=BOB_EMAIL), fields=None) + page = await _repo(ADMIN).get_many(CursorParams(), InitiativeFilter(owner=BOB_EMAIL), fields=None) assert {i.slug for i in page.items} == {"owned-bob"} diff --git a/mpcontribs-api/tests/integration/db/test_initiatives_service.py b/mpcontribs-api/tests/integration/db/test_initiatives_service.py index 2a59037d4..6d038534b 100644 --- a/mpcontribs-api/tests/integration/db/test_initiatives_service.py +++ b/mpcontribs-api/tests/integration/db/test_initiatives_service.py @@ -38,7 +38,7 @@ def _collaborator(slug: str, username: str = BOB_EMAIL) -> User: async def _insert_project(pid: str, owner: str = ALICE_EMAIL) -> Project: - return await MongoDbProjectRepository(ADMIN).insert_project( + return await MongoDbProjectRepository(ADMIN).insert_one( pid, ProjectIn( title=pid[:30], @@ -50,7 +50,7 @@ async def _insert_project(pid: str, owner: str = ALICE_EMAIL) -> Project: async def _insert_initiative(slug: str, owner_user: User = ALICE): - return await InitiativeRepository(owner_user).insert_initiative(InitiativeIn(slug=slug, name="Init")) + return await InitiativeRepository(owner_user).insert_one(InitiativeIn(slug=slug, name="Init")) def _assigned_id(project: Project): diff --git a/mpcontribs-api/tests/integration/db/test_project_groups_repository.py b/mpcontribs-api/tests/integration/db/test_project_groups_repository.py index 739cdaa52..958559fc9 100644 --- a/mpcontribs-api/tests/integration/db/test_project_groups_repository.py +++ b/mpcontribs-api/tests/integration/db/test_project_groups_repository.py @@ -45,7 +45,7 @@ def _group_in(name: str, owner: str = ALICE_EMAIL, **overrides) -> ProjectGroupI async def _insert(name: str, owner: str = ALICE_EMAIL, **overrides) -> ProjectGroup: - return await _repo(ADMIN).insert_project_group(_group_in(name, owner, **overrides)) + return await _repo(ADMIN).insert_one(_group_in(name, owner, **overrides)) # --------------------------------------------------------------------------- @@ -103,7 +103,7 @@ async def test_malformed_role_is_ignored(self, db): async def test_role_appears_in_listing(self, db): group = await _insert("role-list") - page = await _repo(_role_user(group.id)).get_project_groups( + page = await _repo(_role_user(group.id)).get_many( pagination=CursorParams(), filter=ProjectGroupFilter(), fields=None ) assert group.id in {g.id for g in page.items} @@ -183,14 +183,14 @@ async def test_bulk_deletes_all_matching_owner(self, db): await _insert("bulk-1") await _insert("bulk-2") await _insert("other", owner="google:bob@example.com") - result = await _repo(ADMIN).delete_project_groups( + result = await _repo(ADMIN).delete_many( filter=ProjectGroupFilter(owner=ALICE_EMAIL) ) assert result.num_deleted == 2 assert await ProjectGroup.find_one(ProjectGroup.owner == "google:bob@example.com") is not None async def test_no_match_returns_zero(self, db): - result = await _repo(ADMIN).delete_project_groups( + result = await _repo(ADMIN).delete_many( filter=ProjectGroupFilter(owner="google:nobody@example.com") ) assert result.num_deleted == 0 @@ -200,14 +200,14 @@ async def test_non_admin_bulk_restricted_to_own(self, db): # someone else must survive even though the filter would otherwise match it. await _insert("own-bulk", owner=ALICE_EMAIL, is_public=True) await _insert("other-bulk", owner=BOB_EMAIL, is_public=True) - result = await _repo(ALICE).delete_project_groups(filter=ProjectGroupFilter(is_public=True)) + result = await _repo(ALICE).delete_many(filter=ProjectGroupFilter(is_public=True)) assert result.num_deleted == 1 assert await ProjectGroup.find_one(ProjectGroup.name == "own-bulk") is None assert await ProjectGroup.find_one(ProjectGroup.name == "other-bulk") is not None # --------------------------------------------------------------------------- -# insert_project_group +# insert_one # # The input model carries no ``_id`` (the server assigns the ObjectId) and takes # plain project ids, which from_input_model resolves into stored Links/DBRefs. diff --git a/mpcontribs-api/tests/integration/db/test_project_groups_service.py b/mpcontribs-api/tests/integration/db/test_project_groups_service.py index 1ce0c1ed1..d368261b3 100644 --- a/mpcontribs-api/tests/integration/db/test_project_groups_service.py +++ b/mpcontribs-api/tests/integration/db/test_project_groups_service.py @@ -36,11 +36,11 @@ async def _insert_project(pid: str, owner: str = ALICE_EMAIL, **overrides): "owner": owner, } payload.update(overrides) - return await MongoDbProjectRepository(ADMIN).insert_project(pid, ProjectIn(**payload)) + return await MongoDbProjectRepository(ADMIN).insert_one(pid, ProjectIn(**payload)) async def _insert_group(name: str, owner: str = ALICE_EMAIL) -> ProjectGroup: - return await ProjectGroupRepository(ADMIN).insert_project_group( + return await ProjectGroupRepository(ADMIN).insert_one( ProjectGroupIn(name=name, owner=owner, projects=[], description="d") ) diff --git a/mpcontribs-api/tests/integration/db/test_projects_repository.py b/mpcontribs-api/tests/integration/db/test_projects_repository.py index f4761f487..e7402ea62 100644 --- a/mpcontribs-api/tests/integration/db/test_projects_repository.py +++ b/mpcontribs-api/tests/integration/db/test_projects_repository.py @@ -51,11 +51,11 @@ async def _insert(id: str, **overrides) -> Project: through overrides); the id comes from the path, and stats/columns keep their server defaults. """ project_in = _project_in(id, **overrides) - return await _repo(ADMIN).insert_project(id, project_in) + return await _repo(ADMIN).insert_one(id, project_in) # --------------------------------------------------------------------------- -# insert_project +# insert_one # --------------------------------------------------------------------------- @@ -88,7 +88,7 @@ class TestAuthorizationScope: async def test_admin_sees_all(self, db): await _insert("scope-priv", is_public=False) await _insert("scope-pub", is_public=True, is_approved=True) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=None) ids = {p.id for p in page.items} assert "scope-priv" in ids assert "scope-pub" in ids @@ -97,7 +97,7 @@ async def test_anonymous_only_sees_public_approved(self, db): await _insert("anon-priv", is_public=False) await _insert("anon-pub", is_public=True, is_approved=True) await _insert("anon-pub-unapproved", is_public=True, is_approved=False) - page = await _repo(ANON).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + page = await _repo(ANON).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=None) ids = {p.id for p in page.items} assert "anon-pub" in ids assert "anon-priv" not in ids @@ -107,7 +107,7 @@ async def test_authenticated_sees_own_and_public(self, db): await _insert("auth-alice-priv", owner="google:alice@example.com", is_public=False) await _insert("auth-bob-priv", owner="google:bob@example.com", is_public=False) await _insert("auth-pub", is_public=True, is_approved=True) - page = await _repo(ALICE).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + page = await _repo(ALICE).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=None) ids = {p.id for p in page.items} assert "auth-alice-priv" in ids assert "auth-pub" in ids @@ -148,7 +148,7 @@ async def test_anon_cannot_get_private_project(self, db): # --------------------------------------------------------------------------- -# get_projects — id filtering +# get_many — id filtering # # Regression: Beanie stores the primary key under Mongo's ``_id`` (``id`` is an # alias), but fastapi-filter keys queries on the raw field name. Without the @@ -162,7 +162,7 @@ async def test_filter_by_id_matches(self, db): from mpcontribs_api.domains.projects.models import ProjectFilter await _insert("filter-id-hit") - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(id="filter-id-hit"), pagination=CursorParams(), fields=None ) assert {p.id for p in page.items} == {"filter-id-hit"} @@ -173,7 +173,7 @@ async def test_filter_by_id_in_matches(self, db): await _insert("filter-id-in-a") await _insert("filter-id-in-b") await _insert("filter-id-in-c") - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(id__in=["filter-id-in-a", "filter-id-in-b"]), pagination=CursorParams(), fields=None, @@ -185,7 +185,7 @@ async def test_filter_by_id_neq_excludes(self, db): await _insert("filter-id-neq-keep") await _insert("filter-id-neq-drop") - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(id__neq="filter-id-neq-drop"), pagination=CursorParams(), fields=None ) ids = {p.id for p in page.items} @@ -194,7 +194,7 @@ async def test_filter_by_id_neq_excludes(self, db): # --------------------------------------------------------------------------- -# get_projects — tags filtering +# get_many — tags filtering # # ``tags__contains`` maps to MongoDB ``$all``: a project matches only when its # tags are a superset of every value supplied (the query list is a subset of @@ -210,7 +210,7 @@ async def test_contains_requires_all_tags_as_subset(self, db): await _insert("tags-superset", tags=["alpha", "beta", "gamma"]) await _insert("tags-partial", tags=["alpha", "beta"]) await _insert("tags-none", tags=["delta"]) - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(tags__contains=["alpha", "gamma"]), pagination=CursorParams(), fields=None, @@ -222,7 +222,7 @@ async def test_contains_single_tag(self, db): await _insert("tags-single-hit", tags=["alpha", "beta"]) await _insert("tags-single-miss", tags=["beta", "gamma"]) - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(tags__contains=["alpha"]), pagination=CursorParams(), fields=None, @@ -236,7 +236,7 @@ async def test_contains_parses_comma_string(self, db): await _insert("tags-csv-miss", tags=["alpha"]) # FilterDepends collapses the list query param to a comma string; the # BaseFilter validator must re-expand it. - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=ProjectFilter(tags__contains="alpha,beta"), pagination=CursorParams(), fields=None, @@ -253,7 +253,7 @@ class TestFieldProjection: async def test_projection_returns_only_requested_fields(self, db): await _insert("proj-fields", is_public=True, is_approved=True) fields = ProjectOut.parse_fields(["title"]) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=fields) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=fields) assert len(page.items) == 1 item = page.items[0] assert item.title == "proj-fields" @@ -262,7 +262,7 @@ async def test_projection_returns_only_requested_fields(self, db): async def test_no_projection_returns_all_fields(self, db): await _insert("proj-all", is_public=True, is_approved=True) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=None) item = page.items[0] assert item.title is not None assert item.authors is not None @@ -279,27 +279,27 @@ async def test_limit_is_respected(self, db): # project under its own owner rather than tripping max_projects. for i in range(5): await _insert(f"pag-limit-{i:02d}", owner=f"google:pager{i}@example.com", is_public=True, is_approved=True) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=3), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(limit=3), fields=None) assert len(page.items) == 3 async def test_next_cursor_set_when_more_items(self, db): for i in range(4): await _insert(f"pag-cursor-{i:02d}", owner=f"google:pager{i}@example.com", is_public=True, is_approved=True) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) assert page.next_cursor is not None async def test_next_cursor_none_on_last_page(self, db): for i in range(3): await _insert(f"pag-last-{i:02d}", owner=f"google:pager{i}@example.com", is_public=True, is_approved=True) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=10), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(limit=10), fields=None) assert page.next_cursor is None async def test_cursor_fetches_next_page(self, db): for i in range(4): await _insert(f"pag-next-{i:02d}", owner=f"google:pager{i}@example.com", is_public=True, is_approved=True) - page1 = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) + page1 = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(limit=2), fields=None) assert page1.next_cursor is not None - page2 = await _repo(ADMIN).get_projects( + page2 = await _repo(ADMIN).get_many( filter=_noop_filter(), pagination=CursorParams(limit=2, cursor=page1.next_cursor), fields=None ) ids1 = {p.id for p in page1.items} @@ -312,7 +312,7 @@ async def test_all_items_covered_across_pages(self, db): all_ids: set[str] = set() cursor = None while True: - page = await _repo(ADMIN).get_projects( + page = await _repo(ADMIN).get_many( filter=_noop_filter(), pagination=CursorParams(limit=2, cursor=cursor), fields=None ) all_ids.update(p.id for p in page.items) @@ -363,7 +363,7 @@ class TestDeleteProject: async def test_deleted_project_not_in_default_query(self, db): await _insert("del-me", is_public=True, is_approved=True) await _repo(ADMIN).delete_one({"id": "del-me"}) - page = await _repo(ADMIN).get_projects(filter=_noop_filter(), pagination=CursorParams(), fields=None) + page = await _repo(ADMIN).get_many(filter=_noop_filter(), pagination=CursorParams(), fields=None) ids = {p.id for p in page.items} assert "del-me" not in ids @@ -678,10 +678,10 @@ async def test_injected_consumer_override_lowers_cap(self, db): # A per-consumer override resolves to a ConsumerSettings injected into the repo; the cap it # carries is enforced without touching global config. Here the override tightens the cap to 1. repo = _repo(ALICE, ConsumerSettings(max_projects=1)) - await repo.insert_project("override-1", _project_in("override-1", owner=ALICE_EMAIL)) + await repo.insert_one("override-1", _project_in("override-1", owner=ALICE_EMAIL)) from mpcontribs_api.exceptions import PermissionError as AppPermissionError with pytest.raises(AppPermissionError): - await repo.insert_project("override-2", _project_in("override-2", owner=ALICE_EMAIL)) + await repo.insert_one("override-2", _project_in("override-2", owner=ALICE_EMAIL)) diff --git a/mpcontribs-api/tests/integration/db/test_stats_recompute.py b/mpcontribs-api/tests/integration/db/test_stats_recompute.py index cbc216962..61b5665d0 100644 --- a/mpcontribs-api/tests/integration/db/test_stats_recompute.py +++ b/mpcontribs-api/tests/integration/db/test_stats_recompute.py @@ -67,7 +67,7 @@ async def _make_project() -> Project: description="Recompute lifecycle fixture", owner="google:admin@example.com", ) - return await MongoDbProjectRepository(ADMIN).insert_project(PID, project_in) + return await MongoDbProjectRepository(ADMIN).insert_one(PID, project_in) def _structure(charge: float | None) -> StructureIn: @@ -151,7 +151,7 @@ async def test_full_lifecycle(self, db, mongo_client): await _assert_empty() # --- insert one contribution with two structures + two tables (+ data) --- - summary = await svc.insert_contributions( + summary = await svc.insert_many( [ _contrib_in( "with-components", @@ -177,7 +177,7 @@ async def test_full_lifecycle(self, db, mongo_client): assert (cols["energy"].min, cols["energy"].max) == (-5.0, -5.0) # --- remove it -> back to empty --- - # NOTE: svc.delete_contributions() cannot delete a contribution that references a real table + # NOTE: svc.delete_many() cannot delete a contribution that references a real table # component: its cascade re-reads the contribution through ContributionFilter, whose nested # component sub-filters make Beanie fetch the linked components, and the fetched Table.data # frame fails to round-trip (Table.data is a PolarsFrame that pymongo stores as bare column @@ -192,7 +192,7 @@ async def test_full_lifecycle(self, db, mongo_client): await _assert_empty() # --- insert one contribution with no components --- - summary = await svc.insert_contributions([_contrib_in("no-components", data={"band_gap": 3.0})]) + summary = await svc.insert_many([_contrib_in("no-components", data={"band_gap": 3.0})]) assert summary.failed == [] project = await _project() assert project.stats.contributions == 1 @@ -204,17 +204,17 @@ async def test_full_lifecycle(self, db, mongo_client): assert (cols["band_gap"].min, cols["band_gap"].max) == (3.0, 3.0) # --- remove it -> empty again --- - deleted = await svc.delete_contributions(ContributionFilter(id=summary.succeeded[0].id)) + deleted = await svc.delete_many(ContributionFilter(id=summary.succeeded[0].id)) assert deleted.num_deleted == 1 await _assert_empty() # --- add two contributions whose data overlaps ("shared") and diverges ("b" is new) --- - await svc.insert_contributions([_contrib_in("c-one", data={"a": 1.0, "shared": 5.0})]) + await svc.insert_many([_contrib_in("c-one", data={"a": 1.0, "shared": 5.0})]) project = await _project() assert project.stats.contributions == 1 assert set(_columns_by_path(project)) == {"a", "shared"} - await svc.insert_contributions([_contrib_in("c-two", data={"shared": 9.0, "b": 2.0})]) + await svc.insert_many([_contrib_in("c-two", data={"shared": 9.0, "b": 2.0})]) project = await _project() assert project.stats.contributions == 2 cols = _columns_by_path(project)