Remove the Go reference server; keep only Rust
CI / Rust (fmt / clippy / test / build) (push) Successful in 1m41s
CI / HTTP/JSON reference + SQLite persistence (push) Successful in 1m16s
CI / Rust (fmt / clippy / test / build) (pull_request) Successful in 47s
CI / HTTP/JSON reference + SQLite persistence (pull_request) Successful in 1m34s

The Go implementation is preserved in the v1.0.0 tag. Its observable
behaviour stays the API reference: responses recorded from the Go server
live in tools/compatibility_expected.txt, and tools/compatibility.py now
compares the Rust server against them (--update re-records after an
intentional change). The Go SQLite schema is frozen in
tests/fixtures/go_schema_v1.sql for the existing-database test.

Move the web client from internal/httpapi/web to web/, drop the Go CI job
and Go version pin, and update README, AGENTS.md and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wtandClaude Opus 5.5 committed 2026-10-30 16:27:33 +07:00
1 parent 16dd2a3b3c
commit 10272f3851
33 files changed
+890 -1438

No files matched your search

+51 -28
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env python3
"""Black-box Go/Rust compatibility and bilateral SQLite takeover checks (stdlib only)."""
"""Black-box HTTP/JSON regression checks against the recorded Go reference (stdlib only).
compatibility_expected.txt holds the observable responses of the original Go
server (v1.0.0), which the Apple and Android clients were built against. Any
difference is a client-visible API change. After an intentional change, review
the diff and re-record with --update.
"""
import argparse
import ast
import base64
import concurrent.futures
import contextlib
@@ -9,6 +16,7 @@ import hashlib
import hmac
import json
import os
import pprint
from pathlib import Path
import socket
import sqlite3
@@ -17,6 +25,7 @@ import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
EXPECTED = Path(__file__).with_name('compatibility_expected.txt')
SECRET = 'compatibility-test-secret-not-for-deployment-0123456789'
PASSWORD = 'compatibility password'
@@ -370,38 +379,52 @@ def raw_json_scenarios(binary, database):
return observed
def web_assets(value):
"""Stand in for embedded web files, so the reference survives web client edits."""
if isinstance(value, bytes):
for asset in sorted((ROOT / 'web').iterdir()):
if value == asset.read_bytes():
return ('web asset', asset.name)
return value
if isinstance(value, dict):
return {k: web_assets(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return type(value)(web_assets(v) for v in value)
return value
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go-binary', type=Path)
parser.add_argument('--rust-binary', type=Path, default=ROOT / 'target/debug/balance-server')
parser.add_argument('--binary', type=Path, default=ROOT / 'target/debug/balance-server')
parser.add_argument('--update', action='store_true',
help=f'record the responses as the new reference in {EXPECTED.name}')
args = parser.parse_args()
binary = args.binary.resolve()
assert binary.is_file(), f'Build first: cargo build --locked (missing {binary})'
with tempfile.TemporaryDirectory(prefix='balance-compatibility-') as directory:
directory = Path(directory)
go = args.go_binary.resolve() if args.go_binary else directory / 'go-server'
if args.go_binary is None:
subprocess.run(['go','build','-o',str(go),'./cmd/balance-server'], cwd=ROOT, check=True)
rust = args.rust_binary.resolve()
assert rust.is_file(), f'Build Rust first: cargo build --locked (missing {rust})'
print('Running Go reference scenarios...', flush=True)
expected = scenario(go, directory / 'go.db')
print('Running Rust scenarios...', flush=True)
actual = scenario(rust, directory / 'rust.db')
for key in expected:
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
print('Comparing HTTP/JSON edge cases...', flush=True)
expected = edge_scenarios(go, directory / 'go-edge.db')
actual = edge_scenarios(rust, directory / 'rust-edge.db')
for key in expected:
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
print('Comparing raw JSON payload and Unicode preservation...', flush=True)
expected = raw_json_scenarios(go, directory / 'go-raw.db')
actual = raw_json_scenarios(rust, directory / 'rust-raw.db')
for key in expected:
assert actual[key] == expected[key], f'Differential mismatch: {key}\nGo: {expected[key]!r}\nRust: {actual[key]!r}'
for first, second, name in [(go,rust,'go-rust'),(rust,go,'rust-go')]:
print(f'Checking {name} SQLite takeover and rollback...', flush=True)
takeover(first, second, directory / f'{name}.db')
print('PASS: HTTP differential scenarios and bilateral SQLite takeover/rollback')
actual = {}
print('Running HTTP/JSON scenarios...', flush=True)
actual['scenario'] = scenario(binary, directory / 'scenario.db')
print('Running HTTP/JSON edge cases...', flush=True)
actual['edge'] = edge_scenarios(binary, directory / 'edge.db')
print('Running raw JSON payload and Unicode preservation...', flush=True)
actual['raw'] = raw_json_scenarios(binary, directory / 'raw.db')
actual = web_assets(actual)
if args.update:
EXPECTED.write_text(pprint.pformat(actual, width=100) + '\n')
print(f'Recorded {EXPECTED.relative_to(ROOT)}')
else:
expected = ast.literal_eval(EXPECTED.read_text())
for group in expected:
for key in expected[group]:
assert actual[group].get(key) == expected[group][key], (
f'Reference mismatch: {group} {key}\n'
f'Expected: {expected[group][key]!r}\nActual: {actual[group].get(key)!r}')
assert actual[group].keys() == expected[group].keys(), f'Scenario set changed in {group}; re-record with --update'
print('Checking SQLite persistence across restarts...', flush=True)
takeover(binary, binary, directory / 'restart.db')
print('PASS: HTTP/JSON reference scenarios and SQLite persistence')
if __name__ == '__main__':
+769
View File
@@ -0,0 +1,769 @@
{'edge': {'expired refresh': (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('DELETE', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/me'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('DELETE', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('HEAD', '/app.js'): (200, {'content-type': 'text/javascript; charset=utf-8'}, b''),
('HEAD', '/health'): (200, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/auth/login'): (404,
{'content-type': 'application/json; charset=utf-8'},
b''),
('HEAD', '/v1/me'): (401, {'content-type': 'application/json; charset=utf-8'}, b''),
('HEAD', '/v1/sync'): (404, {'content-type': 'application/json; charset=utf-8'}, b''),
('PATCH', '/app.js'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/health'): (405,
{'allow': 'GET, HEAD',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PATCH', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/auth/login'): (400,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('POST', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('POST', '/v1/sync'): (401,
{'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('PUT', '/app.js'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/health'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/auth/login'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/me'): (405,
{'allow': 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('PUT', '/v1/sync'): (405,
{'allow': 'GET, HEAD, POST',
'content-type': 'text/plain; charset=utf-8'},
b'Method Not Allowed\n'),
('bearer', ''): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Basic bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('bearer', 'Bearer '): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
('bearer', 'Bearer bad'): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('bearer', 'bearer bad'): (401,
{},
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is '
'required'}}),
('login', 0): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 1): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 2): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 3): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('login', 5): (401,
{},
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('login', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 0): (204, {}, b''),
('logout JSON', 1): (204, {}, b''),
('logout JSON', 2): (204, {}, b''),
('logout JSON', 3): (204, {}, b''),
('logout JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('logout JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('password boundary', 0): 201,
('password boundary', 1): 201,
('password boundary', 2): (400,
{},
{'error': {'code': 'invalid_password',
'message': 'password is too long'}}),
('path', '/%2e%2e/app.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '/%61pp.js'): (200,
{'cache-control': 'public, max-age=3600',
'content-type': 'text/javascript; charset=utf-8'},
('web asset', 'app.js')),
('path', '//app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/a/../app.js'): (307,
{'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/app.js/'): (301,
{'cache-control': 'public, max-age=3600', 'location': '../app.js'},
b''),
('path', '/index.html'): (301, {'cache-control': 'no-cache', 'location': './'}, b''),
('path', '/v1'): (404,
{'content-type': 'text/plain; charset=utf-8'},
b'404 page not found\n'),
('path', '/v1/'): (404,
{'cache-control': 'no-store',
'content-type': 'application/json; charset=utf-8'},
{'error': {'code': 'not_found', 'message': 'Endpoint not found'}}),
('path', '/v1/../app.js'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/app.js'},
b'<a href="/app.js">Temporary Redirect</a>.\n\n'),
('path', '/v1//me'): (307,
{'cache-control': 'no-store',
'content-type': 'text/html; charset=utf-8',
'location': '/v1/me'},
b'<a href="/v1/me">Temporary Redirect</a>.\n\n'),
('range', 'bytes=-10'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 1112-1121/1122',
'content-type': 'image/svg+xml'},
b'/>\n</svg>\n'),
('range', 'bytes=0-0,2-2'): (200,
{'accept-ranges': 'bytes', 'content-type': 'image/svg+xml'},
('web asset', 'icon.svg')),
('range', 'bytes=0-9'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 0-9/1122',
'content-type': 'image/svg+xml'},
b'<svg xmlns'),
('range', 'bytes=10-'): (206,
{'accept-ranges': 'bytes',
'content-range': 'bytes 10-1121/1122',
'content-type': 'image/svg+xml'},
b'="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="im'
b'g" aria-labelledby="title">\n <title id="title">Balance</tit'
b'le>\n <defs>\n <linearGradient id="bg" x1="70" y1="36" x2='
b'"446" y2="476" gradientUnits="userSpaceOnUse">\n <stop s'
b'top-color="#7777ef"/>\n <stop offset="1" stop-color="#42'
b'42b8"/>\n </linearGradient>\n <linearGradient id="shine"'
b' x1="144" y1="122" x2="366" y2="386" gradientUnits="userSpac'
b'eOnUse">\n <stop stop-color="#fff"/>\n <stop offset='
b'"1" stop-color="#dedfff"/>\n </linearGradient>\n </def'
b's>\n <rect x="28" y="28" width="456" height="456" rx="128" f'
b'ill="url(#bg)"/>\n <path d="M141 173c0-25 20-45 45-45h140c25'
b' 0 45 20 45 45v18H186c-25 0-45 20-45 45v-63Z" fill="#fff" op'
b'acity=".5"/>\n <path d="M116 222c0-28 23-51 51-51h179c28 0 5'
b'1 23 51 51v126c0 28-23 51-51 51H167c-28 0-51-23-51-51V222Z" '
b'fill="url(#shine)"/>\n <path d="M302 258h95v86h-95c-24 0-43-'
b'19-43-43s19-43 43-43Z" fill="#5b5bd6"/>\n <circle cx="306" c'
b'y="301" r="13" fill="#fff"/>\n <path d="M184 250h44M184 289h'
b'44M184 328h44" stroke="#5b5bd6" stroke-width="18" stroke-lin'
b'ecap="round" opacity=".72"/>\n</svg>\n'),
('range', 'bytes=10-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'bytes=99999999-'): (416,
{'content-range': 'bytes */1122',
'content-type': 'text/plain; charset=utf-8'},
b'invalid range: failed to overlap\n'),
('range', 'bytes=bad'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('range', 'items=0-1'): (416,
{'content-type': 'text/plain; charset=utf-8'},
b'invalid range\n'),
('raw sync', 0): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 1): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 2): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 3): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 6): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('raw sync', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 8): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('raw sync', 9): (200, {}, {'changes': [], 'cursor': 0, 'hasMore': False}),
('raw sync', 10): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 0): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 1): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 2): (400,
{},
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
('refresh JSON', 3): (401,
{},
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}}),
('refresh JSON', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('refresh JSON', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 0): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 1): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 2): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 3): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 4): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('register', 5): (400,
{},
{'error': {'code': 'invalid_email',
'message': 'Enter a valid email address'}}),
('register', 6): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('signed claims', 0): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 1): (200, {}, {'email': 'e', 'id': 'u'}),
('signed claims', 2): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 3): (200, {}, {'email': '', 'id': 'u'}),
('signed claims', 4): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 5): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('signed claims', 6): (401,
{},
{'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}}),
('sync validation', 0): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'entity must be transaction, category, or '
'budget'}}),
('sync validation', 1): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'record id is invalid'}}),
('sync validation', 2): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 3): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 4): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is required'}}),
('sync validation', 5): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 6): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
('sync validation', 7): (400,
{},
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}}),
('sync validation', 8): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 9): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 10): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 11): (200,
{},
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00.123456789Z',
'version': 2}],
'cursor': 2,
'hasMore': False}),
('sync validation', 12): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'updatedAt is too far in the future'}}),
('sync validation', 13): (400,
{},
{'error': {'code': 'invalid_change',
'message': 'payload is too large'}})},
'raw': {'raw database payloads': [(b'wire-0',
b'{ "duplicate":1, "duplicate":2, "n":123456789012345678901234'
b'567890, "big":1e999 }'),
(b'wire-1',
b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}'),
(b'wire-2', b'{"invalid":"\xff\xc0\xed\xa0\x80"}'),
(b'wire-3',
b'{"html":"<>&","separator":"\xe2\x80\xa8\xe2\x80\xa9"}'),
(b'wire-4',
b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true'
b']}'),
(b'\xef\xbf\xbd', b'{}')],
('unicode credentials', 0): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode credentials', 1): (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email or password is incorrect'}}),
('unicode id', 0): {'changes': [{'deleted': False,
'entity': 'category',
'id': '�',
'payload': {},
'sequence': 6,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 1}],
'cursor': 6,
'hasMore': False},
('unicode id', 1): {'changes': [], 'cursor': 6, 'hasMore': False},
('wire payload', 0): b'{"duplicate":1,"duplicate":2,"n":123456789012345678901234567890,'
b'"big":1e999}',
('wire payload', 1): b'{"lone":"\\ud800","low":"\\udfff","pair":"\\ud83d\\ude00"}',
('wire payload', 2): b'{"invalid":"\xff\xc0\xed\xa0\x80"}',
('wire payload', 3): b'{"html":"\\u003c\\u003e\\u0026","separator":"\\u2028\\u2029"}',
('wire payload', 4): b'{"nested":[{"payload":{"id":"inner"}},"quote\\"end",null,true]}'},
'scenario': {'/': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri 'none'; connect-src "
"'self'; font-src 'self'; form-action 'self'; "
"frame-ancestors 'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/html; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'index.html')),
'/app.js': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors 'none'; "
"img-src 'self' data:; manifest-src 'self'; "
"script-src 'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'app.js')),
'/icon.svg': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'image/svg+xml',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'icon.svg')),
'/manifest.webmanifest': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; "
"script-src 'self'; style-src "
"'self'; worker-src 'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'manifest.webmanifest')),
'/missing-file': (404,
{'cache-control': None,
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; worker-src "
"'self'",
'content-type': 'text/plain; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
b'404 page not found\n'),
'/service-worker.js': (200,
{'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; base-uri "
"'none'; connect-src 'self'; "
"font-src 'self'; form-action "
"'self'; frame-ancestors 'none'; "
"img-src 'self' data:; "
"manifest-src 'self'; script-src "
"'self'; style-src 'self'; "
"worker-src 'self'",
'content-type': 'text/javascript; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), '
'geolocation=(), payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'service-worker.js')),
'/styles.css': (200,
{'cache-control': 'public, max-age=3600',
'content-security-policy': "default-src 'self'; base-uri 'none'; "
"connect-src 'self'; font-src 'self'; "
"form-action 'self'; frame-ancestors "
"'none'; img-src 'self' data:; "
"manifest-src 'self'; script-src 'self'; "
"style-src 'self'; worker-src 'self'",
'content-type': 'text/css; charset=utf-8',
'permissions-policy': 'camera=(), microphone=(), geolocation=(), '
'payment=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY'},
('web asset', 'styles.css')),
'GET /v1/me None': (401,
{'error': {'code': 'authorization_required',
'message': 'Authorization Bearer token is required'}}),
'GET /v1/missing None': (404,
{'error': {'code': 'not_found',
'message': 'Endpoint not found'}}),
"POST /v1/auth/login {'email': 'User@Example.COM', 'password': 'compatibility password'}": (401,
{'error': {'code': 'invalid_credentials',
'message': 'Email '
'or '
'password '
'is '
'incorrect'}}),
"POST /v1/auth/login {'unknown': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not '
'valid JSON'}}),
"POST /v1/auth/refresh {'refreshToken': 'bad'}": (401,
{'error': {'code': 'invalid_refresh_token',
'message': 'Refresh '
'token is '
'invalid or '
'expired'}}),
'POST /v1/auth/refresh {}': (400,
{'error': {'code': 'refresh_token_required',
'message': 'Refresh token is required'}}),
"POST /v1/auth/register {'email': 'a@b', 'password': 'short'}": (400,
{'error': {'code': 'invalid_password',
'message': 'password '
'must '
'contain '
'at '
'least '
'8 '
'characters'}}),
"POST /v1/auth/register {'email': 'bad', 'password': 'compatibility password'}": (400,
{'error': {'code': 'invalid_email',
'message': 'Enter '
'a '
'valid '
'email '
'address'}}),
'deleted': {'changes': [{'deleted': True,
'entity': 'transaction',
'id': 'one',
'sequence': 5,
'updatedAt': '2025-01-02T00:00:00Z',
'version': 2}],
'cursor': 5,
'hasMore': False},
'duplicate': {'error': {'code': 'email_exists',
'message': 'An account with this email already exists'}},
'expired access': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
'first': {'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'transaction',
'id': 'one',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 2,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'category',
'id': 'cat',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 3,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1},
{'deleted': False,
'entity': 'budget',
'id': 'budget',
'payload': {'amount': 125.5,
'nested': [None, True, 1],
'note': 'Тест €'},
'sequence': 4,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 4,
'hasMore': False},
'https://allowed.example': (204,
{'access-control-allow-headers': 'Authorization, '
'Content-Type',
'access-control-allow-methods': 'GET, POST, OPTIONS',
'access-control-allow-origin': 'https://allowed.example',
'vary': 'Origin'}),
'https://denied.example': (204, {}),
'idempotent': {'changes': [], 'cursor': 4, 'hasMore': False},
'invalid bearer': {'error': {'code': 'invalid_token',
'message': 'Access token is invalid or expired'}},
"invalid sync {'changes': [{'entity': 'category', 'id': 'x'}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'updatedAt '
'is '
'required'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': '', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'record '
'id '
'is '
'invalid'}}),
"invalid sync {'changes': [{'entity': 'transaction', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': None}]}": (200,
{'changes': [{'deleted': False,
'entity': 'transaction',
'id': 'x',
'payload': None,
'sequence': 1,
'updatedAt': '2025-01-01T00:00:00Z',
'version': 1}],
'cursor': 1,
'hasMore': False}),
"invalid sync {'changes': [{'entity': 'unknown', 'id': 'x', 'updatedAt': '2025-01-01T00:00:00Z', 'payload': {'amount': 125.5, 'note': 'Тест €', 'nested': [None, True, 1]}}]}": (400,
{'error': {'code': 'invalid_change',
'message': 'entity '
'must '
'be '
'transaction, '
'category, '
'or '
'budget'}}),
"invalid sync {'cursor': -1}": (400,
{'error': {'code': 'invalid_sync_request',
'message': 'Cursor or change count is '
'invalid'}}),
"invalid sync {'extra': 1}": (400,
{'error': {'code': 'invalid_json',
'message': 'Request body is not valid '
'JSON'}}),
'logout refresh': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'malformed': {'error': {'code': 'invalid_json',
'message': 'Request body is not valid JSON'}},
'pagination': (505, 510),
'rate limit': {'error': {'code': 'rate_limited',
'message': 'Too many authentication requests'}},
'refresh replay': {'error': {'code': 'invalid_refresh_token',
'message': 'Refresh token is invalid or expired'}},
'registration disabled': {'error': {'code': 'registration_disabled',
'message': 'Registration is disabled'}}}}