Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Exception: when a backend caps page size below `DEFAULT_LIMIT` (1000), set `limi
- Shared `seeded_*` fixtures are read-only; update/delete tests create private entities and
clean up in `try/finally`.
- Search assertions must be scoped to `seed_prefix`, filtered to the fixture's ids, and
wrapped in `poll_until` (`tests/integration/utils/wait.py`); `text`/`name` params are
wrapped in `poll_until` (`tests/utils/wait.py`); `text`/`name` params are
fuzzy full-text queries, and other workers delete their seeds mid-run.
- No exact-count asserts on unscoped queries.
- Seed helpers live in `tests/integration/seeding.py`; new seed entities are appended
Expand Down
29 changes: 26 additions & 3 deletions tests/integration/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ For any search-based assertion:

1. Scope the query with `text=`/`name=` set to `seed_prefix` (reduces noise).
2. **Filter results to the ids owned by the fixture** (correctness).
3. Wrap the fetch in `poll_until` from `tests/integration/utils/wait.py` (index lag).
3. Wrap the fetch in `poll_until` from `tests/utils/wait.py` (index lag).

```python
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until


def test_hydrate_project(client: Albert, seed_prefix: str, seeded_projects: list[Project]):
Expand All @@ -108,6 +108,29 @@ def test_hydrate_project(client: Albert, seed_prefix: str, seeded_projects: list
assert projects, "Expected at least one project in search results"
```

`poll_until` stops at the first **non-empty** result by default. Our NoSQL store plus
search index is eventually consistent, so freshly seeded items become visible one by one
and a partial page is normal during indexing. One rule decides how to poll: **whatever
stops the polling must imply the assertion that follows.** When asserting on a specific
item, filter the fetched page to that item's id, so a non-empty result means the item is
visible. When the assertion needs the **complete** expected set (exact-equality or
exact-count asserts against your fixture's ids), pass a `predicate` so polling continues
until the set is whole; without it the test flakes the moment one item indexes before
the rest:

```python
expected_ids = {f"INV{lot.inventory_id}" for lot in seeded_lots if ...}
results = poll_until(
lambda: search_scoped(...),
predicate=lambda results: {f"INV{p.id}" for p in results} == expected_ids,
)
assert {f"INV{p.id}" for p in results} == expected_ids
```

Even with a predicate, a rare timeout is expected behavior of an eventually consistent
index. A timeout usually means indexing lag, so re-run once. A repeat failure means the
predicate or the code under test is wrong; investigate it like any other failure.

Also:

- Never assert **exact counts** of unscoped `search()`/`get_all()` results; other workers
Expand Down Expand Up @@ -156,7 +179,7 @@ group's files with `-n 4` to catch cross-worker races.

- [ ] `pytestmark = pytest.mark.xdist_group("...")` chosen per Rule 1 (or deliberately unmarked because only `client` is used)
- [ ] No mutation of `seeded_*` entities; private entities cleaned up in `try/finally`
- [ ] Search assertions scoped, id-filtered, and wrapped in `poll_until`
- [ ] Search assertions scoped, id-filtered, and wrapped in `poll_until` (with a `predicate` when asserting a complete expected set)
- [ ] No exact-count asserts on global queries
- [ ] Names of created entities include `seed_prefix` or `TEST - <uuid>`
- [ ] Ran the file plus its group with `-n 4` locally
2 changes: 1 addition & 1 deletion tests/integration/collections/test_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from albert.resources.inventory import InventoryItem
from albert.resources.lots import Lot
from albert.resources.parameter_groups import DataType, EnumValidationValue, Operator
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("inventory")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_btinsight.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from albert import Albert
from albert.resources.btinsight import BTInsight, BTInsightCategory, BTInsightRegistry
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("bt")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_custom_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
_CustomTemplateDataUnion,
)
from albert.resources.users import User
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("customtemplates")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_data_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from albert import Albert
from albert.resources.data_columns import DataColumn
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("datatemplates")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_data_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from albert.resources.tags import Tag
from albert.resources.units import Unit
from albert.resources.users import User
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("datatemplates")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from albert import Albert
from albert.exceptions import NotFoundError
from albert.resources.files import FileNamespace
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until


def test_file_round_trip(client: Albert):
Expand Down
43 changes: 28 additions & 15 deletions tests/integration/collections/test_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from albert.resources.storage_locations import StorageLocation, StorageLocationFilter
from albert.resources.tags import Tag
from albert.resources.users import User
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("inventory")

Expand Down Expand Up @@ -62,6 +62,8 @@ def filter_seeded(items):
return [item for item in items if normalize_inv_id(item.id) in seeded_ids]

def scoped_search(*, created_by=None, updated_by=None):
# Every seeded item is created by static_user and matches text=seed_prefix, so
# the complete scoped set is seeded_ids; wait for all of it before comparing.
return poll_until(
lambda: filter_seeded(
list(
Expand All @@ -72,7 +74,10 @@ def scoped_search(*, created_by=None, updated_by=None):
max_items=100,
)
)
)
),
predicate=lambda results: (
{normalize_inv_id(item.id) for item in results} == seeded_ids
),
)

results = poll_until(
Expand Down Expand Up @@ -101,17 +106,17 @@ def scoped_search(*, created_by=None, updated_by=None):
assert test_item.created and test_item.created.at
from_created_at = test_item.created.at.date().isoformat()
recently_created = poll_until(
lambda: filter_seeded(
list(
client.inventory.search(
text=seed_prefix,
from_created_at=from_created_at,
max_items=100,
)
lambda: [
item
for item in client.inventory.search(
text=seed_prefix,
from_created_at=from_created_at,
max_items=100,
)
)
if normalize_inv_id(item.id) == test_item.id
]
)
assert test_item.id in {normalize_inv_id(item.id) for item in recently_created}
assert recently_created, "Expected the seeded item in from_created_at search results"

hydrated_by_creator = poll_until(
lambda: filter_seeded(
Expand All @@ -130,9 +135,13 @@ def scoped_search(*, created_by=None, updated_by=None):
assert facets

search_hits = poll_until(
lambda: filter_seeded(list(client.inventory.search(text=test_item.name, max_items=10)))
lambda: [
item
for item in client.inventory.search(text=test_item.name, max_items=10)
if normalize_inv_id(item.id) == test_item.id
]
)
hit = next(item for item in search_hits if normalize_inv_id(item.id) == test_item.id)
hit = search_hits[0]
assert hit.manufacturer is not None
company_name = (
test_item.company.name if isinstance(test_item.company, Company) else test_item.company
Expand Down Expand Up @@ -185,12 +194,16 @@ def search_scoped(**kwargs):
]

filter_results = poll_until(
lambda: search_scoped(storage_location=[StorageLocationFilter(name=unit.name)])
lambda: search_scoped(storage_location=[StorageLocationFilter(name=unit.name)]),
predicate=lambda results: {f"INV{p.id}" for p in results} == expected_ids,
)
assert {f"INV{p.id}" for p in filter_results} == expected_ids

# The full StorageLocation object from a lookup remains accepted.
object_results = poll_until(lambda: search_scoped(storage_location=unit))
object_results = poll_until(
lambda: search_scoped(storage_location=unit),
predicate=lambda results: {f"INV{p.id}" for p in results} == expected_ids,
)
assert {f"INV{p.id}" for p in object_results} == expected_ids


Expand Down
8 changes: 3 additions & 5 deletions tests/integration/collections/test_notebooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from albert.resources.projects import Project
from tests.integration.seeding import generate_notebook_block_seeds, generate_notebook_seeds
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("projects")

Expand Down Expand Up @@ -111,18 +111,16 @@ def test_update_block_content_raises_exception(client: Albert, seeded_notebook:
def test_search(client: Albert, seed_prefix: str, seeded_notebooks: list[Notebook]):
"""Test search finds seeded notebook block content scoped to the seed project."""
nb = seeded_notebooks[0]
seeded_ids = {n.id for n in seeded_notebooks}
hits = poll_until(
lambda: [
hit
for hit in client.notebooks.search(
text=seed_prefix, project_id=nb.parent_id, max_items=50
)
if hit.notebook_id in seeded_ids and hit.block_id
if hit.notebook_id == nb.id and hit.block_id
]
)
assert hits, "Expected at least one notebook search hit"
assert any(hit.notebook_id == nb.id for hit in hits)
assert hits, "Expected seeded notebook in search results"


def test_get_block_by_id(client: Albert, seeded_notebooks: list[Notebook]):
Expand Down
7 changes: 3 additions & 4 deletions tests/integration/collections/test_parameter_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from albert.resources.tags import Tag
from albert.resources.units import Unit
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("datatemplates")

Expand Down Expand Up @@ -85,7 +85,6 @@ def test_parameter_group_search(
parameter = pg.parameters[0].name
assert tag and parameter

seeded_ids = {item.id for item in seeded_parameter_groups}
hits = poll_until(
lambda: [
hit
Expand All @@ -112,11 +111,11 @@ def test_parameter_group_search(
additional_field=["owner", "tags", "createdByName"],
max_items=50,
)
if hit.id in seeded_ids
if hit.id == pg.id
]
)
assert_valid_parameter_groups(results, ParameterGroupSearchItem)
assert pg.id in {hit.id for hit in results}
assert results[0].id == pg.id


def test_hydrate_pg(client: Albert, seed_prefix: str, seeded_parameter_groups):
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/collections/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from albert.client import Albert
from albert.resources.parameters import Parameter
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("datatemplates")

Expand Down Expand Up @@ -35,6 +35,7 @@ def test_parameter_get_all_by_ids(client: Albert, seeded_parameters: list[Parame
ids = [x.id for x in seeded_parameters]
results = poll_until(
lambda: [p for p in client.parameters.get_all(ids=ids, max_items=10) if p.id in set(ids)],
predicate=lambda results: len(results) == len(ids),
timeout=15.0,
interval=1.0,
)
Expand All @@ -53,6 +54,7 @@ def test_get_by_ids(client: Albert, seeded_parameters: list[Parameter]):
ids = [x.id for x in seeded_parameters]
results = poll_until(
lambda: [p for p in client.parameters.get_by_ids(ids=ids) if p.id in set(ids)],
predicate=lambda results: len(results) == len(ids),
timeout=15.0,
interval=1.0,
)
Expand All @@ -74,6 +76,7 @@ def test_get_by_ids_omits_unknown_ids(client: Albert, seeded_parameters: list[Pa
for p in client.parameters.get_by_ids(ids=[*known_ids, "PRM0"])
if p.id in set(known_ids)
],
predicate=lambda results: len(results) == len(known_ids),
timeout=15.0,
interval=1.0,
)
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/collections/test_product_design.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from albert.resources.inventory import InventoryItem
from albert.resources.product_design import UnpackedProductDesign
from albert.resources.worksheets import Worksheet
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("sheets")

Expand All @@ -16,7 +16,7 @@ def test_search(
seeded_worksheet: Worksheet,
):
"""Test search finds the seeded formula on the product design grid within its project."""
seeded_ids = {p.id for p in seeded_products}
product = seeded_products[0]
hits = poll_until(
lambda: [
hit
Expand All @@ -25,11 +25,11 @@ def test_search(
project_id=seeded_worksheet.project_id,
max_items=50,
)
if hit.id in seeded_ids
if hit.id == product.id
]
)
assert hits, "Expected seeded formula in product design search results"
assert seeded_products[0].id in {hit.id for hit in hits}
assert hits[0].id == product.id


def test_get_unpacked(client: Albert, seeded_products: list[InventoryItem]):
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from albert.resources.acls import ACL, AccessControlLevel
from albert.resources.attachments import Attachment
from albert.resources.projects import DocumentSearchItem, Project, ProjectSearchItem
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("projects")

Expand Down
10 changes: 4 additions & 6 deletions tests/integration/collections/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
FullAnalyticalReport,
)
from albert.resources.tasks import BaseTask
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("tasks")

Expand All @@ -24,7 +24,6 @@ def test_search_reports(
):
"""Test searching reports finds seeded reports scoped to their project."""
expected = seeded_reports[0]
seeded_ids = {r.id for r in seeded_reports}
hits = poll_until(
lambda: [
hit
Expand All @@ -33,13 +32,12 @@ def test_search_reports(
project_id=expected.project_id,
max_items=50,
)
if hit.id in seeded_ids
if hit.id == expected.id
]
)
hit_ids = {hit.id for hit in hits}
assert expected.id in hit_ids
assert hits, "Expected seeded report in search results"

hit = next(item for item in hits if item.id == expected.id)
hit = hits[0]
assert hit.name is not None
assert seed_prefix in hit.name
assert hit.project_id == expected.project_id
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from albert import Albert
from albert.exceptions import AlbertException
from albert.resources.notebooks import Notebook
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("projects")

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/collections/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from albert.resources.worker_jobs import WorkerJob
from albert.resources.workflows import Workflow
from tests.integration.utils.metadata import change_metadata, make_metadata_update_assertions
from tests.integration.utils.wait import poll_until
from tests.utils.wait import poll_until

pytestmark = pytest.mark.xdist_group("tasks")

Expand Down
Loading
Loading