446 lines
22 KiB
Python
446 lines
22 KiB
Python
import io
|
|
import os
|
|
import re
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
import warnings
|
|
import zlib
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
import main
|
|
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
|
|
from image_optimizer import create_optimized_candidate
|
|
from image_safety import safe_image_open
|
|
|
|
|
|
class FakeCursor:
|
|
def __init__(self, conn):
|
|
self.conn = conn
|
|
self.results = []
|
|
self.index = 0
|
|
self.rowcount = 0
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
return False
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def execute(self, sql, params=None):
|
|
params = params or ()
|
|
self.index = 0
|
|
self.rowcount = 0
|
|
self.results = []
|
|
if sql == 'SHOW TABLES':
|
|
self.results = [{'Tables_in_test': table} for table in self.conn.schemas]
|
|
elif sql.startswith('SHOW COLUMNS FROM'):
|
|
table = re.search(r'`([^`]+)`', sql).group(1)
|
|
self.results = [{'Field': name, 'Type': data_type} for name, data_type in self.conn.schemas[table].items()]
|
|
elif sql.startswith('SELECT fid, filepath, filesize FROM'):
|
|
self.results = [dict(row) for row in self.conn.files]
|
|
elif sql.startswith('SELECT filesize, filepath FROM'):
|
|
self.results = [dict(row) for row in self.conn.files if row['fid'] == params[0]]
|
|
elif sql.startswith('UPDATE `files` SET filesize'):
|
|
if self.conn.fail_updates:
|
|
self.conn.fail_updates -= 1
|
|
raise RuntimeError('Simulated DB error')
|
|
size, fid, path = params
|
|
for row in self.conn.files:
|
|
if row['fid'] == fid and row['filepath'] == path:
|
|
if row['filesize'] != size:
|
|
row['filesize'] = size
|
|
self.rowcount = 1
|
|
break
|
|
return
|
|
elif 'FROM `upload` t JOIN' in sql or 'FROM `content_field_scan` t JOIN' in sql:
|
|
table = 'upload' if 'FROM `upload`' in sql else 'content_field_scan'
|
|
field = 'fid' if table == 'upload' else 'field_scan_fid'
|
|
for entry in self.conn.relations.get(table, []):
|
|
node = self.conn.nodes.get(entry['nid'])
|
|
if not node or (entry.get('vid') and node['vid'] != entry['vid']):
|
|
continue
|
|
if entry[field] in params:
|
|
self.results.append({
|
|
'nid': node['nid'], 'title': node['title'],
|
|
'node_type': node['type'], 'node_status': node['status'],
|
|
'fid': entry[field], 'cid': entry.get('cid'),
|
|
})
|
|
elif 'FROM `comment_upload` t' in sql:
|
|
for entry in self.conn.relations.get('comment_upload', []):
|
|
comment = next(c for c in self.conn.comments if c['cid'] == entry['cid'])
|
|
node = self.conn.nodes[comment['nid']]
|
|
if entry['fid'] in params:
|
|
self.results.append({'nid': node['nid'], 'title': node['title'],
|
|
'node_type': node['type'], 'node_status': node['status'],
|
|
'comment_status': comment['status'],
|
|
'cid': comment['cid'], 'fid': entry['fid']})
|
|
elif 'r.body AS body' in sql:
|
|
for node in self.conn.nodes.values():
|
|
for revision in self.conn.revisions:
|
|
if revision['nid'] == node['nid'] and revision['vid'] == node['vid']:
|
|
self.results.append({
|
|
'nid': node['nid'], 'title': node['title'], 'node_type': node['type'],
|
|
'node_status': node['status'], 'body': revision['body'],
|
|
})
|
|
elif 'c.cid, c.status AS comment_status' in sql:
|
|
for comment in self.conn.comments:
|
|
node = self.conn.nodes[comment['nid']]
|
|
self.results.append({
|
|
'nid': node['nid'], 'title': node['title'], 'node_type': node['type'],
|
|
'node_status': node['status'], 'cid': comment['cid'],
|
|
'comment_status': comment['status'], 'comment': comment['comment'],
|
|
})
|
|
else:
|
|
raise AssertionError(f'Unexpected fake SQL: {sql} / {params}')
|
|
self.rowcount = len(self.results)
|
|
|
|
def fetchall(self):
|
|
return list(self.results)
|
|
|
|
def fetchone(self):
|
|
return self.results[0] if self.results else None
|
|
|
|
def fetchmany(self, count):
|
|
result = self.results[self.index:self.index + count]
|
|
self.index += len(result)
|
|
return result
|
|
|
|
|
|
class FakeConnection:
|
|
def __init__(self):
|
|
self.schemas = {
|
|
'files': {'fid': 'int', 'filepath': 'varchar(255)', 'filesize': 'int'},
|
|
'node': {'nid': 'int', 'vid': 'int', 'title': 'varchar(255)', 'type': 'varchar(32)', 'status': 'tinyint'},
|
|
'node_revisions': {'nid': 'int', 'vid': 'int', 'body': 'longtext'},
|
|
'upload': {'fid': 'int', 'nid': 'int', 'vid': 'int'},
|
|
'content_field_scan': {'field_scan_fid': 'int', 'nid': 'int', 'vid': 'int'},
|
|
'comments': {'cid': 'int', 'nid': 'int', 'comment': 'longtext', 'status': 'tinyint'},
|
|
'comment_upload': {'cid': 'int', 'fid': 'int'},
|
|
}
|
|
self.nodes = {
|
|
11: {'nid': 11, 'vid': 101, 'title': 'Stary skan mapy', 'type': 'forum', 'status': 1},
|
|
12: {'nid': 12, 'vid': 102, 'title': 'Zdjecia z wakacji', 'type': 'story', 'status': 1},
|
|
13: {'nid': 13, 'vid': 103, 'title': 'Odpowiedz na forum', 'type': 'forum', 'status': 0},
|
|
}
|
|
self.relations = {
|
|
'upload': [{'nid': 11, 'vid': 101, 'fid': 7}, {'nid': 13, 'vid': 999, 'fid': 7}],
|
|
'content_field_scan': [{'nid': 12, 'vid': 102, 'field_scan_fid': 8}],
|
|
'comment_upload': [{'cid': 55, 'fid': 8}],
|
|
}
|
|
self.revisions = [
|
|
{'nid': 11, 'vid': 101, 'body': '<img src="/sites/default/files/scans/mapa.jpg">'},
|
|
{'nid': 12, 'vid': 102, 'body': '<p>Wspomnienia.</p>'},
|
|
{'nid': 13, 'vid': 103, 'body': 'Zobacz mapa.jpg tutaj.'},
|
|
{'nid': 13, 'vid': 999, 'body': 'NIEAKTUALNE /sites/default/files/scans/mapa.jpg'},
|
|
]
|
|
self.comments = [
|
|
{'cid': 55, 'nid': 12, 'comment': 'Znalazlem /sites/default/files/scans/mapa.jpg', 'status': 1},
|
|
]
|
|
self.files = []
|
|
self.fail_updates = 0
|
|
self.commits = 0
|
|
self.rollbacks = 0
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
return False
|
|
|
|
def cursor(self, *_):
|
|
return FakeCursor(self)
|
|
|
|
def commit(self):
|
|
self.commits += 1
|
|
|
|
def rollback(self):
|
|
self.rollbacks += 1
|
|
|
|
|
|
class TestLinks(unittest.TestCase):
|
|
def test_links(self):
|
|
self.assertEqual(node_url('https://example.org/forum/', 42), 'https://example.org/forum/node/42')
|
|
self.assertEqual(node_url('https://example.org/forum', 42, 66), 'https://example.org/forum/node/42#comment-66')
|
|
self.assertEqual(node_url('https://example.org/forum', 42, clean_urls=False), 'https://example.org/forum/?q=node/42')
|
|
self.assertIn('skan%20mapa.jpg', search_url('https://example.org', 'skan mapa.jpg'))
|
|
self.assertEqual(node_url('https://example.org', 42, 66, comment_permalinks=True),
|
|
'https://example.org/comment/66#comment-66')
|
|
|
|
def test_matcher_precision(self):
|
|
matcher = ReferenceMatcher([
|
|
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'},
|
|
])
|
|
self.assertEqual(matcher.match('<img src="/sites/default/files/scans/mapa.jpg">')[7], 'sciezka')
|
|
self.assertEqual(matcher.match('mapa.jpg')[7], 'nazwa')
|
|
self.assertFalse(matcher.match('supermapa.jpg'))
|
|
self.assertFalse(ReferenceMatcher([
|
|
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}
|
|
], basename_match=False).match('mapa.jpg'))
|
|
|
|
def test_schema_references_current_only_and_comment_status(self):
|
|
conn = FakeConnection()
|
|
lookup = DrupalReferenceLookup(conn, base_url='https://forum.example.org')
|
|
files = [
|
|
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'},
|
|
{'fid': 8, 'db_filepath': 'sites/default/files/inne.jpg', 'rel_path': 'inne.jpg'},
|
|
]
|
|
fake_pymysql = types.SimpleNamespace(cursors=types.SimpleNamespace(SSDictCursor=object))
|
|
with patch.dict(sys.modules, {'pymysql': fake_pymysql}):
|
|
with redirect_stdout(io.StringIO()):
|
|
result = lookup.lookup(files)
|
|
self.assertEqual({x['nid'] for x in result[7]}, {11, 12, 13})
|
|
self.assertEqual({x['nid'] for x in result[8]}, {12})
|
|
attached_to_comment = [x for x in result[8] if x['cid'] == 55][0]
|
|
self.assertIn('comment_upload.fid', attached_to_comment['sources'])
|
|
direct = [x for x in result[7] if x['nid'] == 11][0]
|
|
self.assertIn('upload.fid', direct['sources'])
|
|
self.assertIn('tresc wpisu (sciezka)', direct['sources'])
|
|
self.assertEqual(direct['confidence'], 'pewne')
|
|
self.assertTrue(direct['url'].endswith('/node/11'))
|
|
comment = [x for x in result[7] if x['cid'] == 55][0]
|
|
self.assertFalse(comment['comment_published'])
|
|
self.assertEqual(comment['url'], 'https://forum.example.org/node/12#comment-55')
|
|
possible = [x for x in result[7] if x['nid'] == 13][0]
|
|
self.assertFalse(possible['published'])
|
|
self.assertEqual(possible['confidence'], 'mozliwe')
|
|
self.assertNotIn('upload.fid', possible['sources']) # nieaktualna rewizja
|
|
|
|
|
|
class TestFiles(unittest.TestCase):
|
|
def setUp(self):
|
|
self.workdir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.workdir.cleanup)
|
|
self.root = Path(self.workdir.name)
|
|
self.site_root = self.root / 'drupal'
|
|
self.folder = self.site_root / 'sites' / 'default' / 'files'
|
|
(self.folder / 'scans').mkdir(parents=True)
|
|
self.image = self.folder / 'scans' / 'mapa.jpg'
|
|
src = Image.effect_noise((950, 1250), 115).convert('RGB')
|
|
src.save(self.image, 'JPEG', quality=99)
|
|
self.conn = FakeConnection()
|
|
self.conn.files = [{
|
|
'fid': 7, 'filepath': 'sites/default/files/scans/mapa.jpg', 'filesize': 12345,
|
|
}]
|
|
self.cfg = types.SimpleNamespace(
|
|
FILES_BASE_PATH=str(self.folder), PUBLIC_FILES_PATH='sites/default/files',
|
|
DRUPAL_ROOT=str(self.site_root), DRUPAL_BASE_URL='https://forum.example.org',
|
|
)
|
|
|
|
def args(self, *overrides):
|
|
return main.build_parser().parse_args(list(overrides))
|
|
|
|
def test_resolve_prevent_traversal(self):
|
|
valid, rel = main.resolve_filepath('sites/default/files/scans/mapa.jpg', str(self.folder))
|
|
self.assertEqual(valid, str(self.image))
|
|
self.assertEqual(rel, 'scans/mapa.jpg')
|
|
bad, _ = main.resolve_filepath('../../etc/shadow', str(self.folder))
|
|
self.assertIsNone(bad)
|
|
outside, _ = main.resolve_filepath('/etc/passwd', str(self.folder))
|
|
self.assertIsNone(outside)
|
|
|
|
def test_backup_must_not_be_in_webroot(self):
|
|
with self.assertRaises(ValueError):
|
|
main._safe_backup_dir(str(self.site_root / 'backup'),
|
|
str(self.folder), str(self.site_root))
|
|
|
|
def test_dry_run_no_changes_and_preview(self):
|
|
args = self.args('--optimize', '--update-db', '--dry-run', '--max-width', '600',
|
|
'--max-height', '800', '--max-file-mb', '0.5')
|
|
images, missing, prefix = main.collect_images(self.conn, self.cfg, args)
|
|
self.assertFalse(missing)
|
|
before = self.image.read_bytes()
|
|
with redirect_stdout(io.StringIO()):
|
|
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
|
|
self.assertEqual(self.image.read_bytes(), before)
|
|
self.assertEqual(self.conn.files[0]['filesize'], 12345)
|
|
self.assertEqual(stats['optimized'], 0)
|
|
self.assertEqual(stats['planned'], 1)
|
|
self.assertEqual(images[0]['optimize_status'], 'planned')
|
|
self.assertLess(images[0]['after_size'], images[0]['before_size'])
|
|
|
|
def test_apply_backup_db_and_restore_on_sql_failure(self):
|
|
args = self.args('--optimize', '--apply', '--max-width', '600', '--max-height', '800',
|
|
'--max-file-mb', '0.5', '--backup-dir', str(self.root / 'backups'))
|
|
before = self.image.read_bytes()
|
|
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
|
|
with redirect_stdout(io.StringIO()):
|
|
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
|
|
self.assertEqual(stats['optimized'], 1)
|
|
self.assertEqual(stats['errors'], 0)
|
|
self.assertLess(self.image.stat().st_size, len(before))
|
|
self.assertEqual(self.conn.files[0]['filesize'], self.image.stat().st_size)
|
|
self.assertEqual(Path(images[0]['backup_file']).read_bytes(), before)
|
|
with Image.open(self.image) as img:
|
|
self.assertLessEqual(img.width, 600)
|
|
self.assertLessEqual(img.height, 800)
|
|
|
|
# Ponownie uruchomienie z bladem w DB: plik i metadata wracaja.
|
|
self.conn.fail_updates = 1
|
|
self.conn.files[0]['filesize'] = 12345
|
|
second_before = self.image.read_bytes()
|
|
args2 = self.args('--optimize', '--apply', '--max-width', '400', '--max-height', '550',
|
|
'--max-file-mb', '0.3', '--backup-dir', str(self.root / 'backups'))
|
|
images2, _, prefix = main.collect_images(self.conn, self.cfg, args2)
|
|
with redirect_stdout(io.StringIO()):
|
|
stats2 = main.process_changes(self.conn, self.cfg, args2, images2, prefix)
|
|
self.assertEqual(stats2['errors'], 1)
|
|
self.assertEqual(self.image.read_bytes(), second_before)
|
|
self.assertEqual(self.conn.files[0]['filesize'], 12345)
|
|
|
|
def test_symlink_is_not_modified(self):
|
|
link = self.folder / 'scans' / 'alias.jpg'
|
|
link.symlink_to(self.image)
|
|
self.conn.files[0]['filepath'] = 'sites/default/files/scans/alias.jpg'
|
|
args = self.args('--optimize', '--dry-run', '--max-width', '500')
|
|
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
|
|
self.assertEqual(images[0]['path'], str(link))
|
|
with redirect_stdout(io.StringIO()):
|
|
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
|
|
self.assertEqual(images[0]['optimize_status'], 'skip')
|
|
self.assertEqual(stats['planned'], 0)
|
|
self.assertTrue(link.is_symlink())
|
|
|
|
def test_exif_orientation_after_optimization(self):
|
|
original = self.folder / 'scan-orientation.jpg'
|
|
img = Image.new('RGB', (240, 120), 'orange')
|
|
exif = Image.Exif()
|
|
exif[274] = 6
|
|
img.save(original, 'JPEG', quality=100, exif=exif)
|
|
optimized = create_optimized_candidate(str(original), max_width=150, max_height=200,
|
|
max_bytes=100, min_gain_percent=0)
|
|
self.assertEqual(optimized['status'], 'ready', optimized['reason'])
|
|
try:
|
|
with Image.open(optimized['temp_path']) as out:
|
|
self.assertEqual(out.size, (100, 200))
|
|
self.assertNotEqual(out.getexif().get(274), 6)
|
|
finally:
|
|
os.unlink(optimized['temp_path'])
|
|
|
|
def test_png_with_alpha_preserved(self):
|
|
filename = self.folder / 'scans' / 'alpha.png'
|
|
img = Image.new('RGBA', (300, 900), (10, 100, 50, 130))
|
|
img.save(filename)
|
|
result = create_optimized_candidate(str(filename), max_width=200, max_height=500,
|
|
max_bytes=200, min_gain_percent=0)
|
|
self.assertEqual(result['status'], 'ready', result['reason'])
|
|
try:
|
|
with Image.open(result['temp_path']) as output:
|
|
self.assertEqual(output.mode, 'RGBA')
|
|
self.assertEqual(output.size, (167, 500))
|
|
self.assertEqual(output.getpixel((1, 1))[3], 130)
|
|
finally:
|
|
os.unlink(result['temp_path'])
|
|
|
|
def test_no_optimization_for_small_or_gif(self):
|
|
tiny = self.folder / 'tiny.png'
|
|
Image.new('RGB', (30, 30), 'red').save(tiny)
|
|
self.assertEqual(create_optimized_candidate(str(tiny))['status'], 'skip')
|
|
gif = self.folder / 'animated.gif'
|
|
Image.new('RGB', (300, 500), 'red').save(gif)
|
|
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip')
|
|
|
|
def test_image_limit_replaces_pillow_warning_without_disabling_safety(self):
|
|
# Symulujemy niski prog Pillow: nasz konfigurowalny limit nie
|
|
# powinien powodowac ostrzezen ani wychodzic poza scope.
|
|
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
|
|
with warnings.catch_warnings(record=True) as emitted:
|
|
warnings.simplefilter('always', Image.DecompressionBombWarning)
|
|
self.assertEqual(main.file_resolution(str(self.image), max_pixels=2_000_000),
|
|
(950, 1250))
|
|
result = create_optimized_candidate(
|
|
str(self.image), max_width=550, max_height=700,
|
|
max_bytes=100_000, max_pixels=2_000_000,
|
|
)
|
|
self.assertEqual(result['status'], 'ready', result['reason'])
|
|
if result['temp_path']:
|
|
os.unlink(result['temp_path'])
|
|
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
|
|
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
|
|
for item in emitted))
|
|
|
|
def test_giant_image_header_is_readable_but_skipped_above_limit(self):
|
|
# Header PNG deklaruje tyle pikseli co trzy problematyczne pliki
|
|
# z logu: 14400 * 13872 = 199 756 800. Nie generujemy tych pikseli.
|
|
fake = self.folder / 'scans' / '200mp.png'
|
|
with io.BytesIO() as buffer:
|
|
Image.new('RGB', (2, 2), 'red').save(buffer, format='PNG')
|
|
content = bytearray(buffer.getvalue())
|
|
content[16:24] = struct.pack('>II', 14400, 13872)
|
|
content[29:33] = struct.pack('>I', zlib.crc32(content[12:29]))
|
|
fake.write_bytes(content)
|
|
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
|
|
with warnings.catch_warnings(record=True) as emitted:
|
|
warnings.simplefilter('always', Image.DecompressionBombWarning)
|
|
self.assertEqual(main.file_resolution(str(fake), max_pixels=220_000_000),
|
|
(14400, 13872))
|
|
with safe_image_open(str(fake), max_pixels=220_000_000) as source:
|
|
self.assertEqual(source.width * source.height, 199_756_800)
|
|
# Obrazu nie nalezy dekodowac bez limitu RAM.
|
|
rejected = create_optimized_candidate(str(fake), max_pixels=150_000_000)
|
|
self.assertEqual(rejected['status'], 'skip')
|
|
self.assertIn('--max-image-mpixels=150', rejected['reason'])
|
|
# Przypadek > 2 * limitu Pillow rowniez jest SKIP, nie ERROR.
|
|
rejected_strict = create_optimized_candidate(str(fake), max_pixels=60_000_000)
|
|
self.assertEqual(rejected_strict['status'], 'skip')
|
|
self.assertIn('--max-image-mpixels=60', rejected_strict['reason'])
|
|
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
|
|
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
|
|
for item in emitted))
|
|
|
|
def test_oversize_images_are_reported_as_skipped_not_errors(self):
|
|
args = self.args('--optimize', '--dry-run', '--max-image-mpixels', '0.5',
|
|
'--max-width', '500')
|
|
original = self.image.read_bytes()
|
|
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
|
|
with redirect_stdout(io.StringIO()) as output:
|
|
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
|
|
self.assertEqual(stats['errors'], 0)
|
|
self.assertEqual(stats['planned'], 0)
|
|
self.assertEqual(images[0]['optimize_status'], 'skip')
|
|
self.assertIn('[SKIP-LIMIT]', output.getvalue())
|
|
self.assertEqual(self.image.read_bytes(), original)
|
|
self.assertEqual(self.conn.files[0]['filesize'], 12345)
|
|
|
|
def test_cli_end_to_end_read_only_generates_reports(self):
|
|
csv_path = self.root / 'end.csv'
|
|
json_path = self.root / 'end.json'
|
|
with patch.object(main, 'load_config', return_value=self.cfg), \
|
|
patch.object(main, 'get_connection', return_value=self.conn), \
|
|
redirect_stdout(io.StringIO()):
|
|
status = main.main(['--find-usage', '--no-text-scan', '--top', '10',
|
|
'--report-csv', str(csv_path),
|
|
'--report-json', str(json_path)])
|
|
self.assertEqual(status, 0)
|
|
self.assertIn('https://forum.example.org/node/11', csv_path.read_text(encoding='utf-8-sig'))
|
|
self.assertIn('"images"', json_path.read_text(encoding='utf-8'))
|
|
self.assertEqual(self.conn.files[0]['filesize'], 12345)
|
|
|
|
def test_csv_has_direct_link(self):
|
|
args = self.args('--find-usage', '--site-url', 'https://forum.example.org', '--report-csv', str(self.root / 'out.csv'))
|
|
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
|
|
images[0]['references'] = [
|
|
{'nid': 11, 'cid': None, 'title': 'Przypiety temat', 'node_type': 'forum',
|
|
'published': True, 'comment_published': None, 'sources': ['upload.fid'],
|
|
'confidence': 'pewne', 'url': 'https://forum.example.org/node/11',
|
|
'edit_url': 'https://forum.example.org/node/11/edit'}
|
|
]
|
|
main.write_csv(args.report_csv, images, self.cfg, args)
|
|
text = Path(args.report_csv).read_text(encoding='utf-8-sig')
|
|
self.assertIn('https://forum.example.org/node/11', text)
|
|
self.assertIn('Przypiety temat', text)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|