diff --git a/tests/__snapshots__/test_websocket/test_append_rows.json b/tests/__snapshots__/test_websocket/test_append_rows.json new file mode 100644 index 0000000..d87e12d --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_append_rows.json @@ -0,0 +1,26 @@ +[ + { + "_archived": false, + "_creator": null, + "_ctime": null, + "_id": "str", + "_last_modifier": null, + "_locked": null, + "_locked_by": null, + "_mtime": null, + "number": 1, + "text": "a" + }, + { + "_archived": false, + "_creator": null, + "_ctime": null, + "_id": "str", + "_last_modifier": null, + "_locked": null, + "_locked_by": null, + "_mtime": null, + "number": 2, + "text": "b" + } +] diff --git a/tests/__snapshots__/test_websocket/test_column_operation[freeze_column].json b/tests/__snapshots__/test_websocket/test_column_operation[freeze_column].json new file mode 100644 index 0000000..a99da43 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_column_operation[freeze_column].json @@ -0,0 +1,19 @@ +{ + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "frozen": true, + "key": "str", + "name": "text", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "text", + "width": 200 +} diff --git a/tests/__snapshots__/test_websocket/test_column_operation[modify_column_type].json b/tests/__snapshots__/test_websocket/test_column_operation[modify_column_type].json new file mode 100644 index 0000000..58bb667 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_column_operation[modify_column_type].json @@ -0,0 +1,18 @@ +{ + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "text", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "long-text", + "width": 200 +} diff --git a/tests/__snapshots__/test_websocket/test_column_operation[rename_column].json b/tests/__snapshots__/test_websocket/test_column_operation[rename_column].json new file mode 100644 index 0000000..6ec3fb3 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_column_operation[rename_column].json @@ -0,0 +1,18 @@ +{ + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "renamed", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "text", + "width": 200 +} diff --git a/tests/__snapshots__/test_websocket/test_column_operation[resize_column].json b/tests/__snapshots__/test_websocket/test_column_operation[resize_column].json new file mode 100644 index 0000000..56f4fce --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_column_operation[resize_column].json @@ -0,0 +1,18 @@ +{ + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "text", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "text", + "width": 400 +} diff --git a/tests/__snapshots__/test_websocket/test_insert_column.json b/tests/__snapshots__/test_websocket/test_insert_column.json new file mode 100644 index 0000000..2891ec9 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_insert_column.json @@ -0,0 +1,8 @@ +{ + "data": { + "options": [] + }, + "key": "str", + "name": "status", + "type": "single-select" +} diff --git a/tests/__snapshots__/test_websocket/test_insert_columns.json b/tests/__snapshots__/test_websocket/test_insert_columns.json new file mode 100644 index 0000000..7090272 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_insert_columns.json @@ -0,0 +1,13 @@ +[ + { + "key": "str", + "name": "first", + "type": "text" + }, + { + "data": null, + "key": "str", + "name": "second", + "type": "number" + } +] diff --git a/tests/__snapshots__/test_websocket/test_insert_row.json b/tests/__snapshots__/test_websocket/test_insert_row.json new file mode 100644 index 0000000..ca1037d --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_insert_row.json @@ -0,0 +1,12 @@ +{ + "_archived": false, + "_creator": null, + "_ctime": "str", + "_id": "str", + "_last_modifier": null, + "_locked": null, + "_locked_by": null, + "_mtime": "str", + "number": null, + "text": "inserted" +} diff --git a/tests/__snapshots__/test_websocket/test_insert_table.json b/tests/__snapshots__/test_websocket/test_insert_table.json new file mode 100644 index 0000000..6796399 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_insert_table.json @@ -0,0 +1,29 @@ +{ + "_id": "str", + "columns": [ + { + "key": "str", + "name": "Name", + "type": "text" + } + ], + "is_header_locked": false, + "name": "test_insert_table", + "views": [ + { + "_id": "str", + "colorbys": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "name": "Default View", + "row_height": "default", + "rows": [], + "sorts": [], + "type": "table" + } + ] +} diff --git a/tests/__snapshots__/test_websocket/test_insert_view.json b/tests/__snapshots__/test_websocket/test_insert_view.json new file mode 100644 index 0000000..4094706 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_insert_view.json @@ -0,0 +1,5 @@ +{ + "_id": "str", + "name": "New View", + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.1.json b/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.1.json new file mode 100644 index 0000000..1cc1716 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.1.json @@ -0,0 +1,12 @@ +{ + "_archived": false, + "_creator": "str", + "_ctime": "str", + "_id": "str", + "_last_modifier": "str", + "_locked": null, + "_locked_by": null, + "_mtime": "str", + "number": null, + "text": "a" +} diff --git a/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.json b/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.json new file mode 100644 index 0000000..b024a5e --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.json @@ -0,0 +1,12 @@ +{ + "_archived": false, + "_creator": "str", + "_ctime": "str", + "_id": "str", + "_last_modifier": "str", + "_locked": true, + "_locked_by": "automated-test", + "_mtime": "str", + "number": null, + "text": "a" +} diff --git a/tests/__snapshots__/test_websocket/test_modify_column_data.json b/tests/__snapshots__/test_websocket/test_modify_column_data.json new file mode 100644 index 0000000..836be24 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_modify_column_data.json @@ -0,0 +1,27 @@ +{ + "colorbys": {}, + "data": { + "options": [ + { + "color": "#FF8000", + "id": "123456", + "name": "open", + "textColor": "#FFFFFF" + } + ] + }, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "status", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "single-select", + "width": 200 +} diff --git a/tests/__snapshots__/test_websocket/test_modify_header_lock.json b/tests/__snapshots__/test_websocket/test_modify_header_lock.json new file mode 100644 index 0000000..bfc74ed --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_modify_header_lock.json @@ -0,0 +1,71 @@ +{ + "_id": "str", + "columns": [ + { + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "text", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "text", + "width": 200 + }, + { + "colorbys": {}, + "data": { + "decimal": "dot", + "format": "number", + "thousands": "no" + }, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "number", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "number", + "width": 200 + } + ], + "header_settings": {}, + "is_header_locked": true, + "name": "test_modify_header_lock", + "summary_configs": {}, + "views": [ + { + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" + } + ] +} diff --git a/tests/__snapshots__/test_websocket/test_modify_row.json b/tests/__snapshots__/test_websocket/test_modify_row.json new file mode 100644 index 0000000..57b96c9 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_modify_row.json @@ -0,0 +1,12 @@ +{ + "_archived": false, + "_creator": "str", + "_ctime": "str", + "_id": "str", + "_last_modifier": "str", + "_locked": null, + "_locked_by": null, + "_mtime": "str", + "number": null, + "text": "after" +} diff --git a/tests/__snapshots__/test_websocket/test_modify_rows.json b/tests/__snapshots__/test_websocket/test_modify_rows.json new file mode 100644 index 0000000..a1dfbe9 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_modify_rows.json @@ -0,0 +1,26 @@ +[ + { + "_archived": false, + "_creator": "str", + "_ctime": "str", + "_id": "str", + "_last_modifier": "str", + "_locked": null, + "_locked_by": null, + "_mtime": "str", + "number": null, + "text": "a-updated" + }, + { + "_archived": false, + "_creator": "str", + "_ctime": "str", + "_id": "str", + "_last_modifier": "str", + "_locked": null, + "_locked_by": null, + "_mtime": "str", + "number": null, + "text": "b-updated" + } +] diff --git a/tests/__snapshots__/test_websocket/test_rename_table.json b/tests/__snapshots__/test_websocket/test_rename_table.json new file mode 100644 index 0000000..5f1e677 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_rename_table.json @@ -0,0 +1,71 @@ +{ + "_id": "str", + "columns": [ + { + "colorbys": {}, + "data": null, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "text", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "text", + "width": 200 + }, + { + "colorbys": {}, + "data": { + "decimal": "dot", + "format": "number", + "thousands": "no" + }, + "description": null, + "draggable": true, + "edit_metadata_permission_type": "", + "edit_metadata_permitted_group": [], + "edit_metadata_permitted_users": [], + "editable": true, + "key": "str", + "name": "number", + "permission_type": "", + "permitted_group": [], + "permitted_users": [], + "resizable": true, + "type": "number", + "width": 200 + } + ], + "header_settings": {}, + "is_header_locked": false, + "name": "test_rename_table_renamed", + "summary_configs": {}, + "views": [ + { + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" + } + ] +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_filters].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_filters].json new file mode 100644 index 0000000..aeb67c8 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_filters].json @@ -0,0 +1,26 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [ + { + "column_key": "str", + "filter_predicate": "greater", + "filter_term": 1 + } + ], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_groupbys].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_groupbys].json new file mode 100644 index 0000000..c6d1f7a --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_groupbys].json @@ -0,0 +1,25 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [ + { + "column_key": "str", + "sort_type": "up" + } + ], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_hidden_columns].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_hidden_columns].json new file mode 100644 index 0000000..f07334b --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_hidden_columns].json @@ -0,0 +1,22 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [ + "str" + ], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_row_height].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_row_height].json new file mode 100644 index 0000000..8b59274 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_row_height].json @@ -0,0 +1,20 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "double", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_sorts].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_sorts].json new file mode 100644 index 0000000..f613b0e --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_sorts].json @@ -0,0 +1,25 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [ + { + "column_key": "str", + "sort_type": "up" + } + ], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[modify_view_lock].json b/tests/__snapshots__/test_websocket/test_view_operation[modify_view_lock].json new file mode 100644 index 0000000..c57b5c4 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[modify_view_lock].json @@ -0,0 +1,20 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": true, + "name": "Default View", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/__snapshots__/test_websocket/test_view_operation[rename_view].json b/tests/__snapshots__/test_websocket/test_view_operation[rename_view].json new file mode 100644 index 0000000..b9840f0 --- /dev/null +++ b/tests/__snapshots__/test_websocket/test_view_operation[rename_view].json @@ -0,0 +1,20 @@ +{ + "_id": "str", + "colorbys": {}, + "colors": {}, + "column_colors": {}, + "filter_conjunction": "And", + "filters": [], + "formula_rows": {}, + "groupbys": [], + "groups": [], + "hidden_columns": [], + "is_locked": false, + "name": "renamed-view", + "private_for": null, + "row_height": "default", + "rows": [], + "sorts": [], + "summaries": {}, + "type": "table" +} diff --git a/tests/requirements.txt b/tests/requirements.txt index 8b5ee55..325695e 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,3 +4,5 @@ syrupy>=4.8 pytest-icdiff>=0.9 pytest-subtests>=0.7 requests>=2.32 +seatable-api==4.0.0 +websocket-client==1.9.1 diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..8b79e59 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,738 @@ +"""Tests for the WebSocket API. + +Clients do not only receive the changes of a base over socket.io, they also send +them: after joining the room of a base with a Base-Token, an operation is emitted +as 'update-dtable' and executed by dtable-server. The socket.io endpoint is served +by the API gateway under /api-gateway/socket.io and is not part of the OpenAPI +specs, so these tests use the client that ships with the seatable-api package. + +Every test performs the operation over the websocket and verifies via the REST API +that the change was applied to the base. +""" + +import base64 +import json +import queue +import threading +import uuid + +import pytest +import socketio +from conftest import BASE_URL, Base, base_operations_schema, normalize +from schemathesis import Case +from seatable_api import Base as SeaTableBase +from seatable_api.constants import JOIN_ROOM, UPDATE_DTABLE +from seatable_api.socket_io import SIO +from syrupy.assertion import SnapshotAssertion +from syrupy.matchers import path_type +from typing import Callable, Generator + +from test_base_operations import create_table, append_rows + + +# Seconds to wait for an acknowledgement or an event +TIMEOUT = 10 + +COLUMNS = [ + {'column_name': 'text', 'column_type': 'text'}, + {'column_name': 'number', 'column_type': 'number'}, +] + +# Table, view and column IDs are generated by the server and differ on every run. +# Row IDs, timestamps and internal user names are replaced by conftest.normalize. +MATCHER = path_type({ + r'(.*\.)?(_id|key|.*_key)': (str,), + r'(.*\.)?(hidden_columns|view_ids)\..*': (str,), +}, regex=True) + + +def new_row_id() -> str: + """Row IDs of new rows are generated by the client, not by the server.""" + return base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip('=') + + +class WebSocketClient: + """socket.io client for a single base.""" + + def __init__(self, api_token: str): + # The seatable-api client resolves the socket.io URL and the Base-Token (JWT) + seatable_base = SeaTableBase(api_token, BASE_URL) + seatable_base.auth() + + self.dtable_uuid = seatable_base.dtable_uuid + self.jwt_token = seatable_base.jwt_token + self.url = f'{seatable_base.dtable_server_url}?dtable_uuid={self.dtable_uuid}' + + self.events = queue.Queue() + self.disconnected = threading.Event() + + self.sio = SIO(reconnection=False, request_timeout=TIMEOUT) + self.sio.on(UPDATE_DTABLE, lambda *args: self.events.put(json.loads(args[0]))) + self.sio.on('disconnect', lambda *args: self.disconnected.set()) + self.sio.on('io-disconnect', lambda *args: self.disconnected.set()) + + def connect(self): + self.sio.connect(self.url) + + def join_room(self, dtable_uuid: str = None, jwt_token: str = None) -> dict: + """Emit 'join-room' and return the acknowledgement of the server.""" + return self.sio.call( + JOIN_ROOM, + (dtable_uuid or self.dtable_uuid, self.jwt_token if jwt_token is None else jwt_token), + timeout=TIMEOUT, + ) + + def update_dtable(self, operation: dict) -> dict: + """Emit 'update-dtable' and return the acknowledgement of the server.""" + ack = self.sio.call(UPDATE_DTABLE, (self.dtable_uuid, json.dumps(operation)), timeout=TIMEOUT) + + # Operations that update auto-numbers are acknowledged with the operation as second value + return ack[0] if isinstance(ack, tuple) else ack + + def next_event(self) -> dict: + """Return the next 'update-dtable' event that was broadcast to this client.""" + try: + return self.events.get(timeout=TIMEOUT) + except queue.Empty: + pytest.fail(f'No {UPDATE_DTABLE} event received within {TIMEOUT} seconds') + + def disconnect(self): + self.sio.disconnect() + + +def connect(api_token: str) -> WebSocketClient: + """Return a client that has joined the room of the base of the API-Token.""" + client = WebSocketClient(api_token) + client.connect() + + assert client.join_room()['status'] == 1 + + return client + + +@pytest.fixture +def websocket_client(base: Base) -> Generator[Callable[[], WebSocketClient], None, None]: + """Factory for clients that have joined the room of the test base.""" + clients = [] + + def _connect() -> WebSocketClient: + client = connect(base.api_token) + clients.append(client) + return client + + yield _connect + + for client in clients: + client.disconnect() + + +@pytest.fixture +def websocket(websocket_client) -> WebSocketClient: + return websocket_client() + + +def execute(client: WebSocketClient, operation: dict) -> dict: + """Execute an operation over the websocket and assert that it was applied.""" + ack = client.update_dtable(operation) + + assert ack['status'] == 1, ack + assert ack['message'] == 'Update completed' + assert isinstance(ack['dtable_version'], int) + + return ack + + +def call(base: Base, operation_id: str, body: dict = None, query: dict = None, **path_parameters): + """Call a base operation with the Base-Token of the test base.""" + arguments = { + 'path_parameters': {'base_uuid': base.uuid, **path_parameters}, + 'headers': {'Authorization': f'Bearer {base.token}'}, + } + if body is not None: + arguments['body'] = body + if query is not None: + arguments['query'] = query + + case: Case = base_operations_schema.find_operation_by_id(operation_id).Case(**arguments) + response = case.call() + + assert response.status_code == 200 + + return response + + +def get_table(base: Base, table_name: str) -> dict: + """Return the metadata of a table, which contains the IDs that operations need.""" + response = call(base, 'getMetadata') + return next(table for table in response.json()['metadata']['tables'] if table['name'] == table_name) + + +def list_rows(base: Base, table_name: str) -> list[dict]: + response = call(base, 'listRows', query={'table_name': table_name, 'convert_keys': True}) + return response.json()['rows'] + + +def get_column(base: Base, table_name: str, column_key: str) -> dict: + response = call(base, 'listColumns', query={'table_name': table_name}) + return next((column for column in response.json()['columns'] if column['key'] == column_key), None) + + +def get_view(base: Base, table_name: str, view_id: str) -> dict: + """Look the view up by ID, because operations may change its name.""" + response = call(base, 'listViews', query={'table_name': table_name}) + return next((view for view in response.json()['views'] if view['_id'] == view_id), None) + + +def setup_table(base: Base, table_name: str, rows: list[dict] = None) -> tuple[dict, list[str]]: + """Create a table via REST and return its metadata and the IDs of its rows.""" + create_table(base, table_name, COLUMNS) + row_ids = append_rows(base, table_name, rows) if rows else [] + + return get_table(base, table_name), row_ids + + +def test_join_room(base: Base): + client = WebSocketClient(base.api_token) + client.connect() + + ack = client.join_room() + + assert ack['status'] == 1 + assert ack['message'] == 'Joined successfully' + assert isinstance(ack['dtable_version'], int) + assert client.sio.transport() == 'websocket' + + client.disconnect() + + +def test_join_room_with_invalid_token(base: Base): + client = WebSocketClient(base.api_token) + client.connect() + + ack = client.join_room(jwt_token='not-a-base-token') + + assert ack['status'] == 0 + assert ack['error_type'] == 'token_expired' + assert ack['message'] == 'Token expired' + + # The server closes the connection after a rejected join + assert client.disconnected.wait(timeout=TIMEOUT) + + +def test_join_room_with_unknown_base(base: Base): + client = WebSocketClient(base.api_token) + client.connect() + + ack = client.join_room(dtable_uuid='00000000-0000-0000-0000-000000000000') + + assert ack['status'] == 0 + assert ack['error_type'] == 'permission_denied' + assert ack['message'] == 'Permission denied' + + assert client.disconnected.wait(timeout=TIMEOUT) + + +def test_connect_without_base_uuid(base: Base): + """The base UUID has to be passed as a query parameter of the socket.io URL.""" + client = WebSocketClient(base.api_token) + client.url = client.url.split('?')[0] + + with pytest.raises(socketio.exceptions.ConnectionError): + client.connect() + + +def test_operation_with_missing_attributes(base: Base, websocket: WebSocketClient): + """append_rows requires 'row_datas', an incomplete operation must not be applied.""" + table_name = 'test_operation_with_missing_attributes' + table, _ = setup_table(base, table_name) + + ack = websocket.update_dtable({'op_type': 'append_rows', 'table_id': table['_id']}) + + assert ack['status'] == 0 + assert ack['error_type'] == 'operation_invalid' + assert ack['message'] == 'Operation invalid' + assert list_rows(base, table_name) == [] + + +def test_operation_with_unknown_op_type(base: Base, websocket: WebSocketClient): + table_name = 'test_operation_with_unknown_op_type' + table, _ = setup_table(base, table_name) + + ack = websocket.update_dtable({'op_type': 'no_such_operation', 'table_id': table['_id']}) + + assert ack['status'] == 0 + assert ack['error_type'] == 'operation_invalid' + + +def test_operation_is_broadcast_to_other_clients(base: Base, websocket_client): + """An operation is sent to the other clients in the room, but not back to its sender.""" + table_name = 'test_operation_is_broadcast' + table, _ = setup_table(base, table_name) + row_id = new_row_id() + + # Both clients connect after the setup, so that they only receive the operation below + sender = websocket_client() + listener = websocket_client() + + execute(sender, { + 'op_type': 'append_rows', + 'table_id': table['_id'], + 'row_datas': [{'_id': row_id, table['columns'][0]['key']: 'broadcast'}], + }) + + event = listener.next_event() + + assert event['op_type'] == 'append_rows' + assert event['row_datas'][0]['_id'] == row_id + + # The client that sent the operation does not receive it back + with pytest.raises(queue.Empty): + sender.events.get(timeout=1) + + +def test_append_rows(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_append_rows' + table, _ = setup_table(base, table_name) + text_key, number_key = (column['key'] for column in table['columns']) + row_ids = [new_row_id(), new_row_id()] + + # System columns are not filled in by the server: rows appended over the + # websocket without '_ctime'/'_creator' keep those fields empty + execute(websocket, { + 'op_type': 'append_rows', + 'table_id': table['_id'], + 'row_datas': [ + {'_id': row_ids[0], text_key: 'a', number_key: 1}, + {'_id': row_ids[1], text_key: 'b', number_key: 2}, + ], + }) + + rows = list_rows(base, table_name) + + assert [row['_id'] for row in rows] == row_ids + assert [(row['text'], row['number']) for row in rows] == [('a', 1), ('b', 2)] + assert snapshot_json(matcher=MATCHER) == normalize([row for row in rows if row['_id'] in row_ids]) + + +def test_insert_row(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_insert_row' + table, existing_row_ids = setup_table(base, table_name, [{'text': 'existing'}]) + row_id = new_row_id() + + execute(websocket, { + 'op_type': 'insert_row', + 'table_id': table['_id'], + # 'row_id' is the row the new one is inserted below, its own ID comes from + # the row data - without '_id' in there the new row ends up with '_id': None + 'row_id': existing_row_ids[0], + 'row_insert_position': 'insert_below', + 'row_data': {'_id': row_id, table['columns'][0]['key']: 'inserted'}, + }) + + rows = list_rows(base, table_name) + + assert [row['_id'] for row in rows] == existing_row_ids + [row_id] + assert rows[-1]['text'] == 'inserted' + assert snapshot_json(matcher=MATCHER) == normalize(rows[-1]) + + +def test_modify_row(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_modify_row' + table, row_ids = setup_table(base, table_name, [{'text': 'before'}]) + + execute(websocket, { + 'op_type': 'modify_row', + 'table_id': table['_id'], + 'row_id': row_ids[0], + 'updated': {table['columns'][0]['key']: 'after'}, + 'old_row': {table['columns'][0]['key']: 'before'}, + }) + + response = call(base, 'getRow', query={'table_name': table_name, 'convert_keys': True}, row_id=row_ids[0]) + row = response.json() + + assert row['text'] == 'after' + assert snapshot_json(matcher=MATCHER) == normalize(row) + + +def test_modify_rows(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_modify_rows' + table, row_ids = setup_table(base, table_name, [{'text': 'a'}, {'text': 'b'}]) + text_key = table['columns'][0]['key'] + + execute(websocket, { + 'op_type': 'modify_rows', + 'table_id': table['_id'], + 'row_ids': row_ids, + 'updated': {row_ids[0]: {text_key: 'a-updated'}, row_ids[1]: {text_key: 'b-updated'}}, + 'old_rows': {row_ids[0]: {text_key: 'a'}, row_ids[1]: {text_key: 'b'}}, + }) + + rows = list_rows(base, table_name) + + assert [row['text'] for row in rows] == ['a-updated', 'b-updated'] + assert snapshot_json(matcher=MATCHER) == normalize([row for row in rows if row['_id'] in row_ids]) + + +def test_delete_row(base: Base, websocket: WebSocketClient): + table_name = 'test_delete_row' + table, row_ids = setup_table(base, table_name, [{'text': 'delete-me'}, {'text': 'keep'}]) + + execute(websocket, { + 'op_type': 'delete_row', + 'table_id': table['_id'], + 'row_id': row_ids[0], + }) + + assert [row['text'] for row in list_rows(base, table_name)] == ['keep'] + + +def test_delete_rows(base: Base, websocket: WebSocketClient): + table_name = 'test_delete_rows' + table, row_ids = setup_table(base, table_name, [{'text': 'a'}, {'text': 'b'}, {'text': 'keep'}]) + text_key = table['columns'][0]['key'] + + execute(websocket, { + 'op_type': 'delete_rows', + 'table_id': table['_id'], + 'row_ids': row_ids[:2], + 'deleted_rows': [{'_id': row_ids[0], text_key: 'a'}, {'_id': row_ids[1], text_key: 'b'}], + 'upper_row_ids': [None, row_ids[0]], + }) + + assert [row['text'] for row in list_rows(base, table_name)] == ['keep'] + + +def test_lock_and_unlock_rows(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_lock_and_unlock_rows' + table, row_ids = setup_table(base, table_name, [{'text': 'a'}]) + + execute(websocket, { + 'op_type': 'lock_rows', + 'table_id': table['_id'], + 'row_ids': row_ids, + 'locked_by': 'automated-test', + }) + + row = list_rows(base, table_name)[0] + + assert row['_locked'] is True + assert row['_locked_by'] == 'automated-test' + assert snapshot_json(matcher=MATCHER) == normalize(row) + + execute(websocket, { + 'op_type': 'unlock_rows', + 'table_id': table['_id'], + 'row_ids': row_ids, + }) + + row = list_rows(base, table_name)[0] + + assert row['_locked'] is None + assert row['_locked_by'] is None + assert snapshot_json(matcher=MATCHER) == normalize(row) + + +def test_insert_column(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_insert_column' + table, _ = setup_table(base, table_name) + + execute(websocket, { + 'op_type': 'insert_column', + 'table_id': table['_id'], + # The new column is inserted behind the column with this key + 'column_key': table['columns'][0]['key'], + # The column is stored as it is sent here, the server adds no defaults + # (width, editable, ... are only set for columns created via REST) + 'column_data': {'key': 'ws01', 'type': 'single-select', 'name': 'status', 'data': {'options': []}}, + }) + + column = get_column(base, table_name, 'ws01') + + assert column['name'] == 'status' + assert column['type'] == 'single-select' + assert snapshot_json(matcher=MATCHER) == normalize(column) + + +def test_insert_columns(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_insert_columns' + table, _ = setup_table(base, table_name) + + execute(websocket, { + 'op_type': 'insert_columns', + 'table_id': table['_id'], + 'insert_columns': [ + {'key': 'ws02', 'type': 'text', 'name': 'first'}, + {'key': 'ws03', 'type': 'number', 'name': 'second', 'data': None}, + ], + }) + + columns = [get_column(base, table_name, key) for key in ('ws02', 'ws03')] + + assert [column['name'] for column in columns] == ['first', 'second'] + assert snapshot_json(matcher=MATCHER) == normalize(columns) + + +def test_delete_column(base: Base, websocket: WebSocketClient): + table_name = 'test_delete_column' + table, _ = setup_table(base, table_name) + text_column, number_column = table['columns'] + + execute(websocket, { + 'op_type': 'delete_column', + 'table_id': table['_id'], + 'column_key': number_column['key'], + 'old_column': number_column, + 'upper_column_key': text_column['key'], + }) + + assert get_column(base, table_name, number_column['key']) is None + assert get_column(base, table_name, text_column['key']) is not None + + +def test_modify_column_data(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_modify_column_data' + create_table(base, table_name, [{'column_name': 'status', 'column_type': 'single-select'}]) + table = get_table(base, table_name) + column_key = table['columns'][0]['key'] + options = [{'id': '123456', 'name': 'open', 'color': '#FF8000', 'textColor': '#FFFFFF'}] + + execute(websocket, { + 'op_type': 'modify_column_data', + 'table_id': table['_id'], + 'column_key': column_key, + 'column_data': {'options': options}, + }) + + column = get_column(base, table_name, column_key) + + assert column['data']['options'] == options + assert snapshot_json(matcher=MATCHER) == normalize(column) + + +# Operations that change a column, and the column attributes they change. The +# server stores the column as it is sent, so 'new_column' has to be the complete +# column - sending only the changed attributes drops all the others. +COLUMN_OPERATIONS = [ + ('rename_column', + lambda column: { + 'column_key': column['key'], + 'new_column_name': 'renamed', + 'old_column_name': column['name'], + }, + {'name': 'renamed'}), + ('resize_column', + lambda column: { + 'column_key': column['key'], + 'new_column_width': 400, + 'old_column_width': column['width'], + }, + {'width': 400}), + ('freeze_column', + lambda column: {'column_key': column['key'], 'frozen': True}, + {'frozen': True}), + ('modify_column_type', + lambda column: { + 'column_key': column['key'], + 'new_column': {**column, 'type': 'long-text', 'data': None}, + 'old_column': column, + 'new_rows_data': [], + 'old_rows_data': [], + }, + {'type': 'long-text'}), +] + + +@pytest.mark.parametrize('op_type, build_operation, expected', + COLUMN_OPERATIONS, ids=[operation[0] for operation in COLUMN_OPERATIONS]) +def test_column_operation(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion, + op_type: str, build_operation, expected: dict): + table_name = f'test_column_operation_{op_type}' + table, _ = setup_table(base, table_name) + text_column = table['columns'][0] + + execute(websocket, {'op_type': op_type, 'table_id': table['_id'], **build_operation(text_column)}) + + column = get_column(base, table_name, text_column['key']) + + for attribute, value in expected.items(): + assert column[attribute] == value + + assert snapshot_json(matcher=MATCHER) == normalize(column) + + +def test_insert_table(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_insert_table' + + execute(websocket, { + 'op_type': 'insert_table', + 'table_data': { + '_id': 'ws04', + 'name': table_name, + 'is_header_locked': False, + 'columns': [{'key': '0000', 'type': 'text', 'name': 'Name'}], + 'rows': [], + 'id_row_map': {}, + 'view_structure': {'folders': [], 'view_ids': ['0000']}, + 'views': [{ + '_id': '0000', + 'name': 'Default View', + 'type': 'table', + 'rows': [], + 'formula_rows': {}, + 'filter_conjunction': 'And', + 'filters': [], + 'sorts': [], + 'groupbys': [], + 'hidden_columns': [], + 'row_height': 'default', + 'colorbys': {}, + 'groups': [], + }], + }, + }) + + table = get_table(base, table_name) + + assert table['_id'] == 'ws04' + assert [column['name'] for column in table['columns']] == ['Name'] + assert list_rows(base, table_name) == [] + assert snapshot_json(matcher=MATCHER) == normalize(table) + + +def test_rename_table(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_rename_table' + table, _ = setup_table(base, table_name) + + execute(websocket, { + 'op_type': 'rename_table', + 'table_id': table['_id'], + 'table_name': f'{table_name}_renamed', + }) + + renamed_table = get_table(base, f'{table_name}_renamed') + + assert renamed_table['_id'] == table['_id'] + assert snapshot_json(matcher=MATCHER) == normalize(renamed_table) + + +def test_delete_table(base: Base, websocket: WebSocketClient): + table_name = 'test_delete_table' + table, _ = setup_table(base, table_name) + + execute(websocket, { + 'op_type': 'delete_table', + 'table_id': table['_id'], + 'table_name': table_name, + 'deleted_table': table, + }) + + response = call(base, 'getMetadata') + table_names = [table['name'] for table in response.json()['metadata']['tables']] + + assert table_name not in table_names + + +def test_modify_header_lock(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + """The header lock of a table can only be changed over the websocket.""" + table_name = 'test_modify_header_lock' + table, _ = setup_table(base, table_name) + + assert table['is_header_locked'] is False + + execute(websocket, { + 'op_type': 'modify_header_lock', + 'table_id': table['_id'], + 'is_header_locked': True, + }) + + locked_table = get_table(base, table_name) + + assert locked_table['is_header_locked'] is True + assert snapshot_json(matcher=MATCHER) == normalize(locked_table) + + +def test_insert_view(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion): + table_name = 'test_insert_view' + table, _ = setup_table(base, table_name) + + execute(websocket, { + 'op_type': 'insert_view', + 'table_id': table['_id'], + 'view_data': {'_id': 'ws05', 'name': 'New View', 'type': 'table'}, + }) + + view = get_view(base, table_name, 'ws05') + + assert view['name'] == 'New View' + assert snapshot_json(matcher=MATCHER) == normalize(view) + + +def test_delete_view(base: Base, websocket: WebSocketClient): + table_name = 'test_delete_view' + table, _ = setup_table(base, table_name) + call(base, 'createView', query={'table_name': table_name}, body={'name': 'Doomed View', 'type': 'table'}) + view_id = get_table(base, table_name)['views'][-1]['_id'] + + execute(websocket, { + 'op_type': 'delete_view', + 'table_id': table['_id'], + 'view_id': view_id, + 'view_name': 'Doomed View', + }) + + assert get_view(base, table_name, view_id) is None + + +# Operations that change a view, and the view attributes they change. The number +# column is addressed by its key, which is only known once the table exists. +VIEW_OPERATIONS = [ + ('rename_view', + lambda key: {'view_name': 'renamed-view'}, + lambda key: {'name': 'renamed-view'}), + ('modify_filters', + lambda key: { + 'filters': [{'column_key': key, 'filter_predicate': 'greater', 'filter_term': 1}], + 'filter_conjunction': 'And', + }, + lambda key: {'filters': [{'column_key': key, 'filter_predicate': 'greater', 'filter_term': 1}]}), + ('modify_sorts', + lambda key: {'sorts': [{'column_key': key, 'sort_type': 'up'}]}, + lambda key: {'sorts': [{'column_key': key, 'sort_type': 'up'}]}), + ('modify_groupbys', + lambda key: {'groupbys': [{'column_key': key, 'sort_type': 'up'}]}, + lambda key: {'groupbys': [{'column_key': key, 'sort_type': 'up'}]}), + ('modify_hidden_columns', + lambda key: {'hidden_columns': [key]}, + lambda key: {'hidden_columns': [key]}), + ('modify_view_lock', + lambda key: {'is_locked': True}, + lambda key: {'is_locked': True}), + ('modify_row_height', + lambda key: {'row_height': 'double'}, + lambda key: {'row_height': 'double'}), +] + + +@pytest.mark.parametrize('op_type, build_operation, expected', + VIEW_OPERATIONS, ids=[operation[0] for operation in VIEW_OPERATIONS]) +def test_view_operation(base: Base, websocket: WebSocketClient, snapshot_json: SnapshotAssertion, + op_type: str, build_operation, expected): + table_name = f'test_view_operation_{op_type}' + table, _ = setup_table(base, table_name) + view_id = table['views'][0]['_id'] + number_key = table['columns'][1]['key'] + + execute(websocket, { + 'op_type': op_type, + 'table_id': table['_id'], + 'view_id': view_id, + **build_operation(number_key), + }) + + view = get_view(base, table_name, view_id) + + for attribute, value in expected(number_key).items(): + assert view[attribute] == value + + assert snapshot_json(matcher=MATCHER) == normalize(view)