Files
thoughtsync/tests/test_client_dist.py
T
bvandeusen 010e9a2f85
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 47s
server: bake the newest Android client into every image (operator call)
Reverses the placement decision made an hour ago. That one put the APK only on
the data volume, reasoning that ~55 MiB should not be charged to installs that
never touch Android. The operator's call is that ending the manual copy is worth
the megabytes, and it is their deployment.

CI now fetches the newest published client into the build context immediately
before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a
`docker compose pull` delivers a new server and a new client together.

**Always the rolling `dev` release — the newest build there is.** A versioned
image therefore carries the newest client rather than one pinned to that
version. Deliberate: the two negotiate a sync protocol version before linking, so
a mismatch is caught by the handshake, and pinning would buy nothing the
handshake does not already provide.

**Fetched by the JOB, never by the Dockerfile.** The release is private, and a
token used inside a build ends up in the context or a layer.

**It cannot fail the image build.** No release yet, a network blip, a first-ever
build — all of them log a warning and produce an image with no client, which is a
state the server already supports. Half a pair is cleaned up rather than shipped:
a sidecar without its APK is worse than neither, because the server would be
describing something it cannot serve.

**The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy
second, so an operator who deliberately drops a build in gets that build — and a
BROKEN drop-in falls through to the image's copy rather than taking the feature
offline, which is what makes the copy-order advice survivable instead of
load-bearing. Three tests cover the precedence, including that last case.

The baked copy lives inside the package, not under DATA_DIR: that path is a
volume mount, and anything the image wrote there would disappear behind it the
moment one is attached.

`client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI
step never ran; the artifacts themselves are gitignored, since a 55 MiB binary
does not belong in git history and is re-fetched on every build anyway.
2026-08-20 21:19:47 -04:00

155 lines
5.9 KiB
Python

import json
from pathlib import Path
import pytest
from thoughtsync import client_dist
from thoughtsync.app import create_app
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
from thoughtsync.config import Config
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is
# why the advertisement is asserted through `advertisement()` rather than through
# `/api/config`: the route is a one-line merge of this dict into a payload whose
# other half needs a database, and testing it here tests the part that can be wrong.
#
# The two routes below ARE exercised, because neither opens a session: the metadata
# route only stats files, and the download's 401 is returned before any token
# lookup.
PAYLOAD = b"not really an apk, but the server only ever stats it"
@pytest.fixture(autouse=True)
def _empty_baked_client(tmp_path, monkeypatch):
"""Point the baked-in copy at an empty directory.
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
would pass anyway — but only by accident of where they are run. A built image
has a real APK there, and a test that silently depends on which tree it is in
is one that will eventually lie.
"""
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
yield
@pytest.fixture
def app():
return create_app()
def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict:
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
root = root if root is not None else Config.client_root()
root.mkdir(parents=True, exist_ok=True)
(root / APK_NAME).write_bytes(payload)
meta = {
"version_name": "0.1.216",
"version_code": 216,
"size": len(payload),
"sha256": "ab" * 32,
}
meta.update(overrides)
(root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8")
return meta
def test_absent_client_is_advertised_as_nothing_at_all():
"""The KEY is missing, not null.
A client testing for it then gets one unambiguous answer rather than having to
tell "this server has no APK" apart from "this server predates the feature".
"""
assert android_release() is None
assert advertisement() == {}
def test_a_present_client_is_advertised_with_what_android_compares():
place_client()
advertised = advertisement()["android_client"]
assert advertised["version"] == "0.1.216"
# The integer is what decides "is this newer", not the name — a name is a
# string and sorts like one.
assert advertised["version_code"] == 216
assert advertised["size"] == len(PAYLOAD)
assert advertised["url"].endswith("/download")
def test_a_sidecar_describing_a_different_build_counts_as_no_client():
"""The likeliest real corruption: a new APK copied over an old sidecar.
Serving one build while advertising another is worse than serving none — the
phone would compare versions against a promise the bytes do not keep.
"""
place_client(size=999_999)
assert android_release() is None
assert advertisement() == {}
def test_an_unreadable_sidecar_counts_as_no_client():
place_client()
(Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8")
assert android_release() is None
def test_a_sidecar_missing_a_field_counts_as_no_client():
root = Config.client_root()
root.mkdir(parents=True, exist_ok=True)
(root / APK_NAME).write_bytes(PAYLOAD)
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8")
assert android_release() is None
def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client():
root = Config.client_root()
root.mkdir(parents=True, exist_ok=True)
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}))
assert android_release() is None
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app):
place_client()
resp = await app.test_client().get("/api/client/android")
assert resp.status_code == 200
assert (await resp.get_json())["version_code"] == 216
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app):
resp = await app.test_client().get("/api/client/android")
assert resp.status_code == 404
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app):
"""Anyone who can reach the port may ask what version exists; only an account
or a linked device may pull the 55 MiB."""
place_client()
resp = await app.test_client().get("/api/client/android/download")
assert resp.status_code == 401
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
"""The ordinary case for a self-hoster who just pulled the image."""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
assert android_release()["version_code"] == 300
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
"""Someone who deliberately put a build on the volume wants that build."""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
place_client(version_name="0.1.99", version_code=99)
advertised = android_release()
# Lower version and all — precedence is about intent, not about newness. An
# operator pinning an older client is doing it on purpose.
assert advertised["version_code"] == 99
def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
"""A half-finished copy onto the volume must not take the app offline.
This is the failure the copy-order advice in docs/android-distribution.md is
about, and the server should ride it out rather than go dark.
"""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
place_client(size=999_999) # sidecar describing a different build
assert android_release()["version_code"] == 300