[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
PRAGMA foreign_keys=ON;
CREATE TABLE metadata(key TEXT PRIMARY KEY,value_json TEXT NOT NULL);
CREATE TABLE titles(
id INTEGER PRIMARY KEY,type TEXT NOT NULL,title TEXT,description TEXT,
year INTEGER,imdb REAL,rating REAL,duration TEXT,classification TEXT,
image TEXT,cover TEXT,downloadas TEXT,playas TEXT,comments_enabled INTEGER,
raw_json TEXT NOT NULL);
CREATE INDEX titles_type ON titles(type);
CREATE INDEX titles_title ON titles(title COLLATE NOCASE);
CREATE INDEX titles_year ON titles(year);
CREATE TABLE genres(id INTEGER PRIMARY KEY,title TEXT);
CREATE TABLE countries(id INTEGER PRIMARY KEY,title TEXT,image TEXT);
CREATE TABLE title_genres(title_id INTEGER REFERENCES titles(id),genre_id INTEGER REFERENCES genres(id),PRIMARY KEY(title_id,genre_id));
CREATE TABLE title_countries(title_id INTEGER REFERENCES titles(id),country_id INTEGER REFERENCES countries(id),PRIMARY KEY(title_id,country_id));
CREATE TABLE movie_sources(
title_id INTEGER NOT NULL REFERENCES titles(id),source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(title_id,source_index));
CREATE INDEX movie_sources_title ON movie_sources(title_id);
CREATE TABLE series_fetch_status(
series_id INTEGER PRIMARY KEY REFERENCES titles(id),status TEXT NOT NULL,
season_count INTEGER,source_url TEXT NOT NULL);
CREATE TABLE seasons(
series_id INTEGER NOT NULL REFERENCES titles(id),season_index INTEGER NOT NULL,
id INTEGER,title TEXT,raw_json TEXT NOT NULL,PRIMARY KEY(series_id,season_index));
CREATE TABLE episodes(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,
id INTEGER,title TEXT,description TEXT,duration TEXT,downloadas TEXT,playas TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index),
FOREIGN KEY(series_id,season_index) REFERENCES seasons(series_id,season_index));
CREATE TABLE episode_sources(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index,source_index),
FOREIGN KEY(series_id,season_index,episode_index) REFERENCES episodes(series_id,season_index,episode_index));
CREATE INDEX episode_sources_series ON episode_sources(series_id);
+98
View File
@@ -0,0 +1,98 @@
import json
import os
from pathlib import Path
import tempfile
import unittest
from test_catalog_api import fixture, load_service
class BannerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
def tearDown(self):
self.catalog.close()
self.temp.cleanup()
def test_local_config_reloads_safe_bounded_ads_without_admin_endpoint(self):
config = Path(self.temp.name) / 'banners.json'
self.catalog.close()
self.catalog = self.api.Catalog(self.path, banner_config=config)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
ad = {'title_en': 'Fixture sponsor', 'title_fa': 'حامی آزمایشی',
'subtitle_en': 'Test only', 'target_url': 'https://sponsor.example/offer',
'image_url': 'https://sponsor.example/banner.jpg', 'secret': 'not public'}
config.write_text(json.dumps({'banners': [ad]}))
result = self.catalog.dispatch('/api/banners/')['banners']
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['title_fa'], ad['title_fa'])
self.assertEqual(result[0]['image_url'], ad['image_url'])
self.assertNotIn('secret', result[0])
config.write_text('{invalid')
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for target in ['http://evil.example/', 'javascript:alert(1)', '//evil.example/',
'https://a:b@evil.example/', 'https://evil.example:444/',
'https://evil.example/ bad', 'https://evil.example/\\\\bad',
'https://evil.example/%0abad', 'https://',
'https://-bad.example/', 'https://bad..example/', 'https://bad.example./',
'https://evil.example/%zz', 'https://evil.example/' + 'x' * 2048]:
config.write_text(json.dumps({'banners': [{**ad, 'target_url': target}, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, target)
for row in [None, True, 5, {}, {**ad, 'title_en': 'x' * 81},
{**ad, 'subtitle_en': 'x' * 181}, {**ad, 'title_fa': 'bad\u202etext'},
{**ad, 'image_url': 'http://evil.example/banner.jpg'}]:
config.write_text(json.dumps({'banners': [row, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, row)
config.write_text(json.dumps({'banners': [ad] * 30}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 5)
config.write_text(json.dumps({'banners': [{}] * 20 + [ad]}))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
config.write_text(' ' * 65537)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for value in [None, [], {'banners': {}}, {'banners': []}]:
config.write_text(json.dumps(value))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for route in ['/api/banners/admin/', '/api/banners/config/']:
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch(route)
self.assertEqual(error.exception.status, 404)
def test_http_banner_endpoint_is_json_no_store_and_rejects_writes(self):
from http.client import HTTPConnection
import threading
server = self.api.make_server(self.catalog, port=0)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
for method, expected in [('GET', 200), ('HEAD', 200), ('POST', 405), ('PUT', 405)]:
client = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
client.request(method, '/api/banners/')
response = client.getresponse()
self.assertEqual(response.status, expected)
self.assertEqual(response.getheader('Cache-Control'), 'no-store')
self.assertIn('application/json', response.getheader('Content-Type'))
body = response.read()
if method == 'GET':
self.assertEqual(json.loads(body), {'banners': []})
if method == 'HEAD':
self.assertEqual(body, b'')
client.close()
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
def test_read_only_banner_route_defaults_to_empty_ad_list(self):
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
self.assertEqual(self.catalog.dispatch('/api/banners'), {'banners': []})
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch('/api/banners/?url=https://evil.example/')
self.assertEqual(error.exception.status, 400)
if __name__ == '__main__':
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
"""Regression coverage for app advertisements disguised as movie records."""
import json
import sqlite3
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
class CatalogueAdTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_app_promotions_and_typographic_variants_are_rejected(self):
self.open()
for text in [
'نورتون', 'نُوْرتـو\u200cن', 'N O R T O N',
'آموزش زبان به سبک موسوی‌باکس',
'آموزش زبان به سبک مووی باکس',
'آموزش زبان به سبک مُووی‌ باكْس',
'نسخه جدید هورتون', 'نسخه‌ جدید هُورتـون',
'اپلیکیشن هورتون', 'نرم افزار هورتون',
]:
with self.subTest(text=text):
self.assertFalse(self.api.allowed_text(text))
for text in [
'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون',
'آموزش زبان New Headway', 'The Marriage App',
'فیلمی درباره یک اپلیکیشن خیالی', 'بازیگران: Alex Horton',
]:
with self.subTest(text=text):
self.assertTrue(self.api.allowed_text(text))
def test_ads_are_absent_from_every_title_surface_and_direct_lookup(self):
with sqlite3.connect(self.path) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(66, 'نورتون', ''),
(67, 'آموزش زبان به سبک مووی باکس', ''),
(68, 'آموزش زبان به سبک موسوی‌باکس', ''),
(69, 'نسخه جدید هورتون', ''),
(70, 'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون'),
(71, 'آموزش زبان New Headway', 'مجموعه ویدیویی آموزش مهارت مکالمه'),
(72, 'The Marriage App', 'فیلمی درباره یک اپلیکیشن خیالی'),
(73, 'Language companion', 'اپلیکیشن هورتون'),
]
for title_id, title, description in records:
raw = {**original, 'id': title_id, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(title_id, 'movie', title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (title_id,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (title_id,))
catalog = self.open()
ads = {66, 67, 68, 69, 73}
actual = set()
for page in range(4):
for order in ['created', 'imdb', 'rating', 'year', 'title', 'random']:
for path in [f'/api/movie/by/filtres/0/{order}/{page}/public/',
f'/api/poster/by/filtres/2/1/{order}/{page}/public/']:
rows = catalog.dispatch(path)
self.assertFalse(ads & {row['id'] for row in rows}, path)
actual.update(row['id'] for row in rows)
self.assertTrue({70, 71, 72} <= actual)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(ads & {row['id'] for row in group['posters']})
for action in ['best-rand-top', 'best-rand-year']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action}))
self.assertFalse(ads & {row['id'] for row in rows})
for query in ['نورتون', 'هورتون', 'آموزش زبان', 'موسوی', 'مووی', 'Language companion']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': 'search-movie', 'q': query}))
self.assertFalse(ads & {row['id'] for row in rows}, query)
rows = catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')
self.assertFalse(ads & {row['id'] for row in rows})
for title_id in ads:
for path in [f'/api/movie/by/{title_id}/public/', f'/api/season/by/serie/{title_id}/public/']:
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
for title_id in [70, 71, 72]:
self.assertEqual(catalog.dispatch(f'/api/movie/by/{title_id}/public/')['id'], title_id)
detail = catalog.dispatch('/api/movie/by/1/public/')
self.assertEqual(detail['sources'], original['sources'])
+147
View File
@@ -0,0 +1,147 @@
"""Upstream notices are not catalogue films, including after later scrapes."""
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import tempfile
from typing import Any
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
from test_catalog_api import load_service
from test_catalog_sync import FakeUpstream, load_sync
class AnnouncementPolicyTests(unittest.TestCase):
def test_generic_notice_labels_are_blocked_without_hiding_real_notice_films(self):
api = load_service()
for text in [
'اطلاعیه جدید', 'اطلاعیه مهم ', 'اطلاعیه خیلی مهم',
'📢 **اطلاعـیّهٔ جدید**', 'اطلاع يه جديد', 'اطلاع‌رسانی جدید',
'اعلان جدید', 'اعلان مهم شماره ۲',
'به روز رسانی جدید', 'به‌روزرسانی برنامه', 'بروز رسانی نسخه ویندوز',
'آپدیت جدید برنامه', 'New announcement', 'IMPORTANT NOTICE!',
'Urgent announcement: service changes', 'App update',
]:
with self.subTest(blocked=text):
self.assertFalse(api.allowed_text(text))
for text in [
'Red Notice', 'SAS: Red Notice', 'Burn Notice', 'Notice to Quit',
'Two Weeks Notice', 'The Announcement', 'اعلان قرمز', 'Upgrade',
'News of the World', 'نام پارسی فیلم: اعلان قرمز',
'A detective reads an important notice before leaving town.',
'در این فیلم یک اطلاعیه جدید، زندگی شخصیت اصلی را تغییر می‌دهد.',
]:
with self.subTest(allowed=text):
self.assertTrue(api.allowed_text(text))
class AnnouncementSurfaceTests(unittest.TestCase):
path: Path
temp: tempfile.TemporaryDirectory
api: Any
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_notices_are_absent_from_all_catalogue_routes_and_direct_lookup(self):
bad = {70, 71, 72, 73, 74}
with closing(sqlite3.connect(self.path)) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(70, 'movie', 'اطلاعیه جدید', ''),
(71, 'movie', 'اطلاعیه خیلی مهم', ''),
(72, 'movie', 'به روز رسانی جدید', ''),
(73, 'movie', 'Service changes', 'Important announcement: app changes'),
(74, 'serie', 'اعلان جدید', ''),
(75, 'movie', 'Red Notice', 'نام پارسی فیلم: اعلان قرمز'),
(76, 'serie', 'Burn Notice', 'An ordinary spy series'),
]
for ident, kind, title, description in records:
raw = {**original, 'id': ident, 'type': kind, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(ident, kind, title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (ident,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (ident,))
db.commit()
catalog = self.open()
found = set()
for kind in ('movie', 'serie', 'poster'):
for order in ('created', 'updated', 'imdb', 'rating', 'year', 'title', 'random'):
for page in range(4):
for path in (f'/api/{kind}/by/filtres/0/{order}/{page}/public/',
f'/api/{kind}/by/filtres/2/1/{order}/{page}/public/'):
ids = {row['id'] for row in catalog.dispatch(path)}
self.assertFalse(bad & ids, path)
found.update(ids)
self.assertTrue({75, 76} <= found)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(bad & {row['id'] for row in group['posters']})
for action in ('best-rand-top', 'best-rand-year'):
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/like.php?' + urlencode({'action': action}))})
for action in ('search-movie', 'search-movie-en'):
for query in ('اطلاعیه', 'به روز', 'announcement', 'Notice'):
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action, 'q': query}))
self.assertFalse(bad & {row['id'] for row in rows})
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')})
for ident in bad:
for path in (f'/api/movie/by/{ident}/public/', f'/api/season/by/serie/{ident}/public/'):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
self.assertEqual(catalog.dispatch('/api/movie/by/75/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/movie/by/76/public/')['title'], 'Burn Notice')
def test_repeated_scrapes_keep_old_and_new_notice_ids_out_of_the_api(self):
# Old raw rows stay for snapshot/source preservation, but are never served.
with closing(sqlite3.connect(self.path)) as db:
item = {'id': 90, 'type': 'movie', 'title': 'اطلاعیه مهم', 'description': ''}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(90, 'movie', item['title'], '', json.dumps(item)))
db.commit()
source_bytes = self.path.read_bytes()
source = self.path
sync = load_sync()
for run, offset in enumerate((0, 100), 1):
new_notice, series_notice, detail_notice = 91 + offset, 92 + offset, 93 + offset
rows = [
{'id': 90, 'type': 'movie', 'title': 'اطلاعیه جدید'},
{'id': new_notice, 'type': 'movie', 'title': '📢 اطلاعیه جدید'},
{'id': detail_notice, 'type': 'movie', 'title': 'Looks like a film'},
{'id': 94, 'type': 'movie', 'title': 'Red Notice'},
]
upstream = FakeUpstream({
'movie/by/filtres/0/created/0': rows,
f'movie/by/{detail_notice}': {'id': detail_notice, 'type': 'movie', 'title': 'به روز رسانی جدید'},
'movie/by/94': {'id': 94, 'type': 'movie', 'title': 'Red Notice', 'sources': []},
'serie/by/filtres/0/created/0': [
{'id': series_notice, 'type': 'serie', 'title': 'Important announcement'}],
})
stage = Path(self.temp.name) / f'scrape-{run}.sqlite'
sync.synchronize(source, stage, upstream)
self.assertNotIn(f'movie/by/{new_notice}', upstream.calls)
self.assertNotIn('movie/by/90', upstream.calls)
self.assertNotIn(f'season/by/serie/{series_notice}', upstream.calls)
with closing(sqlite3.connect(stage)) as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM titles WHERE id IN (?,?,?)',
(new_notice, series_notice, detail_notice)).fetchone()[0], 0)
self.assertEqual(db.execute('SELECT title FROM titles WHERE id=90').fetchone()[0], 'اطلاعیه مهم')
api = load_service()
catalog = api.Catalog(stage)
try:
for ident in (90, new_notice, series_notice, detail_notice):
with self.assertRaises(api.ApiError):
catalog.dispatch(f'/api/movie/by/{ident}/public/')
self.assertEqual(catalog.dispatch('/api/movie/by/94/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/season/by/serie/2/public/')[0]['episodes'][0]['id'], 501)
finally:
catalog.close()
source = stage
self.assertEqual(self.path.read_bytes(), source_bytes)
if __name__ == '__main__':
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
import importlib.util
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
SERVER = Path(__file__).resolve().parents[1]
def load_service():
spec = importlib.util.spec_from_file_location('catalog_api', SERVER / 'catalog_api.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fixture(directory):
path = Path(directory) / 'catalog.sqlite'
con = sqlite3.connect(path)
con.executescript((SERVER / 'tests' / 'schema.sql').read_text())
metadata = {'built_at_utc': '2026-11-02T07:00:18+00:00',
'provenance': 'Realistic test snapshot',
'catalog_pagination_complete': True, 'series_episode_fetch_complete': True}
con.executemany('INSERT INTO metadata VALUES (?,?)',
[(k, json.dumps(v)) for k, v in metadata.items()])
con.executemany('INSERT INTO genres VALUES (?,?)', [(1, 'Horror'), (2, 'Action'), (32, 'Top 250'), (48, 'Live')])
con.execute('INSERT INTO countries VALUES (1,?,?)', ('America', 'http://art.test/a%2Fb.jpg?token=Ab+Cd'))
for i in range(1, 66):
kind = 'serie' if i == 2 else 'movie'
title = 'Alpha' if i == 1 else 'Warrior' if i == 2 else 'Film ' + str(i)
if i == 63:
title = 'نسخه جدید ملودی باکس'
if i == 64:
kind = 'channel'
if i == 65:
title = 'Telegram @channelname'
item = {'id': i, 'type': kind, 'title': title, 'description': 'Actor Example بازیگران',
'year': 2020 + i % 7, 'imdb': i / 10, 'rating': 4.1,
'image': 'http://art.test/a%2Fb.jpg?token=Ab+Cd',
'cover': 'https://art.test/a b.jpg', 'comment': True,
'genres': [{'id': 2, 'title': 'Action'}], 'country': [{'id': 1, 'title': 'America'}]}
if kind == 'movie':
source = {'id': 100 + i, 'type': 'mkv', 'quality': '720',
'url': 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment'}
item['sources'] = [source]
con.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(i, kind, title, item['description'], item['year'], item['imdb'], item['rating'], json.dumps(item)))
if kind == 'movie':
con.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?)',
(i, 0, source['id'], '720', 'mkv', source['url'], json.dumps(source)))
con.execute('INSERT INTO title_genres VALUES (?,2)', (i,))
con.execute('INSERT INTO title_countries VALUES (?,1)', (i,))
con.execute('INSERT INTO title_genres VALUES (1,32)')
episode = {'id': 501, 'title': 'Episode 1', 'sources': [{'id': 201, 'type': 'mkv',
'quality': None, 'url': 'http://media.test/a%20b.mkv?signature=%2f+Ab'}]}
season = {'id': 1001, 'title': 'Season 1', 'episodes': [episode]}
con.execute('INSERT INTO seasons VALUES (2,0,1001,?,?)', ('Season 1', json.dumps(season)))
con.execute('INSERT INTO episodes(series_id,season_index,episode_index,id,title,raw_json) VALUES (2,0,0,501,?,?)',
('Episode 1', json.dumps(episode)))
con.execute('INSERT INTO episode_sources VALUES (2,0,0,0,201,NULL,?,?,?)',
('mkv', episode['sources'][0]['url'], json.dumps(episode['sources'][0])))
con.execute('INSERT INTO series_fetch_status VALUES (2,?,1,?)', ('ok', 'https://old.test/api/season/2'))
con.commit()
con.close()
return path
class CatalogTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
def tearDown(self):
if hasattr(self, 'catalog'):
self.catalog.close()
self.temp.cleanup()
def open(self):
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
return self.catalog
def test_health_is_real_snapshot_counts_and_freshness(self):
catalog = self.open()
health = catalog.dispatch('/api/health')
self.assertEqual(health['status'], 'ok')
self.assertEqual(health['counts']['titles'], 65)
self.assertEqual(health['counts']['episodes'], 1)
self.assertEqual(health['counts']['visible_titles'], 62)
self.assertEqual(health['snapshot_built_at_utc'], '2026-11-02T07:00:18+00:00')
self.assertEqual(health['provenance'], 'Realistic test snapshot')
self.assertFalse(health['media_proxy'])
self.assertTrue(health['offline'])
self.assertGreaterEqual(health['snapshot_age_seconds'], 0)
with self.assertRaises(sqlite3.OperationalError):
catalog.db.execute('DELETE FROM titles')
def test_catalog_filters_pages_and_original_media_links(self):
catalog = self.open()
first = catalog.dispatch('/api/movie/by/filtres/0/created/0/public/')
second = catalog.dispatch('/api/movie/by/filtres/0/created/1/public/')
self.assertEqual(len(first), 30)
self.assertEqual(len(second), 30)
self.assertFalse({r['id'] for r in first} & {r['id'] for r in second})
self.assertTrue(all(r['id'] <= 62 and r['type'] == 'movie' for r in first + second))
self.assertEqual([r['id'] for r in catalog.dispatch('/api/movie/by/filtres/0/created/2/public/')], [1])
self.assertEqual(catalog.dispatch('/api/movie/by/filtres/0/created/3/public/'), [])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/poster/by/filtres/32/1/imdb/0/public/')], [1])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/serie/by/filtres/0/year/0/public/')], [2])
film = catalog.dispatch('/api/movie/by/1/public/')
original = json.loads(catalog.db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
self.assertEqual(film['sources'], original['sources'])
self.assertEqual(film['image'], original['image'])
self.assertNotIn('comment', film)
seasons = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(seasons[0]['episodes'][0]['sources'][0]['url'], 'http://media.test/a%20b.mkv?signature=%2f+Ab')
self.assertEqual(catalog.dispatch('/api/genre/all/public/')[0]['id'], 1)
self.assertNotIn(48, [r['id'] for r in catalog.dispatch('/api/genre/all/public/')])
self.assertEqual(catalog.dispatch('/api/country/all/public/')[0]['title'], 'America')
for title_id in (63, 64, 65, 999):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(f'/api/movie/by/{title_id}/public/')
self.assertEqual(error.exception.status, 404)
def test_home_search_recommendations_curated_and_unexported_metadata(self):
catalog = self.open()
home = catalog.dispatch('/api/first/public/')
self.assertTrue(home['genres'][0]['posters'])
self.assertEqual(home['actors'], [])
self.assertEqual(home['countries'][0]['id'], 1)
search = catalog.dispatch('/api-user/newapi/like.php?action=search-movie-en&q=alpha&pageno=1')
self.assertEqual([r['id'] for r in search], [1])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=no-match&pageno=1'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=%27%20OR%201%3D1--'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-top&pageno=1')[0]['id'], 62)
self.assertTrue(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-year&pageno=1'))
curated = catalog.dispatch('/api-user/newapi/pishnahad.php')
self.assertTrue(curated)
self.assertEqual(len(curated[0]['title']), len(curated[0]['link']))
self.assertTrue(catalog.dispatch(curated[0]['link'][0] + '1'))
self.assertEqual([r['id'] for r in catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=32&pageno=1')], [1])
for target in ['/api-user/newapi/like.php?action=all-actor&pageno=1',
'/api-user/newapi/like.php?action=colection&pageno=1',
'/api-user/filmahang/star.php?key=public&action=starsearch&q=Example&pageno=1',
'/api-user/filmahang/star.php?key=public&action=mazmon&pageno=1',
'/api-user/filmahang/star.php?key=public&action=share&pageno=1',
'/api/role/by/poster/1/public/', '/api/movie/by/actor/12/public/',
'/api/subtitles/by/movie/1/public/', '/api/subtitles/by/episode/501/public/']:
self.assertEqual(catalog.dispatch(target), [], target)
def test_rejects_proxy_comments_traversal_and_unbounded_inputs(self):
catalog = self.open()
for target in ['/api/proxy?url=http://169.254.169.254/', '/api/download/movie.mkv',
'/api/comments/by/poster/1/public/', '/api/../catalog.sqlite',
'/api/%2e%2e/catalog.sqlite', '//evil.test/api/health',
'/api/movie/by/1/public/?url=https://media.test/movie.mkv',
'/api-user/newapi/like.php?action=comments',
'/api-user/newapi/like.php?action=search-movie&q=a&q=b',
'/api-user/newapi/like.php?action=search-movie&q=' + 'a' * 201,
'/api-user/newapi/like.php?action=search-movie&pageno=-1',
'/api-user/newapi/like.php?action=search-movie&pageno=100002',
'/api/movie/by/filtres/0/views/0/public/']:
with self.assertRaises(self.api.ApiError, msg=target):
catalog.dispatch(target)
def test_raw_controls_and_malformed_urls_are_rejected_before_parsing(self):
catalog = self.open()
for target in ['/api/\thealth', '\n/api/health', '/api/health\r', 'http://[',
'/api-user/newapi/like.php?action=search-movie&q=a\tb']:
with self.subTest(target=repr(target)):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(target)
self.assertEqual(error.exception.status, 400)
def test_real_http_is_read_only_bounded_and_returns_direct_source_urls(self):
import threading
from urllib.request import Request, urlopen
from urllib.error import HTTPError
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = 'http://127.0.0.1:' + str(server.server_port)
try:
with urlopen(origin + '/api/movie/by/1/public/', timeout=3) as response:
detail = json.load(response)
self.assertEqual(response.headers['Content-Type'], 'application/json; charset=utf-8')
self.assertNotIn('Server', response.headers)
self.assertEqual(detail['sources'][0]['url'], 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment')
for method in ['POST', 'PUT', 'DELETE', 'PATCH', 'CONNECT']:
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/health', method=method), timeout=3)
self.assertEqual(error.exception.code, 405)
error.exception.close()
with self.assertRaises(HTTPError) as error:
urlopen(origin + '/api/proxy?url=https://media.test/a.mkv', timeout=3)
self.assertIn(error.exception.code, (400, 404))
error.exception.close()
with urlopen(Request(origin + '/api/health', method='HEAD'), timeout=3) as response:
self.assertEqual(response.status, 200)
self.assertEqual(response.read(), b'')
finally:
server.shutdown()
server.server_close()
thread.join(3)
def test_worker_saturation_rejects_extra_connections_and_releases_slots(self):
import socket
import threading
import time
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
sockets = []
def wait_slots(value):
deadline = time.monotonic() + 4
while server.slots._value != value and time.monotonic() < deadline:
time.sleep(.01)
self.assertEqual(server.slots._value, value)
try:
for _ in range(16):
sock = socket.create_connection(server.server_address, timeout=2)
sock.sendall(b'GET /api/health HTTP/1.0\r\n')
sockets.append(sock)
wait_slots(0)
with socket.create_connection(server.server_address, timeout=2) as extra:
try:
self.assertEqual(extra.recv(1), b'')
except ConnectionResetError:
pass
for sock in sockets:
sock.close()
sockets.clear()
wait_slots(16)
with socket.create_connection(server.server_address, timeout=2) as recovered:
recovered.sendall(b'GET /api/health HTTP/1.0\r\n\r\n')
self.assertIn(b'200', recovered.recv(100))
finally:
for sock in sockets:
sock.close()
server.shutdown()
server.server_close()
thread.join(3)
if __name__ == '__main__':
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""Operation-count regressions; timings are recorded separately, never asserted."""
import json
import threading
import unittest
from unittest.mock import patch
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import test_catalog_api as fixtures
class CatalogPerformanceTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def http_server(self, catalog):
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def stop():
server.shutdown()
server.server_close()
thread.join(3)
self.addCleanup(stop)
return 'http://127.0.0.1:' + str(server.server_port)
def test_repeated_snapshot_http_read_does_not_query_or_serialize_again(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
queries = []
catalog.db.set_trace_callback(queries.append)
with patch.object(self.api.json, 'dumps', wraps=json.dumps) as encode:
with urlopen(origin + '/api/first/public/', timeout=3) as response:
self.assertEqual(response.read(), first)
self.assertEqual(queries, [], 'Warm snapshot HTTP responses must reuse encoded bytes')
self.assertEqual(encode.call_count, 0)
def test_cache_bounds_lru_and_large_response_bypass(self):
catalog = self.open()
catalog.RESPONSE_CACHE_ENTRIES = 2
one = '/api/movie/by/1/public/'
two = '/api/movie/by/2/public/'
three = '/api/movie/by/3/public/'
first = catalog.response(one)
catalog.response(two)
self.assertIs(catalog.response(one), first)
catalog.response(three)
self.assertEqual(list(catalog._responses), [one, three])
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
catalog.RESPONSE_CACHE_ITEM_BYTES = 1
catalog.response(two)
self.assertNotIn(two, catalog._responses)
# Byte budget, not just number of entries, forces eviction.
catalog.RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024
catalog.RESPONSE_CACHE_BYTES = len(catalog.response(three)) + len(three) + 1
catalog.response(two)
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
self.assertEqual(len(catalog._responses), 1)
def test_dynamic_responses_errors_and_queries_are_never_retained(self):
catalog = self.open()
with patch.object(catalog, 'health', side_effect=[{'age': 1}, {'age': 2}]):
self.assertNotEqual(catalog.response('/api/health'), catalog.response('/api/health'))
with patch.object(self.api, 'load_banners', side_effect=[{'banners': [1]}, {'banners': [2]}]):
self.assertNotEqual(catalog.response('/api/banners'), catalog.response('/api/banners'))
catalog.response('/api-user/newapi/like.php?action=search-movie&q=Alpha')
for _ in range(2):
with self.assertRaises(self.api.ApiError):
catalog.response('/api/movie/by/999/public/')
self.assertEqual(list(catalog._responses), [])
def test_warm_head_reuses_get_bytes_and_body_requests_still_fail(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
with urlopen(Request(origin + '/api/first/public/', method='HEAD'), timeout=3) as response:
self.assertEqual(response.read(), b'')
self.assertEqual(int(response.headers['Content-Length']), len(first))
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/first/public/', method='GET', data=b'x'), timeout=3)
self.assertEqual(error.exception.code, 400)
error.exception.close()
def test_normalized_search_matches_fallback_with_unicode_and_pagination(self):
catalog = self.open()
indexed = catalog.search_text
self.assertLessEqual(catalog.search_index_bytes, 64 * 1024 * 1024)
for query in ['Actor', 'بازیگران', 'بازيگران', 'ALPHA', 'not-present', 'Film 4']:
for page in [0, 1, 2]:
catalog.search_text = indexed
expected = catalog.titles(query=query, page=page)
catalog.search_text = {}
self.assertEqual(catalog.titles(query=query, page=page), expected)
catalog.search_text = indexed
def test_search_normalizes_query_not_every_snapshot_row(self):
catalog = self.open()
with patch.object(self.api, 'normal', wraps=self.api.normal) as normalize:
catalog.db.create_function('normal', 1, normalize, deterministic=True)
result = catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=ACTOR&pageno=1')
self.assertEqual([row['id'] for row in result], list(range(62, 32, -1)))
self.assertLessEqual(normalize.call_count, 2,
'Immutable title text must not be normalized on every search')
if __name__ == '__main__':
unittest.main()
+463
View File
@@ -0,0 +1,463 @@
"""Synchronizer tests use the exported schema, never the upstream service."""
import copy
from contextlib import closing
import importlib.util
import io
import json
import os
import sys
from pathlib import Path
import sqlite3
import ssl
import tempfile
import unittest
from unittest.mock import patch
from urllib.error import HTTPError, URLError
from test_catalog_api import SERVER, fixture, load_service
def load_sync():
spec = importlib.util.spec_from_file_location('catalog_sync', SERVER / 'catalog_sync.py')
module = importlib.util.module_from_spec(spec)
with patch.object(sys, 'path', [str(SERVER)] + sys.path):
spec.loader.exec_module(module)
return module
class Response(io.BytesIO):
status = 200
headers = {}
def __init__(self, payload=b'[]', url=None, status=200):
super().__init__(payload)
self.url, self.status = url, status
def geturl(self):
return self.url
class QueueOpener:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def open(self, request, timeout):
self.calls.append((request.full_url, request.get_method(), timeout))
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
if response.url is None:
response.url = request.full_url
return response
class UpstreamTests(unittest.TestCase):
def test_exact_fixed_origin_metadata_urls_and_quoted_key(self):
sync = load_sync()
opener = QueueOpener(*(Response(b'{}' if p.startswith('movie/by/3') else b'[]') for p in
['genre/all', 'country/all', 'movie/by/35721',
'movie/by/filtres/0/created/0', 'serie/by/filtres/0/created/12',
'season/by/serie/35716']))
client = sync.Upstream('test secret/+#?', interval=0, opener=opener)
paths = ['genre/all', 'country/all', 'movie/by/35721', 'movie/by/filtres/0/created/0',
'serie/by/filtres/0/created/12', 'season/by/serie/35716']
for path in paths:
client.get(path)
self.assertEqual(opener.calls, [('https://ninwinios.info/api/' + p +
'/test%20secret%2F%2B%23%3F/', 'GET', 30) for p in paths])
self.assertEqual(client.requests_count, 6)
for bad in ['https://evil.test/api/genre/all', '//evil.test/a', '/genre/all',
'genre/all/', 'genre/../all', 'genre/%2e%2e/all', 'genre/all?url=x',
'genre/all#x', 'movie/by/1/surprise', 'download/movie.mkv',
'movie/by/1\n', 'movie/by/0', 'movie/by/-1', 'movie/by/filtres/0/views/0']:
with self.subTest(path=repr(bad)), self.assertRaises(ValueError):
client.get(bad)
self.assertEqual(client.requests_count, 6)
def test_real_upstream_legacy_html_label_still_requires_valid_json(self):
sync = load_sync()
response = Response(b'[{"id":1,"title":"Action"}]')
response.headers = {'Content-Type': 'text/html; charset=UTF-8'}
client = sync.Upstream('k', interval=0, opener=QueueOpener(response))
self.assertEqual(client.get('genre/all'), [{'id': 1, 'title': 'Action'}])
html = Response(b'<html>Service unavailable</html>')
html.headers = {'Content-Type': 'text/html; charset=UTF-8'}
opener = QueueOpener(html)
with self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_wrong_content_type_and_truncated_length_are_not_retried(self):
sync = load_sync()
for headers in ({'Content-Type': 'video/mp4'}, {'Content-Type': 'image/jpeg'},
{'Content-Length': '999'}, {'Content-Length': 'bad'},
{'Content-Length': str(32 * 1024 * 1024 + 1)}):
response = Response(b'[]')
response.headers = headers
opener = QueueOpener(response)
with self.subTest(headers=headers), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_bounded_retry_and_rate_limit(self):
sync = load_sync()
for status in (429, 503):
error = HTTPError('https://secret.test/key', status, 'private key', {}, None)
opener = QueueOpener(error, Response(b'[]'))
client = sync.Upstream('private-key', interval=.25, opener=opener)
with patch.object(sync.time, 'sleep') as sleep:
self.assertEqual(client.get('genre/all'), [])
self.assertGreaterEqual(sleep.call_count, 1)
self.assertEqual(client.requests_count, 2)
opener = QueueOpener(URLError('private URL'), Response(b'[]'))
with patch.object(sync.time, 'sleep'):
self.assertEqual(sync.Upstream('k', interval=0, opener=opener).get('genre/all'), [])
opener = QueueOpener(Response(), Response())
client = sync.Upstream('k', interval=.25, opener=opener)
with patch.object(sync.time, 'monotonic', return_value=100), patch.object(sync.time, 'sleep') as sleep:
client.get('genre/all')
client.get('country/all')
sleep.assert_called_once_with(.25)
def test_auth_cert_redirect_json_and_size_fail_closed_without_secrets(self):
sync = load_sync()
for failure in [HTTPError('https://secret.test/private-key', code, 'private-key', {}, None)
for code in (301, 302, 307, 308, 401, 403, 404)] + [
URLError(ssl.SSLCertVerificationError('private-key')),
ssl.SSLCertVerificationError('private-key')]:
opener = QueueOpener(failure)
client = sync.Upstream('private-key', interval=0, opener=opener)
with self.assertRaises(sync.SyncError) as error:
client.get('genre/all')
self.assertNotIn('private-key', str(error.exception))
self.assertNotIn('https:', str(error.exception))
self.assertEqual(len(opener.calls), 1)
for response in [Response(b'not JSON'), Response(b'NaN'), Response(b'{}'),
Response(b'null'), Response(b'[]', url='https://evil.test/video.mkv'),
Response(b'[]', status=302), Response(b'[' * (32 * 1024 * 1024 + 1))]:
opener = QueueOpener(response)
with self.assertRaises(sync.SyncError):
sync.Upstream('private-key', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
client = sync.Upstream('k', interval=0)
from urllib.request import HTTPRedirectHandler
handlers = [h for h in client._opener.handlers if isinstance(h, HTTPRedirectHandler)]
self.assertEqual(len(handlers), 1)
with self.assertRaises(sync.SyncError):
handlers[0].redirect_request(None, None, 302, 'moved', {}, 'https://evil.test/video')
def test_retry_exhaustion_and_configuration_are_bounded(self):
sync = load_sync()
opener = QueueOpener(*(HTTPError('https://secret/key', 503, 'key', {}, None) for _ in range(3)))
with patch.object(sync.time, 'sleep'), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 3)
for kwargs in ({'attempts': 0}, {'attempts': 11}, {'interval': -1}, {'timeout': 0},
{'interval': float('nan')}, {'timeout': float('inf')}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
sync.Upstream('k', **kwargs)
class FakeUpstream:
def __init__(self, routes=None):
self.routes = {'genre/all': [], 'country/all': [],
'movie/by/filtres/0/created/0': [],
'movie/by/filtres/0/created/1': [],
'serie/by/filtres/0/created/0': [],
'serie/by/filtres/0/created/1': [], 'season/by/serie/2': []}
self.routes.update(routes or {})
self.calls = []
self.requests_count = 0
def get(self, path):
self.calls.append(path)
self.requests_count += 1
if path not in self.routes:
raise AssertionError('Unexpected metadata path: ' + path)
value = self.routes[path]
if isinstance(value, Exception):
raise value
return copy.deepcopy(value)
class SynchronizeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.source = fixture(self.temp.name)
self.stage = Path(self.temp.name) / 'stage.sqlite'
self.sync = load_sync()
def tearDown(self):
self.temp.cleanup()
def rows(self, path, sql, args=()):
with closing(sqlite3.connect(path)) as db:
return db.execute(sql, args).fetchall()
def metadata(self, path):
return {key: json.loads(value) for key, value in self.rows(path, 'SELECT * FROM metadata')}
def test_all_known_visible_series_refresh_and_series_samples_are_bounded(self):
with closing(sqlite3.connect(self.source)) as db:
for title_id, title, description in [(90, 'An older series', 'Normal description'),
(91, 'Telegram @channelname', ''),
(92, 'Disguised advertisement', 'ملودی باکس')]:
item = {'id': title_id, 'type': 'serie', 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(title_id, 'serie', title, description, json.dumps(item)))
db.commit()
upstream = FakeUpstream({'season/by/serie/90': [{'id': 900, 'title': 'Season 1', 'episodes': [
{'id': 901, 'title': 'New episode on an old series'}]}]})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['episodes'], 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertIn('season/by/serie/90', upstream.calls)
self.assertNotIn('season/by/serie/91', upstream.calls)
self.assertNotIn('season/by/serie/92', upstream.calls)
self.assertEqual(report['counts']['series'], 4)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles WHERE id IN (91,92)'), [(2,)])
self.stage.unlink()
records = [{'id': i, 'type': 'serie', 'title': 'New Series ' + str(i)} for i in range(100, 201)]
routes = {'serie/by/filtres/0/created/0': records, 'season/by/serie/90': []}
routes.update({f'season/by/serie/{item["id"]}': [] for item in records})
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['updated_series_count'], 101)
self.assertEqual(len(report['updated_series_ids']), 100)
self.assertEqual(report['updated_series_ids'], list(range(100, 200)))
def test_progress_failure_discards_candidate_and_missing_source_is_not_created(self):
original = self.source.read_bytes()
def reject(_event):
raise RuntimeError('private-key')
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(), progress=reject)
self.assertNotIn('private-key', str(error.exception))
self.assertEqual(self.source.read_bytes(), original)
self.assertFalse(self.stage.exists())
missing = Path(self.temp.name) / 'missing.sqlite'
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(missing, self.stage, FakeUpstream())
self.assertFalse(missing.exists())
self.assertFalse(self.stage.exists())
def test_malformed_metadata_discards_candidate_not_source(self):
original = self.source.read_bytes()
cases = [
{'genre/all': {'error': 'not a list'}},
{'country/all': [{'error': 'bad metadata'}]},
{'movie/by/filtres/0/created/0': {'error': 'failed'}},
{'serie/by/filtres/0/created/0': [None]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie'}]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie', 'title': {'bad': 'title'}}]},
{'movie/by/filtres/0/created/0': [{'id': True, 'type': 'movie', 'title': 'Bad'}]},
{'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha', 'sources': None}]},
{'season/by/serie/2': {'error': 'not seasons'}},
{'season/by/serie/2': [{'error': 'not a season'}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': {'error': 'not episodes'}}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'error': 'not an episode'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{'id': 201, 'url': []}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': 'not sources'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501}, {'id': 501}]}]},
{'season/by/serie/2': ValueError('https://private.test/private-key')},
]
for routes in cases:
with self.subTest(routes=routes):
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertNotIn('private-key', str(error.exception))
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_429_503_exhaustion_rolls_back_staged_writes(self):
original = self.source.read_bytes()
for status in (429, 503):
opener = QueueOpener(Response(b'[{"id":99,"title":"A new genre"}]'), Response(),
*(HTTPError('https://private.test/private-key', status, 'private-key', {}, None)
for _ in range(3)))
upstream = self.sync.Upstream('private-key', interval=0, opener=opener)
with patch.object(self.sync.time, 'sleep'), self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(len(opener.calls), 5)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_page_limit_and_existing_candidates_are_never_published_or_overwritten(self):
original = self.source.read_bytes()
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated'}]}
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes), max_pages=1)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
self.stage.write_bytes(b'existing candidate')
with self.assertRaises(FileExistsError):
self.sync.synchronize(self.source, self.stage, FakeUpstream())
self.assertEqual(self.stage.read_bytes(), b'existing candidate')
with self.assertRaises(ValueError):
self.sync.synchronize(self.source, self.source, FakeUpstream())
self.assertEqual(self.source.read_bytes(), original)
def test_existing_id_does_not_end_pagination_and_missing_ids_use_indices(self):
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha'}],
'movie/by/filtres/0/created/1': [{'id': 70, 'type': 'movie', 'title': 'Next page'}],
'movie/by/filtres/0/created/2': [],
'movie/by/70': {'id': 70, 'type': 'movie', 'title': 'Next page', 'sources': []},
'season/by/serie/2': [{'title': 'Season 1 refreshed', 'episodes': [
{'title': 'Episode 1 refreshed', 'sources': []}, {'title': 'Episode without ID', 'sources': []}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['episodes'], 1)
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM seasons'), [(1001, 'Season 1 refreshed')])
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM episodes ORDER BY episode_index'),
[(501, 'Episode 1 refreshed'), (None, 'Episode without ID')])
self.assertEqual(self.rows(self.stage, 'SELECT source_id FROM episode_sources'), [(201,)])
def test_advertisements_channels_and_detail_ads_are_not_imported(self):
items = [
{'id': 70, 'type': 'movie', 'title': 'Telegram @channelname'},
{'id': 71, 'type': 'movie', 'title': 'Innocent', 'description': 'نسخه جدید ملودی باکس'},
{'id': 72, 'type': 'channel', 'title': 'Live channel'},
{'id': 73, 'type': 'movie', 'title': 'Looks like a movie'}]
upstream = FakeUpstream({'movie/by/filtres/0/created/0': items,
'movie/by/filtres/0/created/1': [],
'movie/by/73': {'id': 73, 'type': 'movie', 'title': 'Telegram @channelname'}})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['titles'], 0)
self.assertNotIn('movie/by/70', upstream.calls)
self.assertNotIn('movie/by/71', upstream.calls)
self.assertNotIn('movie/by/72', upstream.calls)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles'), [(65,)])
def test_reordered_stable_ids_and_truncated_nested_lists_retain_every_old_entry(self):
first = {'season/by/serie/2': [
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/old2'}]}]},
{'id': 1002, 'title': 'Season 2', 'episodes': [{'id': 601, 'title': 'S2 E1'}]}]}
self.sync.synchronize(self.source, self.stage, FakeUpstream(first))
second = Path(self.temp.name) / 'second.sqlite'
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated Alpha',
'sources': [], 'genres': []}],
'movie/by/filtres/0/created/1': [],
'season/by/serie/2': [
{'id': 1002, 'title': 'Season Two', 'episodes': []},
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Updated Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/refreshed'}]},
{'id': 501, 'sources': []}]}]}
report = self.sync.synchronize(self.stage, second, FakeUpstream(routes))
self.assertEqual(report['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(self.rows(second, 'SELECT season_index,id FROM seasons ORDER BY season_index'),
[(0, 1001), (1, 1002)])
self.assertEqual(self.rows(second, 'SELECT season_index,episode_index,id FROM episodes ORDER BY 1,2'),
[(0, 0, 501), (0, 1, 502), (1, 0, 601)])
self.assertEqual(self.rows(second, 'SELECT url FROM episode_sources WHERE source_id=202'),
[('https://video.test/refreshed',)])
for table in ('seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertGreaterEqual(self.rows(second, 'SELECT COUNT(*) FROM ' + table)[0][0],
self.rows(self.stage, 'SELECT COUNT(*) FROM ' + table)[0][0])
third = Path(self.temp.name) / 'third.sqlite'
self.sync.synchronize(second, third, FakeUpstream())
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(second, 'SELECT * FROM ' + table), self.rows(third, 'SELECT * FROM ' + table))
season = json.loads(self.rows(second, 'SELECT raw_json FROM seasons WHERE id=1001')[0][0])
self.assertEqual([e['id'] for e in season['episodes']], [501, 502])
self.assertEqual(season['episodes'][1]['sources'][0]['url'], 'https://video.test/refreshed')
movie = json.loads(self.rows(second, 'SELECT raw_json FROM titles WHERE id=1')[0][0])
self.assertTrue(movie['sources'])
self.assertTrue(movie['genres'])
def test_old_series_absent_from_catalogue_and_new_series_receive_episodes_idempotently(self):
new_series = {'id': 70, 'type': 'serie', 'title': 'New Series'}
old_episode = {'id': 501, 'title': 'Episode 1', 'sources': []}
new_episode = {'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'quality': '1080', 'type': 'mkv', 'url': 'https://video.test/e2.mkv'}]}
routes = {'serie/by/filtres/0/created/0': [new_series],
'serie/by/filtres/0/created/1': [],
'season/by/serie/2': [{'id': 1001, 'title': 'Season 1', 'episodes': [old_episode, new_episode]}],
'season/by/serie/70': [{'id': 1070, 'title': 'New Season', 'episodes': [
{'id': 701, 'title': 'Pilot', 'sources': [
{'id': 702, 'url': 'https://video.test/pilot.mp4', 'type': 'mp4'}]}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added'], {'titles': 1, 'movies': 0, 'series': 1, 'episodes': 2, 'sources': 2})
self.assertEqual(report['updated_series_ids'], [2, 70])
self.assertEqual(report['updated_series_count'], 2)
self.assertEqual(self.rows(self.stage, 'SELECT id FROM episodes ORDER BY id'), [(501,), (502,), (701,)])
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM seasons WHERE series_id=2')[0][0])
self.assertEqual(len(raw['episodes']), 2)
self.assertEqual(raw['episodes'][0]['sources'][0]['id'], 201)
self.assertEqual(raw['episodes'][1], new_episode)
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(served[0]['episodes'][1], new_episode)
finally:
catalog.close()
second_stage = Path(self.temp.name) / 'second.sqlite'
again = self.sync.synchronize(self.stage, second_stage, FakeUpstream(routes))
self.assertEqual(again['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(again['updated_series_ids'], [])
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(self.stage, 'SELECT * FROM ' + table),
self.rows(second_stage, 'SELECT * FROM ' + table))
self.assertEqual(self.metadata(second_stage)['original_snapshot_built_at_utc'],
self.metadata(self.source)['built_at_utc'])
for (url,) in self.rows(second_stage, 'SELECT source_url FROM series_fetch_status'):
self.assertTrue(url.startswith('https://ninwinios.info/api/season/by/serie/'))
self.assertNotIn('key', url)
def test_new_movie_details_staged_source_untouched_and_real_counts(self):
original = self.source.read_bytes()
movie = {'id': 70, 'type': 'movie', 'title': 'New Film', 'year': 2026,
'genres': [{'id': 5, 'title': 'Drama'}], 'country': [{'id': 3, 'title': 'Iran'}],
'image': 'https://images.test/never-fetch.jpg'}
detail = dict(movie, description='New description', sources=[
{'id': 700, 'type': 'mkv', 'url': 'https://video.test/never-fetch.mkv?key=opaque'}])
upstream = FakeUpstream({'genre/all': [{'id': 5, 'title': 'Drama'}],
'country/all': [{'id': 3, 'title': 'Iran'}],
'movie/by/filtres/0/created/0': [movie, movie],
'movie/by/filtres/0/created/1': [], 'movie/by/70': detail})
progress = []
report = self.sync.synchronize(self.source, self.stage, upstream, progress=progress.append)
self.assertEqual(self.source.read_bytes(), original)
self.assertEqual(report['added']['titles'], 1)
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['series'], 0)
self.assertEqual(report['added']['sources'], 1)
self.assertEqual(report['counts']['titles'], 66)
self.assertEqual(report['counts']['episodes'], 1)
self.assertEqual(report['requests_count'], upstream.requests_count)
self.assertFalse(report['media_files_downloaded'])
self.assertEqual(upstream.calls.count('movie/by/70'), 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertTrue(all('http' not in path and '.mkv' not in path and '.jpg' not in path
for path in upstream.calls))
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM titles WHERE id=70')[0][0])
self.assertEqual(raw, detail)
self.assertEqual(self.rows(self.stage, 'SELECT url FROM movie_sources WHERE title_id=70')[0][0],
detail['sources'][0]['url'])
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/movie/by/70/public/')
self.assertEqual(served, detail)
finally:
catalog.close()
self.assertEqual(self.rows(self.stage, 'PRAGMA integrity_check'), [('ok',)])
self.assertEqual(self.rows(self.stage, 'PRAGMA foreign_key_check'), [])
metadata = self.metadata(self.stage)
self.assertEqual(metadata['original_snapshot_built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertNotEqual(metadata['built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertEqual(metadata['built_at_utc'], metadata['synchronized_at_utc'])
self.assertEqual(metadata['sync_interval_seconds'], 259200)
self.assertEqual(metadata['counts']['titles'], 66)
self.assertFalse(metadata['media_files_downloaded'])
self.assertEqual(metadata['last_sync']['upstream_base'], 'https://ninwinios.info/api/')
self.assertEqual(json.loads(json.dumps(report)), report)
self.assertTrue(progress)
for item in progress:
self.assertLessEqual(set(item), {'phase', 'count', 'total'})
self.assertIsInstance(item['phase'], str)
self.assertIsInstance(item['count'], int)
if __name__ == '__main__':
unittest.main()
+271
View File
@@ -0,0 +1,271 @@
"""Atomic publication and rollback for the catalogue updater."""
import grp
import hashlib
from contextlib import closing, redirect_stdout
import importlib.util
import io
import json
import os
from pathlib import Path
import sqlite3
import stat
import tempfile
import unittest
import test_catalog_api as fixtures
SERVER = Path(__file__).resolve().parents[1]
def runner_module():
spec = importlib.util.spec_from_file_location('catalog_sync_runner', SERVER / 'catalog_sync_runner.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class SyncRunnerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.database = fixtures.fixture(self.root)
self.state = self.root / 'state'
self.stamp = '2026-11-03T19:00:00+00:00'
self.api = runner_module()
def tearDown(self):
self.temp.cleanup()
def candidate(self, source, stage, upstream, **kwargs):
with closing(sqlite3.connect(source)) as src, closing(sqlite3.connect(stage)) as dest:
src.backup(dest)
dest.execute('UPDATE metadata SET value_json=? WHERE key=?',
(json.dumps(self.stamp), 'built_at_utc'))
dest.commit()
return {'added_movies': 0, 'added_episodes': 0, 'built_at_utc': self.stamp}
def digest(self):
return hashlib.sha256(self.database.read_bytes()).hexdigest()
def test_progress_normalizes_core_count_without_logging_other_fields(self):
for event, completed in (({'count': 0}, 0), ({'count': 3}, 3),
({'completed': 4, 'count': 5}, 4)):
with self.subTest(event=event):
output = io.StringIO()
with redirect_stdout(output):
self.api.emit_progress({**event, 'phase': 'movies', 'total': 7,
'api_key': 'never-print-this'})
self.assertEqual(json.loads(output.getvalue()),
{'progress': {'phase': 'movies', 'completed': completed, 'total': 7}})
def test_publish_is_atomic_and_records_success_after_health(self):
before = self.digest()
events = []
def restart():
events.append('restart')
with closing(sqlite3.connect(self.database)) as db:
stamp = json.loads(db.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
self.assertEqual(stamp, self.stamp)
def health(stamp):
self.assertEqual(stamp, self.stamp)
self.assertFalse((self.state / 'last-success.json').exists())
events.append('healthy')
result = self.api.run_update(self.database, self.state, object(),
synchronize_fn=self.candidate, restart_fn=restart, health_fn=health)
self.assertEqual(events, ['restart', 'healthy'])
self.assertNotEqual(self.digest(), before)
backup = self.database.with_name('catalog.previous.sqlite')
self.assertEqual(hashlib.sha256(backup.read_bytes()).hexdigest(), before)
self.assertEqual(result['status'], 'success')
self.assertEqual(json.loads((self.state / 'last-success.json').read_text())['status'], 'success')
@unittest.skipUnless(os.geteuid() == 0, 'requires root to set group ownership')
def test_publish_preserves_group_readable_database_ownership_and_mode(self):
group = grp.getgrnam('www-data').gr_gid
os.chown(self.database, 0, group)
os.chmod(self.database, 0o640)
observed = []
def restart():
published = self.database.stat()
observed.append((published.st_uid, published.st_gid, stat.S_IMODE(published.st_mode)))
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=restart, health_fn=lambda _: None)
self.assertEqual(observed, [(0, group, 0o640)])
published = self.database.stat()
self.assertEqual((published.st_uid, published.st_gid), (0, group))
self.assertEqual(stat.S_IMODE(published.st_mode), 0o640)
def test_fetch_failure_never_changes_live_database_or_restarts(self):
before = self.digest()
def fail(*_args, **_kwargs):
raise RuntimeError('network unavailable')
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=fail,
restart_fn=lambda: self.fail('must not restart'), health_fn=lambda _: None)
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertEqual(json.loads((self.state / 'last-result.json').read_text())['status'], 'failed')
self.assertEqual(list(self.root.glob('.catalog-sync-*.sqlite*')), [])
def test_failed_health_rolls_back_before_restarting_old_catalogue(self):
before = self.digest()
with closing(sqlite3.connect(self.database)) as db:
old_stamp = json.loads(db.execute(
"SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
restarts = []
health_checks = []
def restart():
restarts.append(self.digest())
def fail_health(stamp):
health_checks.append(stamp)
if stamp == self.stamp:
raise RuntimeError('new API not healthy')
self.assertEqual(self.digest(), before)
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=restart, health_fn=fail_health)
self.assertEqual(len(restarts), 2)
self.assertNotEqual(restarts[0], before)
self.assertEqual(restarts[1], before)
self.assertEqual(health_checks, [self.stamp, old_stamp])
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertFalse(json.loads((self.state / 'last-result.json').read_text())['rollback_failed'])
def test_failed_rollback_health_requires_operator_attention(self):
before = self.digest()
with closing(sqlite3.connect(self.database)) as db:
old_stamp = json.loads(db.execute(
"SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()[0])
health_checks = []
restarts = []
def fail_health(stamp):
health_checks.append(stamp)
raise RuntimeError('API not healthy')
with self.assertRaisesRegex(self.api.UpdateError, 'operator attention'):
self.api.run_update(self.database, self.state, object(), synchronize_fn=self.candidate,
restart_fn=lambda: restarts.append(self.digest()), health_fn=fail_health)
self.assertEqual(health_checks, [self.stamp, old_stamp])
self.assertEqual(len(restarts), 2)
self.assertEqual(restarts[1], before)
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
self.assertTrue(json.loads((self.state / 'last-result.json').read_text())['rollback_failed'])
def test_concurrent_run_is_rejected_before_fetching(self):
import fcntl
self.state.mkdir()
with (self.state / 'sync.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(),
synchronize_fn=lambda *_a, **_k: self.fail('overlapping fetch'),
restart_fn=lambda: None, health_fn=lambda _: None)
def assert_candidate_rejected(self, mutate):
before = self.digest()
restarts = []
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
mutate(db)
db.commit()
return report
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: restarts.append(True), health_fn=lambda _: None)
self.assertEqual(restarts, [])
self.assertEqual(self.digest(), before)
self.assertFalse((self.state / 'last-success.json').exists())
def test_required_reference_and_source_tables_cannot_be_removed_or_emptied(self):
for table in ('genres', 'countries', 'title_genres', 'title_countries',
'series_fetch_status', 'movie_sources', 'episode_sources'):
for statement in ('DELETE FROM ', 'DROP TABLE '):
with self.subTest(table=table, statement=statement):
self.assert_candidate_rejected(lambda db: db.execute(statement + table))
def test_new_movie_sources_cannot_mask_existing_title_source_loss(self):
def replace(db):
db.execute("INSERT INTO titles(id,type,raw_json) VALUES (66,'movie','{}')")
db.execute('UPDATE movie_sources SET title_id=66 WHERE title_id=1')
self.assert_candidate_rejected(replace)
def test_other_title_sources_cannot_mask_existing_title_source_loss(self):
def replace(db):
db.execute('UPDATE movie_sources SET title_id=3,source_index=1 WHERE title_id=1')
self.assert_candidate_rejected(replace)
def test_new_episode_sources_cannot_mask_existing_episode_source_loss(self):
def replace(db):
db.execute("INSERT INTO episodes(series_id,season_index,episode_index,id,raw_json) "
"VALUES (2,0,1,502,'{}')")
db.execute('UPDATE episode_sources SET episode_index=1')
self.assert_candidate_rejected(replace)
def test_null_id_episode_sources_are_preserved_by_index(self):
with closing(sqlite3.connect(self.database)) as db:
db.execute('UPDATE episodes SET id=NULL')
db.commit()
def replace(db):
db.execute("INSERT INTO episodes(series_id,season_index,episode_index,raw_json) "
"VALUES (2,0,1,'{}')")
db.execute('UPDATE episode_sources SET episode_index=1')
self.assert_candidate_rejected(replace)
def test_source_url_replacement_with_stable_ids_can_be_published(self):
replacement = 'https://media.test/replacement.mkv?token=new'
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
for table in ('movie_sources', 'episode_sources'):
raw = json.loads(db.execute(
'SELECT raw_json FROM ' + table + ' WHERE source_id=?',
(101 if table == 'movie_sources' else 201,)).fetchone()[0])
raw['url'] = replacement
db.execute('UPDATE ' + table + ' SET url=?,raw_json=? WHERE source_id=?',
(replacement, json.dumps(raw), raw['id']))
for table, title_id in (('titles', 1), ('episodes', 501)):
raw = json.loads(db.execute('SELECT raw_json FROM ' + table + ' WHERE id=?',
(title_id,)).fetchone()[0])
raw['sources'][0]['url'] = replacement
db.execute('UPDATE ' + table + ' SET raw_json=? WHERE id=?',
(json.dumps(raw), title_id))
season = json.loads(db.execute('SELECT raw_json FROM seasons').fetchone()[0])
season['episodes'][0]['sources'][0]['url'] = replacement
db.execute('UPDATE seasons SET raw_json=?', (json.dumps(season),))
db.commit()
return report
result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: None, health_fn=lambda _: None)
self.assertEqual(result['status'], 'success')
with closing(sqlite3.connect(self.database)) as db:
self.assertEqual(db.execute('SELECT source_id,url FROM movie_sources WHERE title_id=1').fetchone(),
(101, replacement))
self.assertEqual(db.execute('SELECT source_id,url FROM episode_sources').fetchone(),
(201, replacement))
def test_episode_sources_follow_stable_id_when_episode_index_changes(self):
def candidate(source, stage, upstream, **kwargs):
report = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
db.execute('UPDATE episodes SET episode_index=1 WHERE id=501')
db.execute('UPDATE episode_sources SET episode_index=1')
db.commit()
return report
result = self.api.run_update(self.database, self.state, object(), synchronize_fn=candidate,
restart_fn=lambda: None, health_fn=lambda _: None)
self.assertEqual(result['status'], 'success')
def test_incomplete_candidate_cannot_replace_the_catalogue(self):
before = self.digest()
def truncate(source, stage, upstream, **kwargs):
result = self.candidate(source, stage, upstream, **kwargs)
with closing(sqlite3.connect(stage)) as db:
db.execute('DELETE FROM titles WHERE id=65')
db.commit()
return result
with self.assertRaises(self.api.UpdateError):
self.api.run_update(self.database, self.state, object(), synchronize_fn=truncate,
restart_fn=lambda: self.fail('invalid candidate'), health_fn=lambda _: None)
self.assertEqual(self.digest(), before)
+38
View File
@@ -0,0 +1,38 @@
"""Operational boundaries of the installed updater schedule."""
import configparser
from pathlib import Path
import unittest
SERVER = Path(__file__).resolve().parents[1]
class SyncUnitTests(unittest.TestCase):
def unit(self, name):
config = configparser.ConfigParser(interpolation=None, strict=False)
config.optionxform = str
config.read(SERVER / name)
return config
def test_timer_uses_three_day_interval_and_boot_catch_up(self):
timer = self.unit('reelbox-sync.timer')
self.assertEqual(timer['Timer']['OnUnitActiveSec'], '3d')
self.assertEqual(timer['Timer']['OnBootSec'], '15min')
self.assertEqual(timer['Timer']['Unit'], 'reelbox-sync.service')
self.assertNotIn('OnCalendar', timer['Timer'])
self.assertEqual(timer['Install']['WantedBy'], 'timers.target')
def test_updater_is_bounded_and_separate_from_public_api(self):
service = self.unit('reelbox-sync.service')['Service']
self.assertEqual(service['Type'], 'oneshot')
self.assertEqual(service['TimeoutStartSec'], '8h')
self.assertEqual(service['RestartSec'], '1h')
self.assertEqual(service['ProtectSystem'], 'strict')
self.assertEqual(service['ProtectHome'], 'true')
self.assertEqual(service['EnvironmentFile'], '/etc/reelbox/sync.env')
self.assertEqual(service['UMask'], '0077')
self.assertIn('catalog_sync_runner.py', service['ExecStart'])
self.assertEqual(service['ReadWritePaths'], '/var/lib/reelbox-api /var/lib/reelbox-sync')
api = self.unit('reelbox-api.service')['Service']
self.assertEqual(api['IPAddressDeny'], 'any')
self.assertEqual(api['IPAddressAllow'], 'localhost')
self.assertNotIn('sync.env', api.get('EnvironmentFile', ''))