Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/add-django-config-env-var/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ MY_SETTING = int(os.getenv("BASEROW_MY_SETTING", 123))
3. Add the Nuxt remap if frontend code needs it
4. Use `settings.<NAME>` in code
5. Add a focused test if needed
6. Add a row to `docs/installation/configuration.md`
6. Add a row to `docs/installation/configuration.md` when necessary

## Guardrails

Expand Down
158 changes: 158 additions & 0 deletions .agents/skills/write-backend-unit-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
name: write-backend-unit-test
description: Write or update Baserow backend tests for core, premium, or enterprise code using pytest, Django, DRF APIClient, and the repo's shared fixture patterns.
---

# Write Baserow Backend Tests

Use this skill when a task is to add, fix, or extend a backend test in `backend/tests`, `premium/backend/tests`, or `enterprise/backend/tests`.

Do not invent a generic Django testing style. This repo already has strong pytest and fixture conventions. Start by finding the closest existing test in the same backend area and copy its setup shape.

## First Step

Before editing, identify the test target:

1. Handler, service, registry, or model logic
2. API view or serializer behavior
3. Signals, permissions, or settings-sensitive behavior
4. Query-count or performance-sensitive behavior
5. Premium or enterprise variant of one of the above

Then inspect the nearest existing test file in the same module area.

Useful searches:

- `rg --files backend/tests premium/backend/tests enterprise/backend/tests | rg 'test_.*\.py$'`
- `rg -n "@pytest\\.mark\\.django_db|api_client|data_fixture|premium_data_fixture|enterprise_data_fixture" backend/tests premium/backend/tests enterprise/backend/tests`
- `rg -n "pytest\\.raises|@patch\\(|override_settings|django_assert_num_queries" backend/tests premium/backend/tests enterprise/backend/tests`

## Tooling Used In This Repo

Current backend tests use:

- `pytest`
- `pytest-django`
- Django `reverse`
- DRF `APIClient` from the shared `api_client` fixture
- Shared fixtures from `backend/src/baserow/test_utils/pytest_conftest.py`
- Repo fixture builders such as `data_fixture`, `premium_data_fixture`, and `enterprise_data_fixture`

Important local files:

- `backend/src/baserow/test_utils/pytest_conftest.py`
- `premium/backend/tests/baserow_premium_tests/conftest.py`
- `enterprise/backend/tests/baserow_enterprise_tests/conftest.py`

`pytest_conftest.py` already provides `data_fixture`, `api_client`, `api_request_factory`, registry reset helpers, env helpers, and automatic signal deferral. Reuse those instead of building bespoke fixtures unless the nearby test already does something more specific.

## Choose The Right Pattern

### Handler, service, and model tests

For core business logic, keep the test direct:

1. Mark the test with `@pytest.mark.django_db` when it touches the database.
2. Build state with `data_fixture` or the premium or enterprise equivalent.
3. Instantiate the handler or call the target function directly.
4. Assert concrete persisted state, returned values, and raised exceptions.

Good examples:

- `backend/tests/baserow/core/test_core_handler.py`
- `backend/tests/baserow/core/service/test_service_handler.py`
- `enterprise/backend/tests/baserow_enterprise_tests/teams/test_team_handler.py`

Use `pytest.raises(...)` for error paths. Prefer asserting the specific domain exception over broad response or string checks when testing non-API code.

### API view tests

For API endpoints, match the common DRF style:

1. Create a user and token with `data_fixture.create_user_and_token()` when JWT auth is needed.
2. Build the URL with `reverse(...)`.
3. Call `api_client.get`, `post`, `patch`, or `delete`.
4. Pass auth as `HTTP_AUTHORIZATION=f"JWT {token}"`.
5. Assert both status code and the relevant response body fields.

Good examples:

- `backend/tests/baserow/api/groups/test_workspace_views.py`
- `backend/tests/baserow/contrib/database/api/rows/test_row_views.py`
- `premium/backend/tests/baserow_premium_tests/api/license/test_premium_license_views.py`

Prefer focused payload assertions. Only construct a large expected JSON object when the endpoint response shape is the behavior being tested.

### Signals and side effects

When the important behavior is that a signal or side effect fires:

1. Patch the exact function or signal send call used by the code.
2. Exercise the handler or API path.
3. Assert the mock was called with the expected domain objects or IDs.

Good examples:

- `backend/tests/baserow/core/test_core_handler.py`
- `enterprise/backend/tests/baserow_enterprise_tests/teams/test_team_receivers.py`

The shared test setup already defers many heavy async tasks. Do not add extra mocking for those unless the test specifically needs to assert the call.

### Settings, licenses, and query-count sensitive tests

Use the repo helpers already in use nearby:

1. Use `override_settings(...)` when the behavior is controlled by Django settings.
2. Use `django_assert_num_queries(...)` only when query count is part of the contract.
3. Use the premium or enterprise fixture helpers when the feature depends on licensing or edition-specific behavior.

Good examples:

- `backend/tests/baserow/config/test_read_replica_router.py`
- `premium/backend/tests/baserow_premium_tests/api/license/test_premium_license_views.py`
- `enterprise/backend/tests/baserow_enterprise_tests/sso/test_auth_provider_handler.py`

Do not turn ordinary behavior tests into performance tests.

## Fixtures And Data Setup

Prefer fixture builders over hand-rolling model graphs:

1. Use `data_fixture` for core backend objects.
2. Use `premium_data_fixture` in premium tests.
3. Use `enterprise_data_fixture` in enterprise tests.
4. Reuse `api_client` instead of instantiating `APIClient` manually.

If you need premium or enterprise-only entities, start by checking the corresponding `conftest.py` and nearby test files instead of guessing the fixture name.

## File Placement

Follow the existing test tree:

- Core: `backend/tests/baserow/**`
- Premium: `premium/backend/tests/baserow_premium_tests/**`
- Enterprise: `enterprise/backend/tests/baserow_enterprise_tests/**`

Keep the new test near the feature area rather than creating a new generic test module.

## Validation

Run the narrowest relevant test command first.

Examples:

- `just b test backend/tests/baserow/core/test_core_handler.py`
- `just b test backend/tests/baserow/api/groups/test_workspace_views.py`
- `just b test premium/backend/tests/baserow_premium_tests/api/license/test_premium_license_views.py`
- `just b test enterprise/backend/tests/baserow_enterprise_tests/teams/test_team_handler.py`

If you changed settings-sensitive or query-count-sensitive tests, check the exact failure instead of weakening the assertion immediately.

## Guardrails

- Do not introduce `unittest.TestCase` or Django `TestCase` patterns when the repo already uses plain pytest functions.
- Do not manually construct large object graphs if `data_fixture` already provides the needed factory.
- Do not over-mock core handlers, models, or registries when a focused real-object test is practical.
- Do not skip `@pytest.mark.django_db` on database-touching tests.
- Do not use broad API assertions when the behavior can be proved with a few targeted fields.
- Do not mix core, premium, and enterprise fixture styles in the same file unless the existing test pattern already does that.
4 changes: 4 additions & 0 deletions .agents/skills/write-backend-unit-test/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Write Backend Unit Test"
short_description: "Write Baserow backend pytest tests"
default_prompt: "Use $write-backend-unit-test to add or update a Baserow backend test using the repo's pytest, Django, and shared fixture patterns."
6 changes: 6 additions & 0 deletions backend/src/baserow/contrib/builder/api/elements/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@
HTTP_400_BAD_REQUEST,
"The provided schema_property are not unique.",
)

ERROR_ELEMENT_MOVE_NOT_ALLOWED = (
"ERROR_ELEMENT_MOVE_NOT_ALLOWED",
HTTP_400_BAD_REQUEST,
"The move destination is not allowed for this element.",
)
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ class MoveElementSerializer(serializers.Serializer):
default=None,
help_text="The place in the container.",
)
target_page_id = serializers.IntegerField(
allow_null=True,
required=False,
default=None,
help_text="If provided, the new target page for the element.",
)


class DuplicateElementSerializer(serializers.Serializer):
Expand Down
37 changes: 33 additions & 4 deletions backend/src/baserow/contrib/builder/api/elements/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from baserow.contrib.builder.api.elements.errors import (
ERROR_ELEMENT_DOES_NOT_EXIST,
ERROR_ELEMENT_MOVE_NOT_ALLOWED,
ERROR_ELEMENT_NOT_IN_SAME_PAGE,
ERROR_ELEMENT_PROPERTY_OPTIONS_NOT_UNIQUE,
ERROR_ELEMENT_TYPE_DEACTIVATED,
Expand All @@ -37,19 +38,23 @@
MoveElementSerializer,
UpdateElementSerializer,
)
from baserow.contrib.builder.api.pages.errors import ERROR_PAGE_DOES_NOT_EXIST
from baserow.contrib.builder.api.pages.errors import (
ERROR_PAGE_DOES_NOT_EXIST,
ERROR_PAGE_NOT_IN_BUILDER,
)
from baserow.contrib.builder.application_types import BuilderApplicationType
from baserow.contrib.builder.data_sources.exceptions import DataSourceDoesNotExist
from baserow.contrib.builder.elements.exceptions import (
CollectionElementPropertyOptionsNotUnique,
ElementDoesNotExist,
ElementMoveNotAllowed,
ElementNotInSamePage,
ElementTypeDeactivated,
)
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.elements.registries import element_type_registry
from baserow.contrib.builder.elements.service import ElementService
from baserow.contrib.builder.pages.exceptions import PageDoesNotExist
from baserow.contrib.builder.pages.exceptions import PageDoesNotExist, PageNotInBuilder
from baserow.contrib.builder.pages.handler import PageHandler


Expand Down Expand Up @@ -322,7 +327,8 @@ class MoveElementView(APIView):
@map_exceptions(
{
ElementDoesNotExist: ERROR_ELEMENT_DOES_NOT_EXIST,
ElementNotInSamePage: ERROR_ELEMENT_NOT_IN_SAME_PAGE,
PageNotInBuilder: ERROR_PAGE_NOT_IN_BUILDER,
ElementMoveNotAllowed: ERROR_ELEMENT_MOVE_NOT_ALLOWED,
}
)
@validate_body(MoveElementSerializer)
Expand All @@ -338,6 +344,8 @@ def patch(self, request, data: Dict, element_id: int):
parent_element_id = data.get("parent_element_id", element.parent_element_id)
place_in_container = data.get("place_in_container", element.place_in_container)

target_page_id = data.get("target_page_id", None)

before = None
if before_id is not None:
before = ElementHandler().get_element(before_id)
Expand All @@ -346,8 +354,29 @@ def patch(self, request, data: Dict, element_id: int):
if parent_element_id is not None:
parent_element = ElementHandler().get_element(parent_element_id)

# If we have a before or a parent, we use the same page otherwise
# we use the page provided or the one from the element

try:
target_page = (
before.page
if before
else parent_element.page
if parent_element
else PageHandler().get_page(target_page_id)
if target_page_id
else element.page
)
except PageDoesNotExist as e:
raise PageNotInBuilder(target_page_id) from e

moved_element = ElementService().move_element(
request.user, element, parent_element, place_in_container, before
request.user,
target_page,
element,
parent_element,
place_in_container,
before,
)

serializer = element_type_registry.get_serializer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1912,7 +1912,7 @@ def is_valid(
element: ChoiceElement,
value: Union[List, str],
dispatch_context: DispatchContext,
) -> str:
) -> str | List[str]:
"""
Responsible for validating `ChoiceElement` form data. We handle
this validation a little differently to ensure that if someone creates
Expand Down
4 changes: 4 additions & 0 deletions backend/src/baserow/contrib/builder/elements/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ class ElementNotInSamePage(Exception):
"""Raised when trying to move an element before an element on a different page."""


class ElementMoveNotAllowed(Exception):
"""Raised when trying to move an element to a forbidden position."""


class CollectionElementPropertyOptionsNotUnique(Exception):
"""
Raised when trying to save a collection element property with non-unique options.
Expand Down
18 changes: 11 additions & 7 deletions backend/src/baserow/contrib/builder/elements/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ def update_element(self, element: ElementForUpdate, **kwargs) -> Element:

def move_element(
self,
target_page: Page,
element: ElementForUpdate,
parent_element: Optional[Element],
place_in_container: str,
Expand All @@ -439,29 +440,32 @@ def move_element(
:return: The moved element.
"""

if parent_element is not None:
parent_element = parent_element.specific

parent_element_id = getattr(parent_element, "id", None)

if parent_element is not None and place_in_container is not None:
parent_element = parent_element.specific
parent_element_type = element_type_registry.get_by_model(parent_element)
parent_element_type.validate_place_in_container(
place_in_container, parent_element
)
element.get_type().validate_place(
target_page, parent_element, place_in_container
)

if before:
element.order = Element.get_unique_order_before_element(
before, parent_element_id, place_in_container
)
else:
element.order = Element.get_last_order(
element.page, parent_element_id, place_in_container
target_page, parent_element_id, place_in_container
)

element.page = target_page
element.parent_element = parent_element
element.place_in_container = place_in_container

element.save()

element.get_type().after_move(element)

return element

def order_elements(self, page: Page, order: List[int], base_qs=None) -> List[int]:
Expand Down
Loading
Loading