feat(2): mattermost-lts P3 2nd characteristic test (multi-user message visibility) + PARITY/DECISIONS for the postgres-restore recipe-PR
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -919,3 +919,16 @@ connections + `DROP DATABASE … WITH (FORCE)` + `createdb` + reimport (the matr
|
|||||||
trick does NOT cover immich-server's *networked* connections, so FORCE-drop is required). The
|
trick does NOT cover immich-server's *networked* connections, so FORCE-drop is required). The
|
||||||
VectorChord/pgvecto.rs extensions (vchord, vector) + all tables round-trip cleanly — validated live,
|
VectorChord/pgvecto.rs extensions (vchord, vector) + all tables round-trip cleanly — validated live,
|
||||||
then proven green end-to-end via `RECIPE=immich PR=1` (restore tier `test_restore_returns_state` PASS).
|
then proven green end-to-end via `RECIPE=immich PR=1` (restore tier `test_restore_returns_state` PASS).
|
||||||
|
|
||||||
|
## mattermost-lts postgres restore recipe-PR (Phase 2 Q4.5 P4) — 2026-05-30
|
||||||
|
**Decision:** fix mattermost-lts's broken DB restore with a recipe-PR (`recipe-maintainers/mattermost-lts#1`),
|
||||||
|
mirroring the immich precedent. The published recipe's `postgres` service dumps the DB on backup
|
||||||
|
(pg_dump pre-hook) but ships NO `backupbot.restore.post-hook`, and archived the whole live PGDATA dir
|
||||||
|
(`backup.path=/var/lib/postgresql/data/`). backupbot's restore extracts the files under the running
|
||||||
|
postgres, which does NOT reload PGDATA without a restart → the live DB keeps the un-restored state.
|
||||||
|
Proven by the P4 overlay: `test_restore_returns_state` RED (`relation "ci_marker" does not exist`).
|
||||||
|
**Fix:** switch to the coop-cloud `/pg_backup.sh` convention (as matrix-synapse): config-mounted
|
||||||
|
script, `backup` = pg_dump|gzip → backup.sql, `backupbot.backup.volumes.postgres_data.path=backup.sql`
|
||||||
|
(archive only the dump), `restore` post-hook = terminate connections + DROP DATABASE FORCE + createdb
|
||||||
|
+ reimport. postgres:15 plain dump → no special handling (mechanism already proven generic on immich).
|
||||||
|
Validated: `RECIPE=mattermost-lts PR=1` full lifecycle GREEN, restore tier PASSES (ci_marker survives).
|
||||||
|
|||||||
@ -11,17 +11,24 @@ mattermost-lts ships no `test_<op>.py` overlays, so the **generic** install/upgr
|
|||||||
tiers run by default (the Phase-1e invariant: no overlay ⇒ generic runs). The stack bundles its own
|
tiers run by default (the Phase-1e invariant: no overlay ⇒ generic runs). The stack bundles its own
|
||||||
postgres in-compose (no external dep), so no dependency resolution is needed.
|
postgres in-compose (no external dep), so no dependency resolution is needed.
|
||||||
|
|
||||||
## P3 — Recipe-specific functional tests
|
## P3 — Recipe-specific functional tests (≥2 separate characteristic tests)
|
||||||
- `functional/test_health_check.py`
|
1. `functional/test_create_message.py::test_create_message_roundtrip` — **§4.3 create-an-object +
|
||||||
- `test_root_serves` — web app served at `/` (200/302).
|
read-it-back**: first user (system admin) → login → create team → create channel → POST a unique
|
||||||
- `test_system_ping_ok` — GET `/api/v4/system/ping` → `{"status":"OK"}` (proves the mattermost
|
marker message → GET it back by id → assert the text round-trips.
|
||||||
server + API router are live, not a Traefik fallback).
|
2. `functional/test_multiuser_message.py::test_second_user_reads_first_users_message` — the defining
|
||||||
- `functional/test_create_message.py` — **§4.3 prescribed create-an-object + read-it-back** (planned,
|
**team-chat** behaviour (distinct code path: membership + ACL + cross-user delivery): user_a posts a
|
||||||
authored against the live instance): create the first user (system admin) → login → create team →
|
unique marker; a SECOND user (created via admin API, added to team+channel) logs in with its own
|
||||||
create channel → POST a message → GET it back → assert the message text round-trips. *(In progress —
|
session and GETs the channel posts → asserts it sees user_a's message. Not a self read-back.
|
||||||
requires response-header access for the mattermost login `Token`; harness.http extension pending.)*
|
- `functional/test_health_check.py` — `test_root_serves` (`/` 200/302) + `test_system_ping_ok`
|
||||||
|
(`/api/v4/system/ping` → `{"status":"OK"}`, API liveness). Supporting health/liveness, not counted
|
||||||
|
toward the P3 ≥2 floor.
|
||||||
|
|
||||||
## P4 — Backup data-integrity
|
## P4 — Backup data-integrity (real)
|
||||||
Planned `ops.py`: pre_backup seeds an identifiable message via the API; pre_restore mutates/wipes;
|
`ops.py` seeds a postgres `ci_marker` row (psql in the `postgres` service); `test_backup.py` asserts
|
||||||
the restore assertion re-reads the message and asserts it survived (recipe-aware, not health-only).
|
it at backup time, `test_restore.py` asserts it survives restore, `test_upgrade.py` asserts it survives
|
||||||
*(Follows once the create-message API flow is proven green.)*
|
the upgrade. **Requires recipe-PR `recipe-maintainers/mattermost-lts#1`**: the published recipe dumped
|
||||||
|
the DB on backup but shipped NO `backupbot.restore.post-hook` and archived the whole live PGDATA dir,
|
||||||
|
so a restore extracted files under the running postgres without reloading them → the DB silently kept
|
||||||
|
the un-restored state (`test_restore_returns_state` RED). The PR switches to the coop-cloud
|
||||||
|
`/pg_backup.sh` convention (dump-only backup + terminate/FORCE-drop/recreate/reimport restore); with it
|
||||||
|
the ci_marker survives backup→restore. See DECISIONS.md "mattermost-lts postgres restore recipe-PR".
|
||||||
|
|||||||
114
tests/mattermost-lts/functional/test_multiuser_message.py
Normal file
114
tests/mattermost-lts/functional/test_multiuser_message.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"""mattermost-lts — 2nd recipe-specific functional test (Phase 2 P3): multi-user message visibility.
|
||||||
|
|
||||||
|
The defining behaviour of a team-chat platform is that a message one user posts is delivered to and
|
||||||
|
readable by *another* user in the same channel — not just round-tripped by its own author (that is
|
||||||
|
`test_create_message.py`). This exercises the real membership + post-delivery path end-to-end:
|
||||||
|
|
||||||
|
1. Bootstrap user_a (first user = system admin) → login → create team + open channel.
|
||||||
|
2. user_a posts a unique marker message to the channel.
|
||||||
|
3. Create a SECOND user (user_b) via the admin API; add user_b to the team + the channel.
|
||||||
|
4. user_b logs in (its own session token) and GETs the channel's posts.
|
||||||
|
5. Assert user_b sees user_a's marker message — cross-user delivery, not a self read-back.
|
||||||
|
|
||||||
|
Distinct code path from the single-user post round-trip (membership, ACL, multi-session post fetch).
|
||||||
|
Real assertions on delivered app state; unique marker per run so a stale/echoed response can't pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||||
|
from harness import http as harness_http # noqa: E402
|
||||||
|
|
||||||
|
_PW = "Ccci-Test-Pw-2026!"
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _login(base: str, login_id: str) -> str:
|
||||||
|
status, _, hdrs = harness_http.post_with_headers(
|
||||||
|
f"{base}/users/login", data={"login_id": login_id, "password": _PW}, timeout=30
|
||||||
|
)
|
||||||
|
assert status == 200, f"login {login_id} failed: HTTP {status}"
|
||||||
|
token = hdrs.get("Token") or hdrs.get("token")
|
||||||
|
assert token, f"login {login_id} returned no Token header; headers={list(hdrs.keys())}"
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_user_reads_first_users_message(live_app):
|
||||||
|
base = f"https://{live_app}/api/v4"
|
||||||
|
uniq = uuid.uuid4().hex[:10]
|
||||||
|
|
||||||
|
# 1) user_a = first user (system admin), login, team + channel
|
||||||
|
email_a = f"ccci{uniq}@ccci.example.com"
|
||||||
|
status, ua = harness_http.http_post(
|
||||||
|
f"{base}/users",
|
||||||
|
data={"email": email_a, "username": f"ccci{uniq}", "password": _PW},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201) and ua.get("id"), f"user_a create HTTP {status}: {ua!r}"
|
||||||
|
auth_a = _bearer(_login(base, email_a))
|
||||||
|
|
||||||
|
status, team = harness_http.http_post(
|
||||||
|
f"{base}/teams",
|
||||||
|
data={"name": f"t{uniq}", "display_name": f"ccci {uniq}", "type": "O"},
|
||||||
|
headers=auth_a,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201) and team.get("id"), f"team create HTTP {status}: {team!r}"
|
||||||
|
status, chan = harness_http.http_post(
|
||||||
|
f"{base}/channels",
|
||||||
|
data={"team_id": team["id"], "name": f"c{uniq}", "display_name": f"chan {uniq}", "type": "O"},
|
||||||
|
headers=auth_a,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201) and chan.get("id"), f"channel create HTTP {status}: {chan!r}"
|
||||||
|
|
||||||
|
# 2) user_a posts a unique marker
|
||||||
|
marker = f"ccci-multiuser-{uniq}"
|
||||||
|
status, post = harness_http.http_post(
|
||||||
|
f"{base}/posts", data={"channel_id": chan["id"], "message": marker}, headers=auth_a, timeout=30
|
||||||
|
)
|
||||||
|
assert status in (200, 201) and post.get("id"), f"post create HTTP {status}: {post!r}"
|
||||||
|
|
||||||
|
# 3) create user_b (admin API) + add to team + channel
|
||||||
|
email_b = f"ccci{uniq}b@ccci.example.com"
|
||||||
|
status, ub = harness_http.http_post(
|
||||||
|
f"{base}/users",
|
||||||
|
data={"email": email_b, "username": f"ccci{uniq}b", "password": _PW},
|
||||||
|
headers=auth_a,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201) and ub.get("id"), f"user_b create HTTP {status}: {ub!r}"
|
||||||
|
status, _ = harness_http.http_post(
|
||||||
|
f"{base}/teams/{team['id']}/members",
|
||||||
|
data={"team_id": team["id"], "user_id": ub["id"]},
|
||||||
|
headers=auth_a,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201), f"add user_b to team HTTP {status}"
|
||||||
|
status, _ = harness_http.http_post(
|
||||||
|
f"{base}/channels/{chan['id']}/members",
|
||||||
|
data={"user_id": ub["id"]},
|
||||||
|
headers=auth_a,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert status in (200, 201), f"add user_b to channel HTTP {status}"
|
||||||
|
|
||||||
|
# 4) user_b logs in (own session) and reads the channel posts
|
||||||
|
auth_b = _bearer(_login(base, email_b))
|
||||||
|
status, posts = harness_http.http_get(
|
||||||
|
f"{base}/channels/{chan['id']}/posts", headers=auth_b, timeout=30
|
||||||
|
)
|
||||||
|
assert status == 200 and isinstance(posts, dict), f"user_b get posts HTTP {status}: {posts!r}"
|
||||||
|
|
||||||
|
# 5) user_b sees user_a's marker (cross-user delivery, not a self read-back)
|
||||||
|
messages = [p.get("message") for p in (posts.get("posts") or {}).values()]
|
||||||
|
assert marker in messages, (
|
||||||
|
f"user_b did not see user_a's message {marker!r} in the channel; saw {messages!r}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user