diff --git a/system_admin_account_operations.yaml b/system_admin_account_operations.yaml index ad00178..1faf561 100644 --- a/system_admin_account_operations.yaml +++ b/system_admin_account_operations.yaml @@ -2620,7 +2620,11 @@ paths: "200": description: OK content: - # Actually application/x-zip-compressed, but dtable-web does not accept this in Accept header + # Response body is actually "application/x-zip-compressed", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the "Accept" + # header on this admin endpoint (only */* or application/json work). Keep the workaround + # until the backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_adminExportBase_accepts_zip_content_type. application/json: {} # Common Dataset diff --git a/tests/COVERAGE.md b/tests/COVERAGE.md index f4d8480..b2e8ceb 100644 --- a/tests/COVERAGE.md +++ b/tests/COVERAGE.md @@ -160,17 +160,18 @@ Generated: 2026-03-20 - [x] `POST /api/v2.1/user-list/` - listPublicUserInfos - [ ] `PUT /api/v2.1/user/contact-email/` - updateEmailAddress -### Import & Export (0/9) - -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/append-excel-csv-to-table/` - appendToTableFromFile -- [ ] `GET /dtable/external-links/{external_link_token}/download-zip/` - exportBaseFromExternalLink -- [ ] `GET /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/` - exportBigDataView -- [ ] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/` - exportTable -- [ ] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/` - exportView -- [ ] `POST /api/v2.1/workspace/{workspace_id}/import-dtable/` - importBasefromDTableFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-base/` - importBasefromFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-table/` - importTableFromFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/update-table-via-excel-csv/` - updateFromFile +### Import & Export (10/10) + +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/append-excel-csv-to-table/` - appendToTableFromFile +- [x] `GET /dtable/external-links/{external_link_token}/download-zip/` - exportBaseFromExternalLink +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-dtable/` - exportBase +- [x] `GET /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/` - exportBigDataView +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/` - exportTable (xfail: 406 on real Accept header, backend not yet fixed) +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/` - exportView (xfail: 406 on real Accept header, backend not yet fixed) +- [x] `POST /api/v2.1/workspace/{workspace_id}/import-dtable/` - importBasefromDTableFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-base/` - importBasefromFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-table/` - importTableFromFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/update-table-via-excel-csv/` - updateFromFile ### Bases (9/15) @@ -498,9 +499,9 @@ Generated: 2026-03-20 - [ ] `PUT /api/v2.1/admin/abuse-reports/{abuse_report_id}/` - updateAbuseReport - [ ] `PUT /api/v2.1/admin/virus-files/{virus_id}/` - updateVirusFile -### Export (0/1) +### Export (1/1) -- [ ] `GET /api/v2.1/admin/dtables/{base_uuid}/synchronous-export/export-dtable/` - exportBase +- [x] `GET /api/v2.1/admin/dtables/{base_uuid}/synchronous-export/export-dtable/` - exportBase (xfail: 406 on real Accept header, backend not yet fixed) ### Statistics (0/7) diff --git a/tests/conftest.py b/tests/conftest.py index 6061259..dc41a24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,6 +111,7 @@ class Base: """Class for storing base info""" workspace_id: int uuid: str + name: str # Hide base token from console output by setting repr=False token: str = field(repr=False) # Temporary API token for file uploads @@ -190,7 +191,7 @@ def base(account_token: Secret): api_token = get_api_token(account_token, workspace_id, base_name) # Yield back to the test function - yield Base(workspace_id=workspace_id, uuid=base_uuid, token=base_token, api_token=api_token.value) + yield Base(workspace_id=workspace_id, uuid=base_uuid, name=base_name, token=base_token, api_token=api_token.value) if CLEANUP_AFTER_TESTS == 'True': # Delete base to not cause any issues on future test runs diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..a51df5c --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# Treat every xfail as strict: a test marked xfail that unexpectedly passes (XPASS) +# is reported as a failure. This forces cleanup when the underlying issue is fixed. +xfail_strict = true diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..1d6f384 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,196 @@ +"""Tests for the base/table/view export endpoints. + +Background (ticket): the export endpoints return binary bodies with a specific +Content-Type (``application/x-zip-compressed`` for the ``.dtable`` zip exports, +``application/ms-excel`` for the Excel exports). Historically dtable-web rejected +those exact values in the request ``Accept`` header with +``406 Could not satisfy the request Accept header``, which forced the OpenAPI spec +to declare ``application/json`` as a workaround. + +As of the version tested here, the bug is only *partially* fixed: + +* Fixed – the two ``.dtable`` zip exports in the user API accept their real + media type. These are covered by regular (passing) tests below. +* Broken – the two Excel exports and the sys-admin ``export-dtable`` still return + 406 for their real media type (only ``*/*`` / ``application/json`` work). These + are covered by ``xfail`` tests that assert the *desired* behaviour, so they will + turn into ``XPASS`` once the backend is fixed — that is the signal to drop the + ``application/json`` workaround from the spec and remove the ``xfail`` marker. +""" + +import pytest +import requests +from conftest import ( + Base, BASE_URL, Secret, + user_account_operations, system_admin_account_operations, + base_operations_schema, +) +from schemathesis import Case + +from test_base_operations import create_table, append_rows + + +SIMPLE_COLUMNS = [ + {'column_name': 'text', 'column_type': 'text'}, + {'column_name': 'number', 'column_type': 'number'}, +] + +ZIP_CONTENT_TYPE = 'application/x-zip-compressed' +EXCEL_CONTENT_TYPE = 'application/ms-excel' + + +def _first_table_and_view(base: Base, table_name: str) -> dict: + """Create a populated table and return its id/name plus its default view id/name.""" + create_table(base, table_name, SIMPLE_COLUMNS) + append_rows(base, table_name, [{'text': 'a', 'number': 1}, {'text': 'b', 'number': 2}]) + + headers = {'Authorization': f'Bearer {base.token}'} + case: Case = base_operations_schema.find_operation_by_id('getMetadata') \ + .Case(path_parameters={'base_uuid': base.uuid}, headers=headers) + response = case.call() + assert response.status_code == 200 + + table = next(t for t in response.json()['metadata']['tables'] if t['name'] == table_name) + view = table['views'][0] + return { + 'table_id': table['_id'], + 'table_name': table['name'], + 'view_id': view['_id'], + 'view_name': view['name'], + } + + +# --- Fixed: .dtable zip exports accept their real media type ----------------- + +def test_exportBase(base: Base, account_token: Secret): + """Export a base as a .dtable zip, requesting the real media type via Accept.""" + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-dtable/', + params={'dtable_name': base.name}, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': ZIP_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 + + +def test_exportBaseFromExternalLink(base: Base, account_token: Secret): + """Download a base via its external link, requesting the real media type via Accept.""" + # Create a read-only external link for the base + create_case: Case = user_account_operations.find_operation_by_id('createBaseExternalLink').Case( + path_parameters={'workspace_id': base.workspace_id, 'base_name': base.name}, + ) + create_response = create_case.call(headers={'Authorization': f'Bearer {account_token.value}'}) + assert create_response.status_code == 200 + external_link_token = create_response.json()['token'] + + # Download the zip (public endpoint, no auth required) + response = requests.get( + f'{BASE_URL}/dtable/external-links/{external_link_token}/download-zip/', + headers={'Accept': ZIP_CONTENT_TYPE}, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 + + +def test_exportBigDataView(base: Base, account_token: Secret): + """Trigger a big-data view Excel export; the endpoint returns an async task id.""" + table = _first_table_and_view(base, 'test_exportBigDataView') + + case: Case = user_account_operations.find_operation_by_id('exportBigDataView').Case( + path_parameters={'workspace_id': base.workspace_id, 'base_name': base.name}, + query={'table_id': table['table_id'], 'view_id': table['view_id']}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call() + + assert response.status_code == 200 + assert isinstance(response.json()['task_id'], str) + + +# --- Still broken: Excel exports and admin export reject their real media type --- +# +# These use plain ``requests`` (not schemathesis ``Case``) on purpose: the spec +# still declares ``application/json`` as a workaround, so routing through the +# schemathesis ``after_call`` hook would make the test fail on content-type +# conformance even after the backend is fixed, defeating the XPASS signal. + +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/ms-excel' is sent in the Accept header (only */* / application/json work)", +) +def test_exportTable_accepts_excel_content_type(base: Base, account_token: Secret): + """The table Excel export should accept its real media type in the Accept header.""" + table = _first_table_and_view(base, 'test_exportTable') + + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-table-to-excel/', + params={ + 'table_id': table['table_id'], + 'table_name': table['table_name'], + 'dtable_name': base.name, + }, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': EXCEL_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == EXCEL_CONTENT_TYPE + assert len(response.content) > 0 + + +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/ms-excel' is sent in the Accept header (only */* / application/json work)", +) +def test_exportView_accepts_excel_content_type(base: Base, account_token: Secret): + """The view Excel export should accept its real media type in the Accept header.""" + table = _first_table_and_view(base, 'test_exportView') + + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-view-to-excel/', + params={ + 'table_id': table['table_id'], + 'table_name': table['table_name'], + 'dtable_name': base.name, + 'view_id': table['view_id'], + 'view_name': table['view_name'], + }, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': EXCEL_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == EXCEL_CONTENT_TYPE + assert len(response.content) > 0 + + +@pytest.mark.needs_large_license +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/x-zip-compressed' is sent in the Accept header on the admin export " + "endpoint (only */* / application/json work)", +) +def test_adminExportBase_accepts_zip_content_type(base: Base, system_admin_account_token: Secret): + """The sys-admin base export should accept its real media type in the Accept header.""" + response = requests.get( + f'{BASE_URL}/api/v2.1/admin/dtables/{base.uuid}/synchronous-export/export-dtable/', + headers={ + 'Authorization': f'Bearer {system_admin_account_token.value}', + 'Accept': ZIP_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..aefcdc4 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,162 @@ +"""Tests for the base/table import endpoints (Import & Export group). + +These go through schemathesis ``Case`` like the rest of the suite, so the +``after_call`` hook validates every response against the OpenAPI schema. The +multipart file part itself is passed via ``case.call(files=..., data=...)`` +(forwarded to ``requests``): schemathesis's own multipart serializer never emits +a filename, but these endpoints read ``request.FILES`` and reject a file part +without one (``{"error_msg": "file invalid."}``). + +Bases created by the import-to-base endpoints are made inside the shared ``base`` +fixture's workspace and registered with ``cleanup_bases`` so they are deleted +before the module's group teardown (a group with remaining bases cannot be +deleted). +""" + +import pytest +from conftest import Base, Secret, base_operations_schema, user_account_operations +from schemathesis import Case + +from test_base_operations import create_table, append_rows + + +NAME_NUMBER_COLUMNS = [ + {'column_name': 'Name', 'column_type': 'text'}, + {'column_name': 'Number', 'column_type': 'number'}, +] + +# CSV whose header row matches NAME_NUMBER_COLUMNS +CSV = b'Name,Number\nAlice,1\nBob,2\n' + + +@pytest.fixture +def cleanup_bases(base: Base, account_token: Secret): + """Delete bases created during a test before the module's group teardown runs + (a group with remaining bases cannot be deleted). Register a base name before + creating it; cleanup is best-effort.""" + names: list[str] = [] + yield names + for name in names: + case: Case = user_account_operations.find_operation_by_id('deleteBase').Case( + path_parameters={'workspace_id': base.workspace_id}, + body={'name': name}, + ) + case.call(headers={'Authorization': f'Bearer {account_token.value}'}) + + +def _list_rows(base: Base, table_name: str) -> list[dict]: + """Read a table's rows back once (keyed by column name).""" + case: Case = base_operations_schema.find_operation_by_id('listRows').Case( + path_parameters={'base_uuid': base.uuid}, + query={'table_name': table_name, 'convert_keys': True}, + headers={'Authorization': f'Bearer {base.token}'}, + ) + response = case.call() + assert response.status_code == 200 + return response.json()['rows'] + + +def test_importBasefromFile(base: Base, account_token: Secret, cleanup_bases: list[str]): + """Create a new base by uploading a CSV file.""" + cleanup_bases.append('test_importBasefromFile') + case: Case = user_account_operations.find_operation_by_id('importBasefromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call(files={'dtable': ('test_importBasefromFile.csv', CSV, 'text/csv')}) + + assert response.status_code == 200 + assert response.json()['success'] is True + + +def test_importBasefromDTableFile(base: Base, account_token: Secret, cleanup_bases: list[str]): + """Create a new base by uploading a .dtable file (obtained by exporting a base).""" + cleanup_bases.append('test_importBasefromDTableFile') + headers = {'Authorization': f'Bearer {account_token.value}'} + + # Export the shared base to get a valid .dtable file to import + export_case: Case = user_account_operations.find_operation_by_id('exportBase').Case( + path_parameters={'workspace_id': base.workspace_id}, + query={'dtable_name': base.name}, + headers=headers, + ) + exported = export_case.call() + assert exported.status_code == 200 + + case: Case = user_account_operations.find_operation_by_id('importBasefromDTableFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers=headers, + ) + response = case.call( + files={'dtable': ('test_importBasefromDTableFile.dtable', exported.content, 'application/x-zip-compressed')}, + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data['task_id'], str) + assert data['table']['name'] == 'test_importBasefromDTableFile' + + +def test_importTableFromFile(base: Base, account_token: Secret): + """Import a CSV file as a new table in an existing base.""" + case: Case = user_account_operations.find_operation_by_id('importTableFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid}, + files={'file': ('test_importTableFromFile.csv', CSV, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + # The new table is named after the uploaded file + rows = _list_rows(base, 'test_importTableFromFile') + assert len(rows) == 2 + + +def test_appendToTableFromFile(base: Base, account_token: Secret): + """Append rows from a CSV file to an existing table.""" + table_name = 'test_appendToTableFromFile' + create_table(base, table_name, NAME_NUMBER_COLUMNS) + + case: Case = user_account_operations.find_operation_by_id('appendToTableFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid, 'table_name': table_name}, + files={'file': ('append.csv', CSV, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + rows = _list_rows(base, table_name) + assert {r['Name'] for r in rows} == {'Alice', 'Bob'} + + +def test_updateFromFile(base: Base, account_token: Secret): + """Update matched rows and insert unmatched rows from a CSV file.""" + table_name = 'test_updateFromFile' + create_table(base, table_name, NAME_NUMBER_COLUMNS) + append_rows(base, table_name, [{'Name': 'Alice', 'Number': 1}]) + + # Alice matches the existing row (updated to 99); Charlie is new (inserted) + csv = b'Name,Number\nAlice,99\nCharlie,3\n' + case: Case = user_account_operations.find_operation_by_id('updateFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid, 'table_name': table_name, 'selected_columns': 'Name'}, + files={'file': ('update.csv', csv, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + rows = {r['Name']: r['Number'] for r in _list_rows(base, table_name)} + assert rows['Alice'] == 99 + assert rows['Charlie'] == 3 diff --git a/user_account_operations.yaml b/user_account_operations.yaml index 5d0899a..0cbce0f 100644 --- a/user_account_operations.yaml +++ b/user_account_operations.yaml @@ -4479,8 +4479,7 @@ paths: "200": description: OK content: - # TODO: Actually "application/x-zip-compressed", but dtable-web does not accept this when specified inside an "Accept" header - application/json: {} + application/x-zip-compressed: {} /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/: get: tags: @@ -4499,7 +4498,11 @@ paths: "200": description: OK content: - # TODO: Actually "application/ms-excel", but dtable-web does not accept this when specified inside an "Accept" header + # Response body is actually "application/ms-excel", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the + # "Accept" header (only */* or application/json work). Keep the workaround until the + # backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_exportTable_accepts_excel_content_type. application/json: {} /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/: get: @@ -4521,7 +4524,11 @@ paths: "200": description: OK content: - # TODO: Actually "application/ms-excel", but dtable-web does not accept this when specified inside an "Accept" header + # Response body is actually "application/ms-excel", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the + # "Accept" header (only */* or application/json work). Keep the workaround until the + # backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_exportView_accepts_excel_content_type. application/json: {} /dtable/external-links/{external_link_token}/download-zip/: get: @@ -4538,8 +4545,7 @@ paths: "200": description: OK content: - # TODO: Actually "application/x-zip-compressed", but dtable-web does not accept this when specified inside an "Accept" header - application/json: {} + application/x-zip-compressed: {} /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/: get: