Ctrl K
ComunidadGuías…

tests/test_entity_redirect.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""实体合并的墓碑(migration 0040):旧 id 解析到并进去的那一条。

账本之外按实体 id 记下的东西(头像文件名、复核 CSV、页面上的 `/entity-image?id=`)在合并后
还拿着旧 id。墓碑让它们跳到现在的实体;目标没了就和目标一样查不到,不悬空。
"""
from __future__ import annotations

import importlib.util
import io
import json
import shutil
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs, urlsplit

from PIL import Image, ImageDraw

from peach.entities import (memoized_resolution, merge_entity, normalize_entity_name, prefer_entity_name,
                            resolve_entity, resolve_entity_id, upsert_asset_entity)
from peach.web_review import _use_canonical_entity_names

from support.ledger import fresh_ledger

HAS_DEPS = all(importlib.util.find_spec(name) for name in ("fastapi", "httpx"))


def add_person(connection, entity_id: int, name: str) -> None:
    connection.execute(
        "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
        " VALUES(?,'performer',?,?,'t','t')", (entity_id, name, name.casefold()))


def merge(connection, source_id: int, target_id: int, name: str) -> dict:
    moved = merge_entity(connection, target_id=target_id, source_id=source_id,
                         source_name=name, alias_source="test:merge")
    connection.commit()
    return moved


def picture(colour: str) -> bytes:
    buffer = io.BytesIO()
    image = Image.new("RGB", (40, 60), colour)
    ImageDraw.Draw(image).rectangle((0, 0, 40, 30), fill="black")
    image.save(buffer, format="JPEG")
    return buffer.getvalue()


class EntityRedirectTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.db = fresh_ledger(self.tmp.name)
        # 和服务、脚本的连接一样不开外键:ON DELETE 声明不会替我们收拾墓碑。
        self.con = sqlite3.connect(self.db)
        self.addCleanup(self.con.close)
        for entity_id, name in ((10, "橋本ありな"), (11, "新ありな"), (12, "桥本有菜")):
            add_person(self.con, entity_id, name)
        self.con.commit()

    def redirects(self) -> list[tuple]:
        return self.con.execute(
            "SELECT old_id,target_id FROM entity_redirect ORDER BY old_id").fetchall()

    def test_a_read_request_resolves_each_name_once(self):
        """只读请求里同一个名字只查一次库;块外照旧每次现查,写路径看得到刚写的实体。"""
        queries = []
        self.con.set_trace_callback(queries.append)
        with memoized_resolution():
            first = [resolve_entity(self.con, "performer", "橋本ありな")[0] for _ in range(3)]
        within = len(queries)
        resolve_entity(self.con, "performer", "橋本ありな")
        resolve_entity(self.con, "performer", "橋本ありな")
        self.con.set_trace_callback(None)
        self.assertEqual(first, [10, 10, 10])
        self.assertEqual(within, 1)
        self.assertEqual(len(queries) - within, 2)

    def test_resolution_matches_exactly_and_never_scans_the_name_tables(self):
        """解析按原文逐字比对:只差大小写的写法认不出,别名同理;两条查询都走索引,不扫整张表。"""
        add_person(self.con, 20, "Mia")
        self.con.execute("INSERT INTO entity_alias(entity_id,alias,normalized_alias,source,confidence)"
                         " VALUES(20,'MIA Nanasawa','mia nanasawa','test',1.0)")
        queries = []
        self.con.set_trace_callback(queries.append)
        found = [resolve_entity(self.con, "performer", name) for name in ("Mia", "mia", "MIA Nanasawa", "mia nanasawa")]
        self.con.set_trace_callback(None)
        self.assertEqual([row[0] if row else None for row in found], [20, None, 20, None])
        plans = [detail for sql in queries for *_, detail in self.con.execute("EXPLAIN QUERY PLAN " + sql)]
        self.assertFalse([detail for detail in plans if detail.startswith("SCAN")], plans)
        # 规范名那条只用上 `kind` 前缀时也不算扫表,却仍要把同类实体逐个比对;两个归一列都得用上。
        for key in ("normalized_name=?", "normalized_alias=?"):
            self.assertTrue([detail for detail in plans if key in detail], plans)

    def test_entity_writes_keep_the_normalized_name_the_resolver_looks_up(self):
        """建实体、合并留别名、改用别名作规范名之后,归一列都等于对原文现算的结果,名字都解析得到。"""
        self.con.row_factory = sqlite3.Row
        entity_id = upsert_asset_entity(self.con, kind="performer", name=" Yua\u200b Mikami ", asset_id=1,
                                        role="performer", source="test")
        first = self.con.execute("SELECT canonical_name FROM entity WHERE id=?", (entity_id,)).fetchone()[0]
        merge(self.con, 11, entity_id, "新ありな")
        prefer_entity_name(self.con, entity_id, "新ありな")
        rows = [*self.con.execute("SELECT canonical_name,normalized_name FROM entity"),
                *self.con.execute("SELECT alias,normalized_alias FROM entity_alias")]
        self.assertEqual([(text, key) for text, key in rows if normalize_entity_name(text) != key], [])
        self.assertEqual(resolve_entity(self.con, "performer", "新ありな")[0], entity_id)
        self.assertEqual(resolve_entity(self.con, "performer", first)[0], entity_id)

    def test_the_merged_id_resolves_to_the_target(self):
        merge(self.con, 11, 10, "新ありな")
        self.assertEqual(resolve_entity_id(self.con, 11), 10)
        self.assertEqual(resolve_entity_id(self.con, 10), 10)
        self.assertIsNone(resolve_entity_id(self.con, 999))
        self.assertEqual(self.con.execute(
            "SELECT source FROM entity_redirect WHERE old_id=11").fetchone(), ("test:merge",))

    def test_a_chain_of_merges_points_every_old_id_at_the_last_target(self):
        merge(self.con, 11, 10, "新ありな")
        moved = merge(self.con, 10, 12, "橋本ありな")
        self.assertEqual(self.redirects(), [(10, 12), (11, 12)])
        self.assertEqual(moved["redirects"], 1)
        self.assertEqual(resolve_entity_id(self.con, 11), 12)
        self.assertEqual(self.con.execute("PRAGMA foreign_key_check").fetchall(), [])

    def test_deleting_the_target_removes_its_redirects(self):
        """目标被删(标签改名、清理脚本都直接删 entity)时旧 id 和目标一样查不到。

        悬空的墓碑会让下一次合并后的 `foreign_key_check` 不为 0,ADR-0064 的后继按它回滚。
        """
        merge(self.con, 11, 10, "新ありな")
        self.con.execute("DELETE FROM entity WHERE id=10")
        self.con.commit()
        self.assertEqual(self.redirects(), [])
        self.assertIsNone(resolve_entity_id(self.con, 11))
        self.assertEqual(self.con.execute("PRAGMA foreign_key_check(entity_redirect)").fetchall(), [])

    def test_a_dangling_redirect_resolves_to_nothing(self):
        """绕开触发器留下的悬空行(比如重建 entity 表时丢了触发器)也不解析到死实体。"""
        merge(self.con, 11, 10, "新ありな")
        self.con.execute("DROP TRIGGER entity_redirect_target_delete")
        self.con.execute("DELETE FROM entity WHERE id=10")
        self.assertIsNone(resolve_entity_id(self.con, 11))

    def test_a_new_entity_that_reuses_the_id_wins_over_the_redirect(self):
        """`entity.id` 没有 AUTOINCREMENT,删掉的最大 id 会再发给下一条新实体。"""
        merge(self.con, 12, 10, "桥本有菜")
        add_person(self.con, 12, "別の人")
        self.con.commit()
        self.assertEqual(resolve_entity_id(self.con, 12), 12)
        self.assertEqual(self.redirects(), [])

    def test_favorites_follow_merges_when_sqlite_reuses_the_highest_id(self):
        from peach.web_contract import WebContract, dispatch_api_get, dispatch_api_post
        contract = WebContract(self.db)
        for entity_id in (12, 10):
            dispatch_api_post(contract, "/api/library-shortcuts", {
                "action": "favorite", "entity_id": entity_id, "on": True})
        saved = dispatch_api_post(contract, "/api/library-shortcuts", {
            "action": "save_filter", "name": "常用", "href": "/?loc="})['filters']
        self.con.execute("UPDATE profile SET settings_json=json_set(settings_json,'$.searchHistoryLimit',25)"
                         " WHERE id='local-default'")
        self.con.execute("INSERT INTO profile(id,user_id,name,is_default,settings_json,created_at,updated_at)"
                         " SELECT 'second',user_id,'另一个',0,settings_json,created_at,updated_at"
                         " FROM profile WHERE id='local-default'")
        self.con.execute("INSERT INTO profile(id,user_id,name,is_default,settings_json,created_at,updated_at)"
                         " SELECT 'broken',user_id,'损坏',0,'{',created_at,updated_at"
                         " FROM profile WHERE id='local-default'")
        self.con.commit()
        merge(self.con, 12, 10, "桥本有菜")
        inserted = self.con.execute("INSERT INTO entity(kind,canonical_name,normalized_name,created_at,updated_at)"
                                    " VALUES('performer','別の人','別の人','t','t')").lastrowid
        self.con.commit()
        self.assertEqual(inserted, 12)
        self.assertEqual(self.redirects(), [])
        result = dispatch_api_get(contract, "/api/library-shortcuts", {})
        self.assertEqual([row['entity_id'] for row in result['favorites']], [10])
        self.assertEqual(result['filters'], saved)
        other = json.loads(self.con.execute("SELECT settings_json FROM profile WHERE id='second'").fetchone()[0])
        self.assertEqual([row['entity_id'] for row in other['libraryShortcuts']['favorites']], [10])
        self.assertEqual(other['libraryShortcuts']['filters'], saved)
        self.assertEqual(other['searchHistoryLimit'], 25)
        self.assertEqual(self.con.execute("SELECT settings_json FROM profile WHERE id='broken'").fetchone()[0], '{')
        self.assertEqual(dispatch_api_post(contract, "/api/library-shortcuts", {
            "action": "favorite", "entity_id": 10, "on": False})['favorites'], [])

    def test_favorite_transfer_rolls_back_with_entity_merge(self):
        original = json.dumps({'sidebarOrder': ['tags'], 'libraryShortcuts': {
            'favorites': [{'entity_id': 12}], 'filters': []}})
        self.con.execute("UPDATE profile SET settings_json=? WHERE id='local-default'", (original,))
        self.con.commit()
        merge_entity(self.con, target_id=10, source_id=12, source_name="桥本有菜", alias_source="test:merge")
        self.con.rollback()
        self.assertEqual(self.con.execute("SELECT settings_json FROM profile WHERE id='local-default'").fetchone()[0], original)
        self.assertIsNotNone(self.con.execute("SELECT id FROM entity WHERE id=12").fetchone())

    def test_merging_into_a_missing_or_the_same_entity_is_refused(self):
        with self.assertRaises(ValueError):
            merge_entity(self.con, target_id=999, source_id=11,
                         source_name="新ありな", alias_source="test:merge")
        with self.assertRaises(ValueError):
            merge_entity(self.con, target_id=11, source_id=11,
                         source_name="新ありな", alias_source="test:merge")
        self.assertIsNotNone(self.con.execute("SELECT 1 FROM entity WHERE id=11").fetchone())

    def test_follows_and_feed_subscriptions_follow_the_merge(self):
        """关注与订阅绑在人身上,合并后跟着走,不悬空在已删的实体上。"""
        self.con.executescript("""
          INSERT INTO follow_source(id,entity_id,provider,ref,label,url,created_at,updated_at)
            VALUES(1,11,'x','arina','arina','https://x.test/arina','t','t');
          INSERT INTO feed_source(id,kind,url,entity_id,created_at)
            VALUES(1,'javdb_actor','https://j.test/1',11,'t');
          INSERT INTO feed_discovery(id,code,discovered_at)
            VALUES(1,'ABC-001','t'),(2,'ABC-002','t');
          INSERT INTO feed_discovery_entity(discovery_id,entity_id) VALUES(1,10),(1,11),(2,11);
        """)
        moved = merge(self.con, 11, 10, "新ありな")
        self.assertEqual((moved["follows"], moved["feeds"], moved["discoveries"]), (1, 1, 1))
        self.assertEqual(self.con.execute("SELECT entity_id FROM follow_source").fetchall(), [(10,)])
        self.assertEqual(self.con.execute("SELECT entity_id FROM feed_source").fetchall(), [(10,)])
        self.assertEqual(self.con.execute(
            "SELECT discovery_id,entity_id FROM feed_discovery_entity ORDER BY 1").fetchall(),
            [(1, 10), (2, 10)])
        self.assertEqual(self.con.execute("PRAGMA foreign_key_check").fetchall(), [])

    def test_a_review_row_carrying_the_old_id_shows_the_target(self):
        """候选 CSV 的 `entity_id` 停在出候选那一刻;实体并入别人之后,这一行认现在那一条。"""
        merge(self.con, 11, 10, "新ありな")
        self.con.row_factory = sqlite3.Row
        rows = [{"entity_id": "11", "current_name": "Arina Arata"},
                {"entity_id": "12", "current_name": "桥本有菜"},
                {"entity_id": "999", "current_name": "nobody"}]
        _use_canonical_entity_names(self.con, rows)
        self.assertEqual([(row["entity_id"], row["current_name"]) for row in rows],
                         [("10", "橋本ありな"), ("12", "桥本有菜"), ("999", "nobody")])
        self.assertEqual(rows[0]["source_name"], "Arina Arata")


@unittest.skipUnless(HAS_DEPS, "FastAPI/httpx 尚未安装")
class EntityRedirectRouteTests(unittest.TestCase):
    """页面与接口拿着旧 id 或旧名来,落到并进去的那一条。"""

    def setUp(self):
        from fastapi.testclient import TestClient
        from peach.api import create_app
        from peach.config import PeachSettings

        self.folder = Path(tempfile.mkdtemp()).resolve()
        self.addCleanup(shutil.rmtree, self.folder, True)
        self.avatars = self.folder / "avatars"
        self.avatars.mkdir()
        database = fresh_ledger(self.folder)
        connection = sqlite3.connect(database)
        add_person(connection, 10, "橋本ありな")
        add_person(connection, 11, "新ありな")
        connection.execute(
            "INSERT INTO asset(id,location,path,name,medium) VALUES(1,'local',?,'1.mp4','video')",
            (r"R:\media\1.mp4",))
        connection.execute(
            "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence)"
            " VALUES(1,11,'performer','test',1.0)")
        connection.commit()
        merge(connection, 11, 10, "新ありな")
        connection.close()
        self.face = mock.patch("peach.avatar_provider.FaceProbe").start()
        self.face.return_value.return_value = None
        self.addCleanup(mock.patch.stopall)
        self.app = create_app(PeachSettings(
            db_path=database, configured=True, token="secret", avatar_root=self.avatars,
            candidate_root=self.folder / "generated", cover_root=self.folder / "covers"))
        self.client = TestClient(self.app)
        self.addCleanup(self.client.close)

    def test_the_old_name_opens_the_target_page(self):
        response = self.client.get("/api/entity", params={
            "kind": "performer", "name": "新ありな", "t": "secret"})
        self.assertEqual(response.status_code, 200, response.text)
        self.assertEqual(response.json()["id"], 10)
        self.assertEqual(response.json()["canonical_name"], "橋本ありな")

    def test_entity_and_photo_queries_accept_current_and_merged_ids(self):
        for path in ('/api/entity', '/api/photos'):
            named = self.client.get(path, params={
                'kind': 'performer', 'name': '橋本ありな', 't': 'secret'})
            self.assertEqual(named.status_code, 200, named.text)
            for entity_id in (10, 11):
                response = self.client.get(path, params={
                    'kind': 'performer', 'id': entity_id, 'name': 'ignored', 't': 'secret'})
                self.assertEqual(response.status_code, 200, response.text)
                self.assertEqual(response.json(), named.json())

    def test_entity_id_queries_refuse_invalid_missing_or_other_kind_ids(self):
        for path in ('/api/entity', '/api/photos'):
            for entity_id in ('', '0', '-1', '1.0', 'ten', '10', str(2**63), '9' * 50):
                response = self.client.get(path, params={
                    'kind': 'performer', 'id': entity_id, 't': 'secret'})
                self.assertEqual(response.json(), {'error': 'invalid entity'}, (path, entity_id))
            for kind, entity_id in (('studio', '10'), ('performer', '999')):
                response = self.client.get(path, params={'kind': kind, 'id': entity_id, 't': 'secret'})
                self.assertEqual(response.json(), {'error': 'not found'}, (path, kind, entity_id))

    def test_verified_cross_kind_merge_ids_use_the_final_identity(self):
        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.execute(
                "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
                " VALUES(20,'creator','Old Account','old account','t','t')")
            merge_entity(connection, target_id=10, source_id=20, source_name='Old Account',
                         alias_source='user:identity-merge:creator')
            add_person(connection, 12, 'Final Person')
            merge_entity(connection, target_id=12, source_id=10, source_name='橋本ありな', alias_source='test:merge')
        expected = {'redirect': {'id': 12, 'kind': 'performer', 'name': 'Final Person'}}
        for identity in ({'id': 20}, {'name': 'Old Account'}):
            response = self.client.get('/api/entity', params={'kind': 'creator', 't': 'secret', **identity})
            self.assertEqual(response.status_code, 200, response.text)
            self.assertEqual(response.json(), expected)
        query = '?tag=a%2Fb&tag=second&media=photos&t=secret'
        for method in ('GET', 'HEAD'):
            response = self.client.request(method, '/creators/20' + query, follow_redirects=False)
            self.assertEqual(response.status_code, 307)
            self.assertEqual(response.headers['location'], '/performers/12' + query)
            self.assertEqual(response.headers['cache-control'], 'no-store')
        self.assertEqual(self.client.get('/api/photos', params={
            'kind': 'creator', 'id': 20, 't': 'secret'}).json(), {'error': 'not found'})

    def test_cross_kind_id_redirect_requires_source_kind_evidence(self):
        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.executemany(
                "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
                " VALUES(?,'creator',?,?,'t','t')", [(20, 'Explicit', 'explicit'), (21, 'Generic', 'generic')])
            merge_entity(connection, target_id=10, source_id=20, source_name='Explicit',
                         alias_source='user:identity-merge:creator')
            merge_entity(connection, target_id=10, source_id=21, source_name='Generic', alias_source='test:merge')
        for kind, entity_id in (('creator', 10), ('studio', 20), ('creator', 21)):
            response = self.client.get('/api/entity', params={'kind': kind, 'id': entity_id, 't': 'secret'})
            self.assertEqual(response.json(), {'error': 'not found'}, (kind, entity_id))
            prefix = 'studios' if kind == 'studio' else 'creators'
            for method in ('GET', 'HEAD'):
                page = self.client.request(method, f'/{prefix}/{entity_id}?t=secret', follow_redirects=False)
                self.assertEqual(page.status_code, 404, (kind, entity_id, method))

    def test_a_reused_cross_kind_merge_id_keeps_its_current_identity(self):
        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.execute(
                "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
                " VALUES(20,'creator','Old Account','old account','t','t')")
            merge_entity(connection, target_id=10, source_id=20, source_name='Old Account',
                         alias_source='user:identity-merge:creator')
            add_person(connection, 20, 'New Person')
        self.assertEqual(self.client.get('/api/entity', params={
            'kind': 'creator', 'id': 20, 't': 'secret'}).json(), {'error': 'not found'})
        self.assertEqual(self.client.get('/creators/20?t=secret', follow_redirects=False).status_code, 404)
        current = self.client.get('/api/entity', params={'kind': 'performer', 'id': 20, 't': 'secret'})
        self.assertEqual(current.json()['id'], 20)
        self.assertEqual(current.json()['canonical_name'], 'New Person')

    def test_tag_profiles_and_photos_use_the_same_identity_by_name_and_id(self):
        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.execute("INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
                               " VALUES(20,'tag','Tag A','tag a','t','t')")
            connection.execute("INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(1,20,'tag','test',1)")
            connection.execute("INSERT INTO asset(id,location,path,name,medium) VALUES(2,'local',?,'2.jpg','image')",
                               (r'R:\media\2.jpg',))
            connection.execute("INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(2,20,'tag','test',1)")
        for path in ('/api/entity', '/api/photos'):
            named = self.client.get(path, params={'kind': 'tag', 'name': 'Tag A', 't': 'secret'})
            by_id = self.client.get(path, params={'kind': 'tag', 'id': '20', 't': 'secret'})
            self.assertEqual(named.status_code, 200, named.text)
            self.assertEqual(by_id.json(), named.json())
            if path == '/api/entity':
                self.assertEqual(by_id.json()['asset_count'], 1)
            else:
                self.assertEqual(by_id.json()['total'], 1)
                self.assertEqual(by_id.json()['items'][0]['id'], 2)

    def test_tag_hidden_assets_are_excluded_from_profiles_photos_and_samples(self):
        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.executemany(
                "INSERT INTO entity(id,kind,canonical_name,normalized_name,created_at,updated_at)"
                " VALUES(?,'tag',?,?,'t','t')",
                [(20, 'Tag A', 'tag a'), (21, 'Hidden only', 'hidden only'), (22, 'Visible only', 'visible only')])
            add_person(connection, 12, 'Hidden cast')
            connection.execute("UPDATE asset SET code='ABC-001',size=10,snapshot_path='visible.jpg' WHERE id=1")
            connection.executemany(
                "INSERT INTO asset(id,location,path,name,medium,size,code,snapshot_path,play_count)"
                " VALUES(?,'local',?,?,?,?,?,?,?)",
                [(2, r'R:\media\visible\2.jpg', '2.jpg', 'image', 10, None, None, 0),
                 (3, r'R:\media\3.mp4', '3.mp4', 'video', 1000, 'ABC-002', 'hidden.jpg', 100),
                 (4, r'R:\media\hidden\4.jpg', '4.jpg', 'image', 10, None, None, 0),
                 (5, r'R:\media\5.mp4', '5.mp4', 'video', 500, 'ABC-003', 'hidden-cast.jpg', 0)])
            connection.executemany(
                "INSERT INTO asset_entity(asset_id,entity_id,role,source,confidence) VALUES(?,?,?,'test',1)",
                [(asset_id, 20, 'tag') for asset_id in (1, 2, 3, 4, 5)]
                + [(1, 22, 'tag'), (3, 21, 'tag'), (3, 10, 'performer'), (5, 12, 'performer')])
            connection.executemany(
                "INSERT INTO asset_tag_preference(profile_id,asset_id,normalized_tag,hidden,updated_at)"
                " VALUES('local-default',?,'tag a',1,0)", [(3,), (4,), (5,)])
            connection.executemany(
                "INSERT INTO code_sample_image(code,position,url,site,source,fetched_at)"
                " VALUES(?,1,'https://example.test/sample.jpg','dmm','test','t')",
                [('ABC-001',), ('ABC-002',), ('ABC-003',)])

        def read(path, kind='tag', entity_id=20):
            response = self.client.get(path, params={'kind': kind, 'id': entity_id, 't': 'secret'})
            self.assertEqual(response.status_code, 200, response.text)
            return response.json()

        profile = read('/api/entity')
        self.assertEqual(profile['asset_count'], 1)
        self.assertEqual(profile['representative_asset_id'], 1)
        self.assertEqual({tag['id']: tag['n'] for tag in profile['tags']}, {20: 1, 22: 1})
        self.assertEqual([(person['id'], person['n'], person['rep']) for person in profile['related_performers']],
                         [(10, 1, 1)])
        photos = read('/api/photos')
        self.assertEqual(photos['total'], 1)
        self.assertEqual([item['id'] for item in photos['items']], [2])
        self.assertEqual({item['id']: item['n'] for item in photos['sets']}, {'code:ABC-001': 1, 2: 1})
        self.assertEqual(photos['sample_total'], 1)
        self.assertEqual(read('/api/entity', 'performer', 10)['asset_count'], 2)
        from peach.web_catalog import _entity_asset_filter, q_facets
        from peach.web_state import WebContract

        contract = WebContract(self.app.state.settings.db_path)
        with contract.read_connection() as connection:
            clause, params = _entity_asset_filter(connection, 'tag', 'Tag A')
            scoped_assets = connection.execute(f'SELECT a.id FROM asset a WHERE {clause} ORDER BY a.id', params).fetchall()
        self.assertEqual([row[0] for row in scoped_assets], [1, 2])
        facets = q_facets(contract, filters={'tag': 'Tag A'})
        self.assertEqual(facets['stats']['total'], profile['asset_count'])
        self.assertEqual({item['k']: item['n'] for item in facets['tags']}, {'Tag A': 1, 'Visible only': 1})
        scoped_facets = q_facets(contract, scope_kind='tag', scope_name='Tag A')
        self.assertEqual(scoped_facets['stats']['total'], profile['asset_count'])
        self.assertEqual(scoped_facets['tags'], facets['tags'])

        with sqlite3.connect(self.app.state.settings.db_path) as connection:
            connection.execute("UPDATE asset_tag_preference SET hidden=0 WHERE normalized_tag='tag a'")
        self.assertEqual(read('/api/entity')['asset_count'], 3)
        restored = read('/api/photos')
        self.assertEqual(restored['total'], 2)
        self.assertEqual(restored['sample_total'], 3)
        self.assertEqual(q_facets(contract, filters={'tag': 'Tag A'})['stats']['total'], 3)

    def test_an_old_image_address_redirects_to_the_target(self):
        body = picture("navy")
        (self.avatars / "performer-10.img").write_bytes(body)
        response = self.client.get(
            "/entity-image", params={"kind": "performer", "id": 11, "thumb": 1, "t": "secret"},
            follow_redirects=False)
        self.assertEqual(response.status_code, 307)
        location = urlsplit(response.headers["location"])
        self.assertEqual((location.scheme, location.netloc, location.path), ("", "", "/entity-image"))
        self.assertEqual(parse_qs(location.query),
                         {"kind": ["performer"], "id": ["10"], "thumb": ["1"], "t": ["secret"]})
        followed = self.client.get(
            "/entity-image", params={"kind": "performer", "id": 11, "t": "secret"})
        self.assertEqual(followed.status_code, 200)
        self.assertEqual(followed.content, body)

    def test_an_unknown_id_still_404s(self):
        response = self.client.get(
            "/entity-image", params={"kind": "performer", "id": 999, "t": "secret"},
            follow_redirects=False)
        self.assertEqual(response.status_code, 404)

    def test_a_picture_picked_under_the_old_id_lands_on_the_target(self):
        body = picture("olive")
        response = self.client.post(
            "/api/avatar-pick?id=11&kind=performer&name=me.jpg&t=secret",
            content=body, headers={"content-type": "image/jpeg"})
        self.assertEqual(response.status_code, 200, response.text)
        self.assertEqual(response.json()["id"], 10)
        self.assertEqual((self.avatars / "performer-10.img").read_bytes(), body)
        self.assertFalse((self.avatars / "performer-11.img").exists())


if __name__ == "__main__":
    unittest.main()