Files
thoughtsync/tests/test_integration.py
T
bvandeusenandClaude Opus 5 193dfb9e94
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
tags: renaming onto an existing tag merges them, and the older row survives
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.

I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.

That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.

Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.

  * `store::rename_label` and the server's PATCH now implement one rule.
    THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
    one that already held the name", so that renaming A→B and B→A land on
    the same survivor; otherwise the outcome depends on which way round
    someone typed it, and two devices tidying the same pair disagree about
    which id still exists. Ties go to the incumbent, so it stays
    deterministic.

  * The core reuses `merge_labels` rather than reimplementing the move. That
    is the only place that knows to mark every affected NOTE dirty before
    the delete cascades the membership rows away, which is what makes a
    merge reach the server at all.

  * The server's rename and its `/merge` route now share one `_merge_into`
    helper, for the same reason.

  * Both server lookups became case-INSENSITIVE, matching every client. The
    create path is included: it was the one actually minting the unstorable
    pair, so fixing only the rename would have left the door open.

  * The web asks before merging, naming both note counts. A merge cannot be
    undone by repeating it and is now reachable by a typo in a text field —
    the same reasoning as the delete confirmation in #2116. The confirmation
    lives in the shared store, so the desktop gets it too; the FFI does not
    ask, because that belongs to the surface with a person in front of it.

  * The web store detects the merge from the LIST, not the response: the
    survivor may be the row we asked to rename, so an unchanged id proves
    nothing.

Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.

Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 16:46:15 -04:00

633 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The real-Postgres lane (family rule 6).
Everything else in this suite is deliberately DB-free, which means the schema the
migrations build has never been checked against the models that read it. That gap is
what this file closes, and it is not theoretical: M13 dropped three columns and
rebuilt a generated column, and until now `alembic upgrade head` ran for the first
time when the operator's container started.
Marked `integration` and excluded from the unit lane by `-m "not integration"`, so a
workstation without Postgres runs the rest of the suite unchanged.
The schema comes from real migrations, never `metadata.create_all` (rule 82) — the
point is to test what actually ships, and `create_all` would build a schema no
deployment has ever seen.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note
from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.models.note_revision import NoteRevision
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users"
@pytest_asyncio.fixture
async def db():
"""A session against the migrated database, wiped before each test.
Wiped BEFORE rather than after so a failed test leaves its rows behind to look at.
"""
async with session_scope() as session:
await session.execute(text(f"TRUNCATE {_TABLES} RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await dispose_engine()
@pytest_asyncio.fixture
async def app_client(db):
"""A test client against the real app, over the migrated database.
The credential throttle is process-global and its counters outlive a single
test, so they are cleared here — otherwise a suite that registers a few times
starts handing out 429s for reasons that have nothing to do with the test.
"""
ratelimit.reset_all()
yield create_app().test_client()
ratelimit.reset_all()
@pytest_asyncio.fixture
async def owner(db):
"""A user to hang notes off — `notes.owner_id` is a real foreign key."""
user = User(email=f"{uuid.uuid4().hex}@example.test", display_name="Integration")
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def test_the_migrated_schema_matches_the_models(db, owner):
"""The check that has never run: insert through the ORM, read it back.
A column the models expect and the migrations never created — or the reverse —
fails right here, instead of when a container starts.
"""
note = Note(owner_id=owner.id, body="a thought", display_title="a thought")
db.add(note)
await db.commit()
await db.refresh(note)
found = await db.scalar(select(Note).where(Note.id == note.id))
assert found is not None
assert found.body == "a thought"
assert found.display_title == "a thought"
async def test_the_dropped_columns_are_actually_gone(db):
"""M13 dropped three. If a migration silently no-opped, this is where it shows."""
cols = set(
(
await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'notes'")
)
)
.scalars()
.all()
)
assert "title" not in cols, "notes.title should have gone in 0026"
assert "kind" not in cols, "notes.kind should have gone in 0025"
assert "display_title" in cols and "body" in cols
rev_cols = set(
(
await db.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = 'note_revisions'")
)
)
.scalars()
.all()
)
assert "title" not in rev_cols, "note_revisions.title should have gone in 0026"
tables = set(
(await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")))
.scalars()
.all()
)
assert "note_links" not in tables, "note_links should have gone in 0024"
async def test_the_search_vector_was_rebuilt_over_the_name(db, owner):
"""0026 had to drop and recreate a STORED GENERATED column.
Postgres refuses to drop a column another generated column depends on, so getting
this wrong doesn't produce a subtly wrong ranking — it produces a migration that
won't run at all. Worth proving the replacement actually indexes something.
"""
note = Note(owner_id=owner.id, body="ferry tickets\nbook before friday", display_title="ferry tickets")
db.add(note)
await db.commit()
hit = await db.scalar(
text(
"SELECT count(*) FROM notes "
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
).bindparams(q="ferry")
)
assert hit == 1
# The NAME is weight A and the body weight B, which is what makes a name match
# rank above a body-only one. Both must be in the vector at all.
body_only = await db.scalar(
text(
"SELECT count(*) FROM notes "
"WHERE search_vector @@ websearch_to_tsquery('english', :q)"
).bindparams(q="friday")
)
assert body_only == 1
async def test_a_note_keeps_its_prose_on_both_sides_of_its_list(db, owner):
"""The shape M304 made expressible at all.
The old model could not hold this: a row had a position in a table and none in the
text, so a checklist could only ever render AFTER the body. Prose, list, prose is
the case that proves the storage changed, not just the styling.
"""
body = "weekend shop\n\n- [ ] milk\n- [x] eggs\n\nback before six"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert [(i.text, i.checked) for i in parse_items(stored)] == [("milk", False), ("eggs", True)]
assert stored.splitlines()[0] == "weekend shop"
assert stored.splitlines()[-1] == "back before six"
async def test_ticking_an_item_is_a_body_edit(db, owner):
"""What replaced `_apply_note_items`: there is no separate thing left to apply.
The regression that function guarded against — a sync silently eating a checklist
off a note that also had a body — cannot recur, because there is nothing to delete.
A pushed body either has the lines or it does not.
"""
body = "packing\n\n- [ ] socks"
note = Note(owner_id=owner.id, body=body, display_title="packing")
db.add(note)
await db.commit()
note.body = set_item_checked(note.body, 0, True)
await db.commit()
stored = await db.scalar(select(Note.body).where(Note.id == note.id))
assert stored == "packing\n\n- [x] socks"
assert parse_items(stored)[0].checked
# The prose is untouched — a tick rewrites one line, not the note.
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is False
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is True
assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
the first line with its marker stripped, because calling the note "- [ ] milk"
would show someone the storage instead of the note.
"""
body = "- [ ] milk\n- [ ] eggs"
note = Note(owner_id=owner.id, body=body, display_title=derive_display_title(body))
db.add(note)
await db.commit()
assert (await db.scalar(select(Note.display_title).where(Note.id == note.id))) == "milk"
async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner, monkeypatch):
"""The background pass, run inline so the assertions are deterministic.
The network is stubbed — this is about what reaches the DATABASE, not about
parsing someone's OpenGraph tags (unfurl.py's own tests cover that). What matters
here is the part only a real database can show: the unique constraint holding, the
upsert going to the right row, and a second pass not re-fetching.
"""
note = Note(
owner_id=owner.id,
body="read https://example.com/a and https://example.com/b",
display_title="read https://example.com/a and https://example.com/b",
)
db.add(note)
await db.commit()
calls: list[str] = []
async def fake_unfurl(url):
calls.append(url)
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, note.body)
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
assert {r.url for r in rows} == {"https://example.com/a", "https://example.com/b"}
assert all(r.title.startswith("T ") for r in rows)
# A second pass over an unchanged body fetches nothing — the whole reason
# `schedule` is safe to call on every save.
calls.clear()
await _unfurl_new_urls(note.id, note.body)
assert calls == []
async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, monkeypatch):
"""A slow fetch must not resurrect a link the person deleted mid-flight."""
note = Note(owner_id=owner.id, body="https://example.com/gone", display_title="x")
db.add(note)
await db.commit()
async def fake_unfurl(url):
# Simulate the body changing while the request was in the air.
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
note.body = "changed my mind"
await db.commit()
await _unfurl_new_urls(note.id, "https://example.com/gone")
rows = (await db.scalars(select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))).all()
assert rows == [], "a preview was stored for a URL the note no longer contains"
async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
"""The detector and the storage path read the same body the same way."""
body = "one https://example.com/x. two (https://example.com/y) three"
assert detect_urls(body) == ["https://example.com/x", "https://example.com/y"]
note = Note(owner_id=owner.id, body=body, display_title="one")
db.add(note)
await db.commit()
async def fake_unfurl(url):
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, body)
stored = {
r for r in (await db.scalars(select(NoteLinkPreview.url).where(NoteLinkPreview.note_id == note.id))).all()
}
assert stored == set(detect_urls(body))
async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
"""The gap this removes: registration was open between "my account exists" and
"I remembered to turn it off", and on a public host that gap starts at DNS.
Runs against a real database because it is the interaction between two writes —
the user row and the settings row — inside one transaction.
"""
# The instance is empty (the fixture truncated it), so this is the first account:
# allowed unconditionally, and it becomes the admin.
first = await app_client.post(
"/api/auth/register",
json={"email": "owner@example.test", "password": "a-long-enough-password"},
)
assert first.status_code == 201
assert (await first.get_json())["is_admin"] is True
# …and the door shut behind it.
async with session_scope() as fresh:
assert await get_setting(fresh, "allow_registration") is False
second = await app_client.post(
"/api/auth/register",
json={"email": "stranger@example.test", "password": "a-long-enough-password"},
)
assert second.status_code == 403
# Re-opening it deliberately still works — that is how a second person gets in
# until invites exist.
async with session_scope() as fresh:
await set_settings(fresh, {"allow_registration": True})
await fresh.commit()
third = await app_client.post(
"/api/auth/register",
json={"email": "invited@example.test", "password": "a-long-enough-password"},
)
assert third.status_code == 201
assert (await third.get_json())["is_admin"] is False
async def test_security_settings_are_live_and_bounded(app_client, db):
"""The security values are settings now, not constants — so saving one has to take
effect without a restart, and a dangerous value has to be refused.
Real database because the whole point is the round trip: write through the admin
API, re-read into the cache the throttle consults, observe the new number.
"""
# An admin to authenticate as. First account, so it is allowed and becomes admin.
reset_live()
created = await app_client.post(
"/api/auth/register",
json={"email": "admin@example.test", "password": "a-long-enough-password"},
)
assert created.status_code == 201
# Defaults are what the registry says.
async with session_scope() as fresh:
await refresh_live(fresh)
assert live("trusted_proxy_hops") == 1
assert live("signin_limit_per_account") == 10
# A value that would disable the protection is REFUSED, not clamped — storing a
# different number than the one typed is how somebody ends up believing a limit
# is set to something it is not.
bad = await app_client.patch("/api/settings", json={"signin_limit_per_account": 0})
assert bad.status_code == 400
assert "at least" in (await bad.get_json())["error"]
# …and so is a hop count that would trust anything a caller sent.
bad_hops = await app_client.patch("/api/settings", json={"trusted_proxy_hops": 99})
assert bad_hops.status_code == 400
# A legitimate change applies to the cache the throttle reads, immediately.
ok = await app_client.patch(
"/api/settings", json={"signin_limit_per_account": 3, "trusted_proxy_hops": 2}
)
assert ok.status_code == 200
assert live("signin_limit_per_account") == 3
assert live("trusted_proxy_hops") == 2
# And it is persisted, not just cached.
async with session_scope() as fresh:
assert await get_setting(fresh, "trusted_proxy_hops") == 2
reset_live()
async def test_the_security_group_reaches_the_admin_ui(app_client, db):
"""Every security value has to be visible and editable, which is the whole reason
they moved out of the environment."""
created = await app_client.post(
"/api/auth/register",
json={"email": "admin2@example.test", "password": "a-long-enough-password"},
)
assert created.status_code == 201
resp = await app_client.get("/api/settings")
assert resp.status_code == 200
rows = (await resp.get_json())["settings"]
security = {r["key"]: r for r in rows if r["group"] == "Security"}
assert set(security) == {
"trusted_proxy_hops",
"signin_limit_per_account",
"signin_limit_per_address",
"signin_window_minutes",
"register_limit_per_address",
"register_window_minutes",
}
# The UI renders a number input from these, and it cannot offer a safe range it
# was never told about.
for row in security.values():
assert row["type"] == "int"
assert row["minimum"] is not None and row["maximum"] is not None
assert row["description"], f"{row['key']} has no description to explain itself"
async def _revision_count(db, note_id) -> int:
rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all()
return len(rows)
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
# A session's worth of autosaves.
for text_ in ("one two", "one two three", "one two three four"):
if await should_snapshot(db, note.id, note.body, text_):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = text_
await db.commit()
assert await _revision_count(db, note.id) == 1
# And it is the body as it was BEFORE the sitting, not some midpoint — which is
# what makes one-per-session the useful granularity rather than an arbitrary one.
kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all()
assert kept == ["one"]
async def test_rewriting_the_same_text_is_not_a_version(db, owner):
note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged")
db.add(note)
await db.commit()
assert await should_snapshot(db, note.id, note.body, "unchanged") is False
assert await _revision_count(db, note.id) == 0
async def test_a_later_sitting_earns_its_own_revision(db, owner):
"""The window has to REOPEN, or a note edited daily would keep only its first
version forever — which would be a worse history than the one we replaced."""
note = Note(owner_id=owner.id, body="today", display_title="today")
db.add(note)
await db.flush()
# A revision from longer ago than one session: the clock is not mocked, the row
# is simply written with an older timestamp, which is what the query reads.
stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1)
db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "tomorrow") is True
async def test_a_revision_inside_the_window_blocks_another(db, owner):
note = Note(owner_id=owner.id, body="draft", display_title="draft")
db.add(note)
await db.flush()
db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc)))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
async def test_renaming_a_tag_onto_an_existing_one_merges_into_the_older(app_client, db):
"""Renaming a tag onto a name another tag holds merges them, and the OLDER row
is the survivor — whichever side the caller happened to be renaming.
Runs against a real database because the whole question is about `created_at`
ordering and the note_labels rows moving, neither of which a unit test sees.
Age decides, rather than "the one that already held the name", so that renaming
A→B and renaming B→A land on the same row. If the incumbent won, the survivor
would depend on which way round someone typed it, and two clients racing the
same tidy-up would disagree about which id still exists.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
# Two separate requests, so two transactions and two distinct `func.now()`s.
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
one = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
two = await (await app_client.post("/api/notes", json={"body": "stamps"})).get_json()
await app_client.put(f"/api/notes/{one['id']}/labels", json={"label_ids": [older["id"]]})
await app_client.put(f"/api/notes/{two['id']}/labels", json={"label_ids": [newer["id"]]})
# Rename the YOUNGER onto the older's name, with different casing — matching is
# case-insensitive, and the survivor must end up spelled the way we asked.
resp = await app_client.patch(f"/api/labels/{newer['id']}", json={"name": "Grocery"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "the older row is the one that keeps existing"
assert survivor["name"] == "Grocery", "the survivor takes the spelling that was asked for"
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1, "the two became one"
assert listing[0]["id"] == older["id"]
assert listing[0]["count"] == 2, "it carries every note from both sides"
async def test_the_rename_merge_survivor_does_not_depend_on_the_direction(app_client, db):
"""The mirror of the test above: rename the OLDER onto the younger's name. The
older still survives — it just changes its name — so the two directions agree."""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags2@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
resp = await app_client.patch(f"/api/labels/{older['id']}", json={"name": "errands"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "age wins in this direction too"
assert survivor["name"] == "errands"
assert newer["id"] != older["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [older["id"]]
async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one(app_client, db):
"""A case-sensitive match here used to mint "Groceries" beside "groceries". No
synced client can hold both — their `labels` index is unique on `lower(name)` —
so the pair was a pull that would fail later, on a phone, with no UI in the path.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags3@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
first = await app_client.post("/api/labels", json={"name": "groceries"})
assert first.status_code == 201
second = await app_client.post("/api/labels", json={"name": "Groceries"})
assert second.status_code == 200, "an existing tag is returned, not a second one made"
assert (await second.get_json())["id"] == (await first.get_json())["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1