[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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'])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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', ''))
|
||||
Reference in new issue
Block a user