diff --git a/AGENTS.md b/AGENTS.md index 64b03ef7e..ab07dce44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,7 +316,7 @@ class CasCollection(BaseCollection): - Beta badge: `(๐Ÿงช Beta)` (with a space). - No em dashes (`โ€”`); use commas, colons, or parentheses (org language policy). -- Docstrings must describe **what** a method does from the caller's perspective โ€” never mention internal implementation details or backend API specifics (e.g. diffing, patching, HTTP methods, "returned by the API"). +- Docstrings must describe **what** a method does from the caller's perspective โ€” never how it is wired to the backend. Banned terms in public docstrings: `endpoint`, `route`, HTTP verbs (`GET`, `POST`, `PATCH`, `DELETE`), HTTP status codes (`HTTP 200`, `404`), `request` / `response`, `payload`, `JSON`, `diffing`, `patching`, and phrasing like "returned by the API". Keep code comments and log messages free of these too whenever a caller-neutral phrasing exists. The standard `session` Parameters line and `base_path` Attributes line ("The base API route for \ requests") are the sanctioned exceptions. - Wrong: `"""Update an attachment by diffing the current server state."""` - Right: `"""Update an attachment."""` - Wrong: `The updated attachment returned by the API.` diff --git a/docs/collections/personalization.md b/docs/collections/personalization.md new file mode 100644 index 000000000..0c35a4cb1 --- /dev/null +++ b/docs/collections/personalization.md @@ -0,0 +1 @@ +::: albert.collections.personalization.PersonalizationCollection diff --git a/docs/resources/personalization.md b/docs/resources/personalization.md new file mode 100644 index 000000000..9559492d8 --- /dev/null +++ b/docs/resources/personalization.md @@ -0,0 +1 @@ +::: albert.resources.personalization diff --git a/mkdocs.yml b/mkdocs.yml index 9581e46ca..7ec443ad4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -179,6 +179,7 @@ nav: - Parameter Groups: collections/parameter_groups.md - Parameters: collections/parameters.md - PDF Generator: collections/pdf_generator.md + - Personalization: collections/personalization.md - Pricings: collections/pricings.md - Product Design: collections/product_design.md - Projects: collections/projects.md @@ -238,6 +239,7 @@ nav: - Parameter Groups: resources/parameter_groups.md - Parameters: resources/parameters.md - PDF Generator: resources/pdf_generator.md + - Personalization: resources/personalization.md - Pricings: resources/pricings.md - Product Design: resources/product_design.md - Projects: resources/projects.md diff --git a/src/albert/client.py b/src/albert/client.py index 515e70809..9dbb60b32 100644 --- a/src/albert/client.py +++ b/src/albert/client.py @@ -37,6 +37,7 @@ from albert.collections.parameter_groups import ParameterGroupCollection from albert.collections.parameters import ParameterCollection from albert.collections.pdf_generator import PDFGeneratorCollection +from albert.collections.personalization import PersonalizationCollection from albert.collections.pricings import PricingCollection from albert.collections.product_design import ProductDesignCollection from albert.collections.projects import ProjectCollection @@ -332,6 +333,10 @@ def parameter_groups(self) -> ParameterGroupCollection: def parameters(self) -> ParameterCollection: return ParameterCollection(session=self.session) + @property + def personalization(self) -> PersonalizationCollection: + return PersonalizationCollection(session=self.session) + @property def property_data(self) -> PropertyDataCollection: return PropertyDataCollection(session=self.session) diff --git a/src/albert/collections/personalization.py b/src/albert/collections/personalization.py new file mode 100644 index 000000000..86205a004 --- /dev/null +++ b/src/albert/collections/personalization.py @@ -0,0 +1,231 @@ +from collections.abc import Iterator +from typing import Any + +from pydantic import validate_call + +from albert.collections.base import BaseCollection +from albert.collections.users import UserCollection +from albert.core.pagination import AlbertPaginator +from albert.core.session import AlbertSession +from albert.core.shared.enums import PaginationMode +from albert.resources.personalization import Personalization, PersonalizationCategory + +# The list handler parses `limit` with no fallback, so the SDK always sends a page size. +_PERSONALIZATION_PAGE_LIMIT = 200 + + +class PersonalizationCollection(BaseCollection): + """Manage user personalization records in the Albert platform. + + Personalization records store per-user preferences such as starred projects, + hidden rows, and saved filters. Records always belong to a user: records + created or listed without an explicit user belong to the current user. + + This collection is accessed as ``client.personalization``. + + !!! example + ```python + from albert import Albert + from albert.resources.personalization import Personalization, PersonalizationCategory + + client = Albert() + record = client.personalization.create( + personalization=Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id="PRO123", + saved_name="Weatherproof Coatings 2026", + ) + ) + print(record.id) + ``` + + Parameters + ---------- + session : AlbertSession + The authenticated Albert session used for API calls. + + Attributes + ---------- + base_path : str + The base API route for personalization requests. + + Methods + ------- + create(personalization) -> Personalization + Create a new personalization record for the current user. + get_by_id(id) -> Personalization + Get a single personalization record by its ID. + get_all(...) -> Iterator[Personalization] + Get personalization records, with optional filters. + delete(id) -> None + Delete a personalization record by its ID. + """ + + _api_version = "v3" + + def __init__(self, *, session: AlbertSession): + super().__init__(session=session) + self.base_path = f"/api/{PersonalizationCollection._api_version}/personalization" + + @validate_call + def create(self, *, personalization: Personalization) -> Personalization: + """Create a new personalization record for the current user. + + !!! example + ```python + from albert.resources.personalization import Personalization, PersonalizationCategory + + record = client.personalization.create( + personalization=Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id="PRO123", + saved_name="Weatherproof Coatings 2026", + ) + ) + record.id + # 'USP123' + ``` + + Parameters + ---------- + personalization : Personalization + The record to create. Requires ``category`` plus the fields the + category stores (e.g. ``saved_id`` and ``saved_name`` for a starred + project). + + Returns + ------- + Personalization + The created record, populated with its assigned ID. + """ + payload = personalization.model_dump( + by_alias=True, exclude_none=True, exclude_unset=True, mode="json" + ) + response = self.session.post(self.base_path, json=[payload]) + data = response.json()[0] + # The create response omits `category`; the value just sent is authoritative. + data.setdefault("category", payload["category"]) + return Personalization(**data) + + @validate_call + def get_by_id(self, *, id: str) -> Personalization: + """Get a personalization record by its ID. + + !!! example + ```python + record = client.personalization.get_by_id(id="USP123") + record.category + # + ``` + + Parameters + ---------- + id : str + The ID of the record to retrieve (format ``USP...``). + + Returns + ------- + Personalization + The fully populated record. + """ + response = self.session.get(f"{self.base_path}/{id}") + return Personalization(**response.json()) + + @validate_call + def get_all( + self, + *, + category: PersonalizationCategory | None = None, + sub_category: str | None = None, + user_id: str | None = None, + max_items: int | None = None, + ) -> Iterator[Personalization]: + """Get personalization records, with optional filters. + + When ``user_id`` is not given, records of the current user are returned. + + !!! example + ```python + from albert.resources.personalization import PersonalizationCategory + + for record in client.personalization.get_all( + category=PersonalizationCategory.STARRED_PROJECTS + ): + print(record.id, record.saved_id) + ``` + + Parameters + ---------- + category : PersonalizationCategory, optional + Only return records in this category. + sub_category : str, optional + Only return records in this subcategory. Requires ``category``. + user_id : str, optional + Only return records belonging to this user. Defaults to the current + user. + max_items : int, optional + Maximum number of records to return in total. If None, returns all + matching records. + + Returns + ------- + Iterator[Personalization] + An iterator of matching records. + + Raises + ------ + ValueError + If ``sub_category`` is given without ``category``. + """ + params = self._get_all_params( + category=category, sub_category=sub_category, user_id=user_id + ) + if "createdBy" not in params: + params["createdBy"] = UserCollection(session=self.session).get_current_user().id + return AlbertPaginator( + mode=PaginationMode.KEY, + path=self.base_path, + session=self.session, + params=params, + max_items=max_items, + deserialize=lambda items: [Personalization(**item) for item in items], + ) + + @validate_call + def delete(self, *, id: str) -> None: + """Delete a personalization record by its ID. + + !!! example + ```python + client.personalization.delete(id="USP123") + ``` + + Parameters + ---------- + id : str + The ID of the record to delete (format ``USP...``). + + Returns + ------- + None + """ + self.session.delete(f"{self.base_path}/{id}") + + @staticmethod + def _get_all_params( + *, + category: PersonalizationCategory | None, + sub_category: str | None, + user_id: str | None, + ) -> dict[str, Any]: + """Build the query parameters for listing personalization records.""" + if sub_category and not category: + raise ValueError("`category` is required when `sub_category` is provided.") + params: dict[str, Any] = {"limit": _PERSONALIZATION_PAGE_LIMIT} + if user_id: + params["createdBy"] = user_id + if category: + params["category"] = category.value + if sub_category: + params["subCategory"] = sub_category + return params diff --git a/src/albert/collections/projects.py b/src/albert/collections/projects.py index 62d886015..39da97646 100644 --- a/src/albert/collections/projects.py +++ b/src/albert/collections/projects.py @@ -1,17 +1,20 @@ -from collections.abc import Iterator +from collections.abc import Iterable, Iterator +from contextlib import suppress from typing import Any from pydantic import validate_call from albert.collections.base import BaseCollection +from albert.collections.personalization import PersonalizationCollection from albert.core.logging import logger from albert.core.pagination import AlbertPaginator, MappedPaginator from albert.core.session import AlbertSession from albert.core.shared.enums import OrderBy, PaginationMode from albert.core.shared.identifiers import ProjectId, SearchProjectId from albert.core.utils import ensure_list -from albert.exceptions import AlbertHTTPError +from albert.exceptions import AlbertHTTPError, BadRequestError, NotFoundError from albert.resources.acls import ACL +from albert.resources.personalization import Personalization, PersonalizationCategory from albert.resources.projects import DocumentSearchItem, Project, ProjectSearchItem @@ -71,6 +74,12 @@ class ProjectCollection(BaseCollection): Same filters as search, but returns fully populated projects (slower). document_search(...) -> Iterator[DocumentSearchItem] Search documents (attachments) linked to a project. + star(id) -> Project + Star (pin, favorite) a project for the current user. + unstar(id) -> None + Remove a project from the current user's starred (pinned, favorited) projects. + get_starred(...) -> Iterator[Project] + Get the current user's starred (pinned, favorited) projects. """ _api_version = "v3" @@ -307,6 +316,132 @@ def reactivate(self, *, id: ProjectId) -> Project: self.session.patch(url) return self.get_by_id(id=id) + @validate_call + def star(self, *, id: ProjectId) -> Project: + """Star (pin, favorite) a project for the current user. + + Starring a project adds it to the current user's starred projects list. + If the project is already starred, this method leaves it starred. + + !!! example + ```python + project = client.projects.star(id="PRO123") + project.description + # 'Weatherproof Coatings 2026' + ``` + + Parameters + ---------- + id : ProjectId + The Project ID (format ``PRO...``, e.g. ``"PRO123"``). + + Returns + ------- + Project + The fully populated starred Project. + """ + project = self.get_by_id(id=id) + record = Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id=project.id, + saved_name=project.description, + ) + try: + PersonalizationCollection(session=self.session).create(personalization=record) + except BadRequestError as e: + if not self._is_already_starred_error(e): + raise + return project + + @validate_call + def unstar(self, *, id: ProjectId) -> None: + """Remove a project from the current user's starred (pinned, favorited) projects. + + If the project is not starred, this method does nothing. + + !!! example + ```python + client.projects.unstar(id="PRO123") + ``` + + Parameters + ---------- + id : ProjectId + The Project ID (format ``PRO...``, e.g. ``"PRO123"``). + + Returns + ------- + None + """ + personalizations = PersonalizationCollection(session=self.session) + records = personalizations.get_all(category=PersonalizationCategory.STARRED_PROJECTS) + for record_id in self._starred_record_ids(records=records, project_id=id): + with suppress(NotFoundError): + personalizations.delete(id=record_id) + + @validate_call + def get_starred(self, *, max_items: int | None = None) -> Iterator[Project]: + """Get the current user's starred (pinned, favorited) projects. + + Yields fully populated [`Project`][albert.resources.projects.Project] entities + belonging to the current user's starred list. + + !!! example + ```python + for project in client.projects.get_starred(): + print(project.id, project.description) + # PRO123 Weatherproof Coatings 2026 + ``` + + Parameters + ---------- + max_items : int, optional + Maximum number of projects to return in total. If None, returns all + starred projects. + + Returns + ------- + Iterator[Project] + An iterator of fully populated Project entities. + """ + + def _hydrate(record: Personalization) -> Project | None: + if not record.saved_id: + return None + try: + return self.get_by_id(id=record.saved_id) + except AlbertHTTPError as e: + logger.warning(f"Error fetching starred project {record.saved_id}: {e}") + return None + + records = PersonalizationCollection(session=self.session).get_all( + category=PersonalizationCategory.STARRED_PROJECTS, max_items=max_items + ) + return MappedPaginator(records, _hydrate) + + @staticmethod + def _starred_record_ids(*, records: Iterable[Personalization], project_id: str) -> list[str]: + """Return the personalization record IDs that star ``project_id``.""" + target = project_id.upper() + return [ + record.id + for record in records + if record.id and (record.saved_id or "").upper() == target + ] + + @staticmethod + def _is_already_starred_error(error: BadRequestError) -> bool: + """Return True if ``error`` reports that the project is already starred.""" + try: + payload = error.response.json() + except ValueError: + return False + errors = payload.get("errors") if isinstance(payload, dict) else None + return any( + isinstance(item, dict) and "savedId already exist" in str(item.get("msg", "")) + for item in errors or [] + ) + @validate_call def search( self, diff --git a/src/albert/resources/personalization.py b/src/albert/resources/personalization.py new file mode 100644 index 000000000..25d655b8e --- /dev/null +++ b/src/albert/resources/personalization.py @@ -0,0 +1,85 @@ +from enum import Enum +from typing import Any + +from pydantic import AliasChoices, Field + +from albert.core.base import BaseAlbertModel +from albert.core.shared.models.base import BaseResource + + +class PersonalizationCategory(str, Enum): + """The category of a personalization record. + + - ``STARRED_PROJECTS``: projects the user has starred (pinned). + - ``HIDDEN_ROWS``: projects or inventory items the user has hidden. + - ``SAVED_FILTERS``: saved filter views, grouped by ``sub_category``. + - ``USER_CONFIGURATION``: the user's configuration (one record per user). + - ``NOTIFICATION``: the user's notification preferences (one record per user). + - ``SKILLS``: the user's disabled-skills list (one record per user). + """ + + STARRED_PROJECTS = "Starred Projects" + HIDDEN_ROWS = "Hidden Rows" + SAVED_FILTERS = "Saved Filters" + USER_CONFIGURATION = "User Configuration" + NOTIFICATION = "Notification" + SKILLS = "Skills" + + +class PersonalizationFilter(BaseAlbertModel): + """A saved filter entry on a personalization record.""" + + filter_name: str | None = Field(default=None, alias="filterName") + """The display name of the filter.""" + + url: str | None = Field(default=None) + """The URL the filter points to.""" + + filter_type: str | None = Field(default=None, alias="filterType") + """The type of the filter (e.g. ``Default`` or ``Custom``).""" + + +class Personalization(BaseResource): + """A user personalization record in Albert. + + Personalization records store per-user preferences such as starred projects, + hidden rows, and saved filters. They are managed through the + [`PersonalizationCollection`][albert.collections.personalization.PersonalizationCollection]. + + !!! example + ```python + from albert.resources.personalization import Personalization, PersonalizationCategory + + record = Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id="PRO123", + saved_name="Weatherproof Coatings 2026", + ) + ``` + """ + + id: str | None = Field( + default=None, alias="albertId", validation_alias=AliasChoices("albertId", "id") + ) + """The Albert ID of the record (format ``USP...``). Assigned by Albert when the record is created.""" + + category: PersonalizationCategory + """The category of the record.""" + + sub_category: str | None = Field(default=None, alias="subCategory") + """The subcategory of the record (e.g. the entity a saved filter applies to).""" + + parent_id: str | None = Field(default=None, alias="parentId") + """The ID of the user the record belongs to. Assigned from the authenticated session when the record is created.""" + + saved_id: str | None = Field(default=None, alias="savedId") + """The ID of the saved entity (e.g. a Project ID for a starred project).""" + + saved_name: str | None = Field(default=None, alias="savedName") + """The display name of the saved entity.""" + + metadata: dict[str, Any] | None = Field(default=None, alias="Metadata") + """Arbitrary metadata key-value pairs stored on the record.""" + + filters: list[PersonalizationFilter] | None = Field(default=None) + """Saved filter entries, present on records in the Saved Filters category.""" diff --git a/tests/integration/collections/test_personalization.py b/tests/integration/collections/test_personalization.py new file mode 100644 index 000000000..8b94491e4 --- /dev/null +++ b/tests/integration/collections/test_personalization.py @@ -0,0 +1,54 @@ +from contextlib import suppress + +import pytest + +from albert.client import Albert +from albert.core.shared.models.base import EntityLink +from albert.exceptions import NotFoundError +from albert.resources.personalization import Personalization, PersonalizationCategory +from albert.resources.projects import Project + +pytestmark = pytest.mark.xdist_group("projects") + + +def test_personalization_crud(client: Albert, seeded_locations, seed_prefix: str): + """Test creating, reading, listing, and deleting a personalization record.""" + project = client.projects.create( + project=Project( + description=f"{seed_prefix} - Personalization Target", + locations=[EntityLink(id=seeded_locations[1].id)], + ) + ) + record = None + try: + record = client.personalization.create( + personalization=Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id=project.id, + saved_name=project.description, + ) + ) + assert record.id and record.id.startswith("USP") + assert record.category is PersonalizationCategory.STARRED_PROJECTS + assert record.saved_id == project.id + + fetched = client.personalization.get_by_id(id=record.id) + assert fetched.id == record.id + assert fetched.saved_id == project.id + + starred = list( + client.personalization.get_all(category=PersonalizationCategory.STARRED_PROJECTS) + ) + assert record.id in {r.id for r in starred} + assert all(r.category is PersonalizationCategory.STARRED_PROJECTS for r in starred) + + client.personalization.delete(id=record.id) + record, deleted_id = None, record.id + with pytest.raises(NotFoundError): + client.personalization.get_by_id(id=deleted_id) + finally: + if record is not None: + with suppress(NotFoundError): + client.personalization.delete(id=record.id) + with suppress(NotFoundError): + client.projects.delete(id=project.id) diff --git a/tests/integration/collections/test_projects.py b/tests/integration/collections/test_projects.py index 15bb44783..4e2057865 100644 --- a/tests/integration/collections/test_projects.py +++ b/tests/integration/collections/test_projects.py @@ -229,3 +229,39 @@ def test_reactivate_project(client: Albert, seeded_locations, seed_prefix: str): finally: with suppress(NotFoundError): client.projects.delete(id=project.id) + + +def test_star_get_starred_and_unstar_project(client: Albert, seeded_locations, seed_prefix: str): + """Test starring, getting starred, and unstarring a project for the current user.""" + project = client.projects.create( + project=Project( + description=f"{seed_prefix} - Project to Star", + locations=[EntityLink(id=seeded_locations[1].id)], + ) + ) + try: + starred = client.projects.star(id=project.id) + assert isinstance(starred, Project) + assert starred.id == project.id + + again = client.projects.star(id=project.id) + assert again.id == project.id + + starred_ids = poll_until( + lambda: [p.id for p in client.projects.get_starred() if p.id == project.id] + ) + assert starred_ids == [project.id] + + client.projects.unstar(id=project.id) + remaining = poll_until( + lambda: [p.id for p in client.projects.get_starred()], + predicate=lambda ids: project.id not in ids, + ) + assert project.id not in remaining + + client.projects.unstar(id=project.id) + finally: + with suppress(NotFoundError): + client.projects.unstar(id=project.id) + with suppress(NotFoundError): + client.projects.delete(id=project.id) diff --git a/tests/unit/collections/test_personalization.py b/tests/unit/collections/test_personalization.py new file mode 100644 index 000000000..786276746 --- /dev/null +++ b/tests/unit/collections/test_personalization.py @@ -0,0 +1,52 @@ +"""Unit tests for PersonalizationCollection private helpers. + +Covers the pure ``_get_all_params`` query builder, which owns the list +endpoint's filter-combination rules, with no I/O to fake. +""" + +import pytest + +from albert.collections.personalization import ( + _PERSONALIZATION_PAGE_LIMIT, + PersonalizationCollection, +) +from albert.resources.personalization import PersonalizationCategory + + +def test_get_all_params_default_sends_only_page_limit() -> None: + """Test that no filters produce a bare paginated request.""" + params = PersonalizationCollection._get_all_params( + category=None, sub_category=None, user_id=None + ) + + assert params == {"limit": _PERSONALIZATION_PAGE_LIMIT} + + +def test_get_all_params_sub_category_requires_category() -> None: + """Test that sub_category without category is rejected.""" + with pytest.raises(ValueError, match="`category` is required"): + PersonalizationCollection._get_all_params(category=None, sub_category="Task", user_id=None) + + +def test_get_all_params_user_id_maps_to_created_by() -> None: + """Test that user_id maps to its wire parameter name.""" + params = PersonalizationCollection._get_all_params( + category=None, sub_category=None, user_id="USR1" + ) + + assert params["createdBy"] == "USR1" + + +def test_get_all_params_category_serializes_to_wire_value() -> None: + """Test that the category enum is sent as its wire string.""" + params = PersonalizationCollection._get_all_params( + category=PersonalizationCategory.STARRED_PROJECTS, + sub_category=None, + user_id="USR1", + ) + + assert params == { + "limit": _PERSONALIZATION_PAGE_LIMIT, + "createdBy": "USR1", + "category": "Starred Projects", + } diff --git a/tests/unit/collections/test_projects.py b/tests/unit/collections/test_projects.py index 3342e05d8..6acb9c072 100644 --- a/tests/unit/collections/test_projects.py +++ b/tests/unit/collections/test_projects.py @@ -1,12 +1,21 @@ -"""Unit tests for ProjectCollection ACL PATCH payload generation. +"""Unit tests for ProjectCollection private helpers. Allowed under the patch-builder exception in OPINIONS.md: these guard -non-obvious diff behavior in ``_generate_acl_patch_operations`` with no I/O to -fake. +non-obvious diff behavior in ``_generate_acl_patch_operations`` and the +starred-project record matching / duplicate-star detection helpers, with no +I/O to fake. """ +import json + +import pytest +import requests + from albert.collections.projects import ProjectCollection +from albert.exceptions import BadRequestError from albert.resources.acls import ACL, AccessControlLevel +from albert.resources.personalization import Personalization, PersonalizationCategory +from tests.unit.conftest import UNIT_BASE_URL def test_no_acl_changes_emit_no_ops(offline_session) -> None: @@ -159,3 +168,58 @@ def test_none_fgc_on_both_sides_emits_no_op(offline_session) -> None: ) assert ops == [] + + +def test_starred_record_ids_match_project_case_insensitively() -> None: + """Test that starred-record lookup matches the project ID regardless of case.""" + records = [ + Personalization( + id="USP1", category=PersonalizationCategory.STARRED_PROJECTS, saved_id="PRO1" + ), + Personalization( + id="USP2", category=PersonalizationCategory.STARRED_PROJECTS, saved_id="pro2" + ), + Personalization( + id="USP3", category=PersonalizationCategory.STARRED_PROJECTS, saved_id="PRO2" + ), + ] + + ids = ProjectCollection._starred_record_ids(records=records, project_id="PRO2") + + assert ids == ["USP2", "USP3"] + + +def test_starred_record_ids_empty_when_project_not_starred() -> None: + """Test that an unstarred project yields no record IDs, so unstar does nothing.""" + records = [ + Personalization( + id="USP1", category=PersonalizationCategory.STARRED_PROJECTS, saved_id="PRO1" + ), + Personalization(category=PersonalizationCategory.STARRED_PROJECTS, saved_id="PRO2"), + ] + + ids = ProjectCollection._starred_record_ids(records=records, project_id="PRO2") + + assert ids == [] + + +def _bad_request(body: object) -> BadRequestError: + request = requests.Request("POST", f"{UNIT_BASE_URL}/api/v3/personalization").prepare() + response = requests.Response() + response.status_code = 400 + response.request = request + response._content = json.dumps(body).encode() + return BadRequestError(response) + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ({"title": "Bad Request", "errors": [{"msg": "savedId already exist"}]}, True), + ({"title": "Bad Request", "errors": [{"msg": "projectId PRO1 does not exist"}]}, False), + ({"title": "Bad Request"}, False), + ], +) +def test_is_already_starred_error(body: object, expected: bool) -> None: + """Test that only the duplicate-star error is recognized as already starred.""" + assert ProjectCollection._is_already_starred_error(_bad_request(body)) is expected diff --git a/tests/unit/resources/test_personalization.py b/tests/unit/resources/test_personalization.py new file mode 100644 index 000000000..0fd5b1b19 --- /dev/null +++ b/tests/unit/resources/test_personalization.py @@ -0,0 +1,71 @@ +"""Unit tests for the Personalization resource model's wire round-trips.""" + +import pytest +from pydantic import ValidationError + +from albert.resources.personalization import Personalization, PersonalizationCategory + + +def test_personalization_reads_albert_id_from_list_payload() -> None: + """Test that a list response keyed by ``albertId`` populates ``id``.""" + record = Personalization( + **{ + "albertId": "USP27", + "category": "Starred Projects", + "parentId": "USR4227", + "savedId": "PROMO130903", + "savedName": "test-worksheets", + "status": "active", + } + ) + + assert record.id == "USP27" + assert record.category is PersonalizationCategory.STARRED_PROJECTS + assert record.saved_id == "PROMO130903" + + +def test_personalization_reads_id_from_create_payload() -> None: + """Test that a create response keyed by ``id`` populates ``id``.""" + record = Personalization( + **{ + "id": "USP17620", + "parentId": "USR4227", + "savedId": "PROMO130903", + "category": "Starred Projects", + } + ) + + assert record.id == "USP17620" + + +def test_personalization_create_payload_omits_category() -> None: + """Test that the create response shape (recorded live) has no ``category`` key.""" + payload = { + "id": "USP17620", + "parentId": "USR4227", + "savedId": "PROMO130903", + "savedName": "test-worksheets", + } + + with pytest.raises(ValidationError): + Personalization(**payload) + + payload["category"] = "Starred Projects" + assert Personalization(**payload).category is PersonalizationCategory.STARRED_PROJECTS + + +def test_personalization_create_dump_uses_wire_aliases() -> None: + """Test that a caller-built record dumps to the wire create shape.""" + record = Personalization( + category=PersonalizationCategory.STARRED_PROJECTS, + saved_id="PRO123", + saved_name="Weatherproof Coatings 2026", + ) + + payload = record.model_dump(by_alias=True, exclude_none=True, exclude_unset=True, mode="json") + + assert payload == { + "category": "Starred Projects", + "savedId": "PRO123", + "savedName": "Weatherproof Coatings 2026", + }