From 7fb5b66e1dc1b463c2f50c726abd82164dbad0eb Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Thu, 27 Aug 2026 11:47:47 +0200 Subject: [PATCH 1/5] Initialize v7.0 I disabled the jobs that sync any files to readme.com on purpose and cleared out the IDs in .github/readme-ids.json. --- .github/readme-ids.json | 18 +++++++++--------- .github/workflows/rdme-openapi.yml | 4 +++- authentication.yaml | 2 +- base_operations.yaml | 2 +- file_operations.yaml | 2 +- ping_and_info.yaml | 2 +- python-scheduler.yaml | 2 +- system_admin_account_operations.yaml | 2 +- team_admin_account_operations.yaml | 2 +- user_account_operations.yaml | 2 +- 10 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/readme-ids.json b/.github/readme-ids.json index d8316d0..864d76c 100644 --- a/.github/readme-ids.json +++ b/.github/readme-ids.json @@ -1,11 +1,11 @@ { - "version": "6.2", - "authentication": "69d4dc0c1422831f8d6fbb8e", - "base_operations": "69d4dc0c1422831f8d6fbb96", - "file_operations": "69d4dc0c1422831f8d6fbb95", - "system_admin_account_operations": "69d4dc0c1422831f8d6fbb92", - "team_admin_account_operations": "69d4dc0c1422831f8d6fbb91", - "user_account_operations": "69d4dc0c1422831f8d6fbb93", - "ping_and_info": "69d4dc0c1422831f8d6fbb90", - "python_scheduler": "69d4dc0c1422831f8d6fbb94" + "version": "7.0", + "authentication": "", + "base_operations": "", + "file_operations": "", + "system_admin_account_operations": "", + "team_admin_account_operations": "", + "user_account_operations": "", + "ping_and_info": "", + "python_scheduler": "" } diff --git a/.github/workflows/rdme-openapi.yml b/.github/workflows/rdme-openapi.yml index 0d266be..79d5b1e 100644 --- a/.github/workflows/rdme-openapi.yml +++ b/.github/workflows/rdme-openapi.yml @@ -30,6 +30,7 @@ jobs: # Job 1: Publish OpenAPI specs and docs to ReadMe.com # ----------------------------------------------------------------------- publish: + if: false # temporarily disabled name: Publish to ReadMe runs-on: ubuntu-latest steps: @@ -175,7 +176,8 @@ jobs: # ----------------------------------------------------------------------- deploy-static: name: Deploy static files - if: github.ref_name == github.event.repository.default_branch + if: false # temporarily disabled + # if: github.ref_name == github.event.repository.default_branch runs-on: ubuntu-latest needs: publish steps: diff --git a/authentication.yaml b/authentication.yaml index a9a3767..90cdd2a 100644 --- a/authentication.yaml +++ b/authentication.yaml @@ -3,7 +3,7 @@ info: title: Authentication description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/base_operations.yaml b/base_operations.yaml index 4f4c30d..f8738fb 100644 --- a/base_operations.yaml +++ b/base_operations.yaml @@ -3,7 +3,7 @@ info: title: Base Operations description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/file_operations.yaml b/file_operations.yaml index 18abb0c..e0f30e8 100644 --- a/file_operations.yaml +++ b/file_operations.yaml @@ -3,7 +3,7 @@ info: title: File Operations description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/ping_and_info.yaml b/ping_and_info.yaml index b103d75..7d98839 100644 --- a/ping_and_info.yaml +++ b/ping_and_info.yaml @@ -3,7 +3,7 @@ info: title: Ping and Server Info description: >- The official SeaTable API Reference (OpenAPI 3.0) - Part "Ping and Server Info". - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/python-scheduler.yaml b/python-scheduler.yaml index fc0914e..a32f681 100644 --- a/python-scheduler.yaml +++ b/python-scheduler.yaml @@ -3,7 +3,7 @@ info: title: Python Scheduler description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/system_admin_account_operations.yaml b/system_admin_account_operations.yaml index 1faf561..b46227e 100644 --- a/system_admin_account_operations.yaml +++ b/system_admin_account_operations.yaml @@ -3,7 +3,7 @@ info: title: "Account Operations: System admin" description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/team_admin_account_operations.yaml b/team_admin_account_operations.yaml index a2b85d1..d5346c7 100644 --- a/team_admin_account_operations.yaml +++ b/team_admin_account_operations.yaml @@ -3,7 +3,7 @@ info: title: Account Operations - Team admin description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/user_account_operations.yaml b/user_account_operations.yaml index ee4e22f..2447bc2 100644 --- a/user_account_operations.yaml +++ b/user_account_operations.yaml @@ -3,7 +3,7 @@ info: title: Account Operations - User description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.2" + version: "7.0" servers: - url: "https://{server}" variables: From c21fe953ff018a0da36092cc25ca018df611d5f9 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Thu, 27 Aug 2026 12:31:24 +0200 Subject: [PATCH 2/5] Remove init-databases.sql mount --- version-compare/docker-compose.yml | 2 -- version-compare/init-databases.sql | 3 --- 2 files changed, 5 deletions(-) delete mode 100644 version-compare/init-databases.sql diff --git a/version-compare/docker-compose.yml b/version-compare/docker-compose.yml index bcad3fe..0060011 100644 --- a/version-compare/docker-compose.yml +++ b/version-compare/docker-compose.yml @@ -46,8 +46,6 @@ services: - MYSQL_LOG_CONSOLE=true - MARIADB_AUTO_UPGRADE=1 - TZ=${TIME_ZONE} - volumes: - - ./init-databases.sql:/docker-entrypoint-initdb.d/init-databases.sql:ro networks: - backend healthcheck: diff --git a/version-compare/init-databases.sql b/version-compare/init-databases.sql deleted file mode 100644 index 0a040bc..0000000 --- a/version-compare/init-databases.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE DATABASE IF NOT EXISTS `dtable_db`; -CREATE DATABASE IF NOT EXISTS `ccnet_db`; -CREATE DATABASE IF NOT EXISTS `seafile_db`; From 6bc01b886d4ad5abf94995bfbd0bb88df7924591 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 31 Aug 2026 15:02:44 +0200 Subject: [PATCH 3/5] Deploy dtable-server container --- .github/workflows/api-tests.yml | 14 ++- .github/workflows/postman.yml | 2 +- .github/workflows/rdme-openapi.yml | 4 +- .github/workflows/version-compare.yml | 2 +- README.md | 4 + ping_and_info.yaml | 13 +- .../test_getSystemInformation.json | 5 + tests/test_ping.py | 14 --- version-compare/config/seatable-nginx.conf | 113 ++++++++++++++++++ version-compare/docker-compose.yml | 41 ++++++- version-compare/setup.sh | 13 +- 11 files changed, 197 insertions(+), 28 deletions(-) create mode 100644 version-compare/config/seatable-nginx.conf diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 3130dca..50f7425 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -15,9 +15,9 @@ on: workflow_dispatch: inputs: version: - description: "SeaTable version (e.g. 6.1.8)" + description: "SeaTable version" required: true - default: "6.1.8" + default: "7.0.5" image: description: "Docker Hub repository" required: true @@ -28,13 +28,14 @@ on: - "seatable/seatable-enterprise-testing" env: - DEFAULT_VERSION: "6.2.12" + DEFAULT_VERSION: "7.0.5" DEFAULT_IMAGE: "seatable/seatable-enterprise-testing" + DTABLE_SERVER_VERSION: "7.0.3-testing" jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v6 @@ -67,8 +68,11 @@ jobs: - name: Start SeaTable ${{ steps.version.outputs.version }} working-directory: version-compare + env: + SEATABLE_IMAGE: ${{ steps.version.outputs.image }} + SEATABLE_VERSION: ${{ steps.version.outputs.version }} run: | - SEATABLE_IMAGE=${{ steps.version.outputs.image }} SEATABLE_VERSION=${{ steps.version.outputs.version }} docker compose up -d + docker compose up -d ./setup.sh - name: Run API tests diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 2e00905..5fdefb8 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -10,7 +10,7 @@ env: jobs: postman: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/rdme-openapi.yml b/.github/workflows/rdme-openapi.yml index 79d5b1e..b209a05 100644 --- a/.github/workflows/rdme-openapi.yml +++ b/.github/workflows/rdme-openapi.yml @@ -32,7 +32,7 @@ jobs: publish: if: false # temporarily disabled name: Publish to ReadMe - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v4 @@ -178,7 +178,7 @@ jobs: name: Deploy static files if: false # temporarily disabled # if: github.ref_name == github.event.repository.default_branch - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 needs: publish steps: - name: Check out repo diff --git a/.github/workflows/version-compare.yml b/.github/workflows/version-compare.yml index 3dcc7c7..0abc410 100644 --- a/.github/workflows/version-compare.yml +++ b/.github/workflows/version-compare.yml @@ -38,7 +38,7 @@ on: jobs: compare: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v4 diff --git a/README.md b/README.md index a6fd7bf..1b99a74 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ cd version-compare export SEATABLE_IMAGE=seatable/seatable-enterprise export SEATABLE_VERSION=6.0.10 +# seatable/dtable-server-js or seatable/dtable-server-go +export DTABLE_SERVER_IMAGE=seatable/dtable-server-js +export DTABLE_SERVER_VERSION=7.0.3-testing + # Create license file cp "SOURCE" seatable-license.txt diff --git a/ping_and_info.yaml b/ping_and_info.yaml index 7d98839..8afe28b 100644 --- a/ping_and_info.yaml +++ b/ping_and_info.yaml @@ -115,10 +115,14 @@ paths: - Ping summary: Ping dtable Server operationId: pingDtableServer - description: >- + description: |- Check the status of the dtable-server. Base related operations are carried out by the dtable-server. These operations are included in the section **Base Operations**. + + > 🚧 Not exposed by default + > + > As of SeaTable 7.0, the default NGINX configuration no longer routes `/dtable-server/` and this endpoint returns `404`. responses: "200": description: Service is available and running @@ -134,7 +138,12 @@ paths: - Ping summary: Ping dtable-db Server operationId: pingDtableDbServer - description: Check the status of the dtable-db server. + description: |- + Check the status of the dtable-db server. + + > 🚧 Not exposed by default + > + > As of SeaTable 7.0, the default NGINX configuration no longer routes `/dtable-db/` and this endpoint returns `404`. responses: "200": description: Service is available and running diff --git a/tests/__snapshots__/test_system_admin/test_getSystemInformation.json b/tests/__snapshots__/test_system_admin/test_getSystemInformation.json index aa74e0d..5848b8f 100644 --- a/tests/__snapshots__/test_system_admin/test_getSystemInformation.json +++ b/tests/__snapshots__/test_system_admin/test_getSystemInformation.json @@ -5,11 +5,16 @@ "archived_row_count": "int", "dtable_server_info": [ { + "assigned_keys_count": 0, + "backend": "", "enable_cluster": false, "last_dtable_saving_count": "int", "last_period_operations_count": "int", "loaded_dtables_count": "int", + "local_node_url": "http://dtable-server:5000", + "node_id": "", "operation_count_since_up": "int", + "start_time": "", "web_socket_count": "int" } ], diff --git a/tests/test_ping.py b/tests/test_ping.py index 56f67e9..94e5bfb 100644 --- a/tests/test_ping.py +++ b/tests/test_ping.py @@ -30,20 +30,6 @@ def test_pingServerWithAuth(account_token: Secret): assert response.status_code == 200 assert response.text.strip('"') == 'pong' -def test_pingDtableServer(): - case: Case = ping_and_info_schema.find_operation_by_id('pingDtableServer').Case() - response = case.call() - - assert response.status_code == 200 - assert response.text.strip() == 'pong' - -def test_pingDtableDbServer(): - case: Case = ping_and_info_schema.find_operation_by_id('pingDtableDbServer').Case() - response = case.call() - - assert response.status_code == 200 - assert response.json()['ret'] == 'pong' - def test_pingApiGateway(): case: Case = ping_and_info_schema.find_operation_by_id('pingApiGateway').Case() response = case.call() diff --git a/version-compare/config/seatable-nginx.conf b/version-compare/config/seatable-nginx.conf new file mode 100644 index 0000000..cb7fe73 --- /dev/null +++ b/version-compare/config/seatable-nginx.conf @@ -0,0 +1,113 @@ +log_format seatableformat '\$http_x_forwarded_for \$remote_addr [\$time_local] "\$request" \$status \$body_bytes_sent "\$http_referer" "\$http_user_agent" \$upstream_response_time'; + +server { + listen 80; + listen [::]:80; + server_name _; + + proxy_set_header X-Forwarded-For $remote_addr; + + # CORS settings to allow API access from api.seatable.com + proxy_hide_header 'Access-Control-Allow-Origin'; + add_header 'Access-Control-Allow-Origin' 'https://api.seatable.com' always; + add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Accept, authorization, token, deviceType, x-seafile-otp' always; + if ($request_method = 'OPTIONS') { + return 204; + } + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_read_timeout 1200s; + client_max_body_size 0; + + access_log /opt/nginx-logs/dtable-web.access.log seatableformat; + error_log /opt/nginx-logs/dtable-web.error.log; + } + + location /seafhttp { + rewrite ^/seafhttp(.*)$ $1 break; + proxy_pass http://127.0.0.1:8082; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_request_buffering off; + proxy_connect_timeout 36000s; + proxy_read_timeout 36000s; + proxy_send_timeout 36000s; + send_timeout 36000s; + client_max_body_size 0; + + access_log /opt/nginx-logs/seafhttp.access.log seatableformat; + error_log /opt/nginx-logs/seafhttp.error.log; + } + + location /media { + root /opt/seatable/seatable-server-latest/dtable-web; + access_log off; + error_log off; + } + + location /api-gateway/ { + proxy_pass http://127.0.0.1:7780/; + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + + client_max_body_size 10m; + + access_log /opt/nginx-logs/api-gateway.access.log seatableformat; + error_log /opt/nginx-logs/api-gateway.error.log; + } + + location /api-gateway/socket.io/ { + proxy_pass http://127.0.0.1:7780/socket.io/; + + # websocket proxying + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection upgrade; + + proxy_redirect off; + proxy_buffers 8 32k; + proxy_buffer_size 64k; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-NginX-Proxy true; + + access_log /opt/nginx-logs/socket-io.access.log seatableformat; + error_log /opt/nginx-logs/socket-io.error.log; + } + + location /internal-thumbnails/ { + internal; + alias /opt/seatable/seahub-data/thumbnail/; + + add_header X-Served-By "Nginx-via-X-Accel"; + expires 7d; + add_header Cache-Control "public, no-transform"; + + access_log /opt/nginx-logs/internal-thumbnails.access.log seatableformat; + error_log /opt/nginx-logs/internal-thumbnails.error.log; + } + + # Deprecated endpoints + location /dtable-server { + return 404 'This endpoint is deprecated. Please migrate to the API Gateway: https://forum.seatable.com/t/important-changes-to-the-seatable-cloud-api-in-version-5-3/6677'; + } + + location /dtable-db { + return 404 'This endpoint is deprecated. Please migrate to the API Gateway: https://forum.seatable.com/t/important-changes-to-the-seatable-cloud-api-in-version-5-3/6677'; + } +} diff --git a/version-compare/docker-compose.yml b/version-compare/docker-compose.yml index 0060011..7dae2c5 100644 --- a/version-compare/docker-compose.yml +++ b/version-compare/docker-compose.yml @@ -20,8 +20,10 @@ services: - SEATABLE_ADMIN_EMAIL=admin@example.com - SEATABLE_ADMIN_PASSWORD=admin1234 - TIME_ZONE=Europe/Berlin + - INNER_DTABLE_SERVER_URL=http://dtable-server:5000 volumes: - ./seatable-data:/shared + - ./config/seatable-nginx.conf:/etc/nginx/sites-enabled/default - type: bind source: ./seatable-license.txt target: /shared/seatable/seatable-license.txt @@ -37,8 +39,43 @@ services: networks: - backend + dtable-server: + image: ${DTABLE_SERVER_IMAGE:-seatable/dtable-server-js}:${DTABLE_SERVER_VERSION:?Variable is not set} + container_name: dtable-server + restart: unless-stopped + environment: + - SEATABLE_MYSQL_DB_HOST=mariadb + - SEATABLE_MYSQL_DB_USER=root + - SEATABLE_MYSQL_DB_PORT=3306 + - SEATABLE_MYSQL_DB_PASSWORD=seatable + - SEATABLE_MYSQL_DB_DTABLE_DB_NAME=dtable_db + - SEATABLE_MYSQL_DB_CCNET_DB_NAME=ccnet_db + - SEATABLE_MYSQL_DB_SEAFILE_DB_NAME=seafile_db + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=seatable + - JWT_PRIVATE_KEY=test-jwt-private-key-for-ci + - TIME_ZONE=Europe/Berlin + - TZ=Europe/Berlin + - INNER_DTABLE_WEB_SERVICE_URL=http://seatable-server:80 + - INNER_DTABLE_DB_URL=http://seatable-server:7777 + volumes: + - ./seatable-data:/shared + - type: bind + source: ./seatable-license.txt + target: /shared/seatable/seatable-license.txt + bind: + create_host_path: false + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + mariadb: - image: mariadb:11.8.3-noble + image: mariadb:11.8.5-noble container_name: mariadb command: ["mariadbd", "--innodb_snapshot_isolation=OFF"] environment: @@ -56,7 +93,7 @@ services: timeout: 10s redis: - image: redis:8.2.2-bookworm + image: redis:8.4.0-bookworm container_name: redis command: ["redis-server", "--requirepass", "seatable"] networks: diff --git a/version-compare/setup.sh b/version-compare/setup.sh index ea533a4..ca315d1 100755 --- a/version-compare/setup.sh +++ b/version-compare/setup.sh @@ -18,7 +18,7 @@ echo "Waiting for SeaTable to become available..." start_time=$(date +%s) while true; do - if curl -sf "${SEATABLE_SERVER}/dtable-server/ping/" > /dev/null 2>&1; then + if curl -sf "${SEATABLE_SERVER}/api-gateway/api/v2/ping/" > /dev/null 2>&1; then echo "SeaTable is ready." break fi @@ -197,4 +197,15 @@ SETTINGS echo "Restarting SeaTable to apply settings..." docker exec seatable-server /templates/seatable.sh restart +# FIXME: dtable-server boots in parallel with seatable-server and only symlinks +# /opt/seatable/storage-data -> /shared/seatable/storage-data if that directory +# already exists. On a fresh data directory it does not, so dtable-server keeps a +# container-local storage-data, cannot find any base, and every base operation +# fails with HTTP 500. The container must be *recreated* (not just restarted) — +# a restart keeps the local directory and the symlink step fails with +# "cannot overwrite directory". Remove once dtable-server creates the symlink +# unconditionally. +echo "Recreating dtable-server..." +docker compose up -d --force-recreate dtable-server + echo "Setup complete." From 791b490b1f60cf0fb143e92856cec6ef3c8777d3 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 31 Aug 2026 14:01:34 +0200 Subject: [PATCH 4/5] Add tests for WebSocket operations --- .../test_websocket/test_append_rows.json | 26 + .../test_column_operation[freeze_column].json | 19 + ..._column_operation[modify_column_type].json | 18 + .../test_column_operation[rename_column].json | 18 + .../test_column_operation[resize_column].json | 18 + .../test_websocket/test_insert_column.json | 8 + .../test_websocket/test_insert_columns.json | 13 + .../test_websocket/test_insert_row.json | 12 + .../test_websocket/test_insert_table.json | 29 + .../test_websocket/test_insert_view.json | 5 + .../test_lock_and_unlock_rows.1.json | 12 + .../test_lock_and_unlock_rows.json | 12 + .../test_modify_column_data.json | 27 + .../test_modify_header_lock.json | 71 ++ .../test_websocket/test_modify_row.json | 12 + .../test_websocket/test_modify_rows.json | 26 + .../test_websocket/test_rename_table.json | 71 ++ .../test_view_operation[modify_filters].json | 26 + .../test_view_operation[modify_groupbys].json | 25 + ...view_operation[modify_hidden_columns].json | 22 + ...est_view_operation[modify_row_height].json | 20 + .../test_view_operation[modify_sorts].json | 25 + ...test_view_operation[modify_view_lock].json | 20 + .../test_view_operation[rename_view].json | 20 + tests/requirements.txt | 1 + tests/test_websocket.py | 738 ++++++++++++++++++ 26 files changed, 1294 insertions(+) create mode 100644 tests/__snapshots__/test_websocket/test_append_rows.json create mode 100644 tests/__snapshots__/test_websocket/test_column_operation[freeze_column].json create mode 100644 tests/__snapshots__/test_websocket/test_column_operation[modify_column_type].json create mode 100644 tests/__snapshots__/test_websocket/test_column_operation[rename_column].json create mode 100644 tests/__snapshots__/test_websocket/test_column_operation[resize_column].json create mode 100644 tests/__snapshots__/test_websocket/test_insert_column.json create mode 100644 tests/__snapshots__/test_websocket/test_insert_columns.json create mode 100644 tests/__snapshots__/test_websocket/test_insert_row.json create mode 100644 tests/__snapshots__/test_websocket/test_insert_table.json create mode 100644 tests/__snapshots__/test_websocket/test_insert_view.json create mode 100644 tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.1.json create mode 100644 tests/__snapshots__/test_websocket/test_lock_and_unlock_rows.json create mode 100644 tests/__snapshots__/test_websocket/test_modify_column_data.json create mode 100644 tests/__snapshots__/test_websocket/test_modify_header_lock.json create mode 100644 tests/__snapshots__/test_websocket/test_modify_row.json create mode 100644 tests/__snapshots__/test_websocket/test_modify_rows.json create mode 100644 tests/__snapshots__/test_websocket/test_rename_table.json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_filters].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_groupbys].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_hidden_columns].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_row_height].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_sorts].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[modify_view_lock].json create mode 100644 tests/__snapshots__/test_websocket/test_view_operation[rename_view].json create mode 100644 tests/test_websocket.py 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..641c7e1 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,3 +4,4 @@ syrupy>=4.8 pytest-icdiff>=0.9 pytest-subtests>=0.7 requests>=2.32 +seatable-api==4.0.0 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) From 561be26ea747f6a5bb230799398ffccfd122f84e Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 31 Aug 2026 14:08:12 +0200 Subject: [PATCH 5/5] Add missing websocket-client dependency --- tests/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/requirements.txt b/tests/requirements.txt index 641c7e1..325695e 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -5,3 +5,4 @@ pytest-icdiff>=0.9 pytest-subtests>=0.7 requests>=2.32 seatable-api==4.0.0 +websocket-client==1.9.1