tests/test_entity_redirect.py
Rama master · réplica de lectura del repositorio original.
"""实体合并的墓碑(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()