"""Offline regression tests; runnable via pytest or stdlib unittest.""" import asyncio import sys import types import unittest from pathlib import Path from unittest.mock import patch, AsyncMock sys.path.insert(0,str(Path(__file__).resolve().parents[1])) import trusted_links as tl from journal_lookup import get_index from repositories import hydrate_selection, hydrate_deposit_strategy class LinkTests(unittest.IsolatedAsyncioTestCase): def setUp(self): tl._cache.clear(); tl._metadata_cache.clear(); tl._locks.clear(); tl._gate=asyncio.Semaphore(4) self.rec=get_index().lookup_exact(title='Nature') self.j={'name':self.rec.title,'scopus_id':self.rec.scopus_id,'issn':self.rec.issn_e,'ranking':{}} self.oa={'issn_l':self.rec.issn_e,'issns':[self.rec.issn_print], 'homepage_url':'https://www.nature.com/nature/', 'openalex_id':'https://openalex.org/S137773608','source_updated_at':'2025-12-01','retrieved_at':'2026-09-20'} self.mock_oa=types.SimpleNamespace(enrich_journal=AsyncMock(return_value=self.oa)) self.modules=patch.dict(sys.modules,{'openalex':self.mock_oa});self.modules.start() def tearDown(self): self.modules.stop() def test_unsafe_urls(self): for url in ['javascript:alert(1)','http://nature.com','https://localhost/','https://127.0.0.1/','https://169.254.169.254/','https://[::1]/','https://user:pass@nature.com/','https://nature.com:444/','https://nature.com\\@evil.com/','https://nature.com/\n']: self.assertIsNone(tl.safe_url(url),url) self.assertFalse(tl.allowed('https://nature.com.evil.com',['nature.com'])) self.assertTrue(tl.allowed('https://www.nature.com/nature/',['nature.com'])) def test_identity_rejects_conflicting_id_issn_or_title(self): self.assertIsNotNone(tl.canonical(self.j)) self.assertIsNone(tl.canonical({**self.j,'issn':'1234-5678'})) self.assertIsNone(tl.canonical({**self.j,'name':'Not Nature'})) self.assertIsNone(tl.canonical({'name':'Nature made up journal'})) self.assertIsNone(tl.canonical({'scopus_id':'invalid','issn':self.rec.issn_e})) async def test_homepage_metadata_dates_and_no_automatic_discovery(self): with patch.object(tl,'fetch_page',side_effect=AssertionError('must not fetch')): links=await tl.journal_links(self.j) self.assertEqual(links['homepage']['status'],'metadata-only') self.assertIsNone(links['homepage']['reviewed_at']) self.assertEqual(links['homepage']['source_updated_at'],'2025-12-01') self.assertIsNone(links['guidelines']['url']) async def test_wrong_openalex_identity_rejected(self): self.mock_oa.enrich_journal.return_value={**self.oa,'issn_l':'12345678','issns':[]} self.assertIsNone((await tl.journal_links(self.j))['homepage']['url']) async def test_category_quartiles_and_identifiers(self): data=tl.metric_data() record=next(r for r in data.values() if len({f['quartile'] for f in r['fields']})>1) self.assertGreater(len({f['quartile'] for f in record['fields']}),1) self.assertEqual(record['year'],'2025') await tl.journal_links(self.j) self.assertIn('tip=sid',self.j['verify_links']['scimago']) self.assertEqual(self.j['verify_links']['scopus'],'https://www.scopus.com/sourceid/'+self.rec.scopus_id) for f in self.j['quartiles_by_field']: self.assertEqual(f['profile_url'],self.j['verify_links']['scimago']) if f['category_url']: self.assertIn('category='+f['code'],f['category_url']) async def test_missing_canonical_data(self): j={'name':'Imaginary Journal','ranking':{}} links=await tl.journal_links(j) self.assertFalse(links['can_discover']); self.assertIsNone(links['homepage']['url']) self.mock_oa.enrich_journal.assert_not_called() async def test_success_cache_and_actual_relative_link(self): home='

Nature

Author guidelines' guide='Nature — author instructions' with patch.object(tl,'fetch_page',side_effect=[(self.oa['homepage_url'],home),('https://www.nature.com/nature/for-authors',guide)]) as fetch: first=await tl.discover(self.j); second=await tl.discover(self.j) self.assertEqual(fetch.call_count,2) self.assertEqual(first['guidelines'],second['guidelines']) self.assertEqual(first['guidelines']['scope'],'journal-specific') self.assertEqual(first['guidelines']['status'],'checked') self.assertTrue(first['guidelines']['reviewed_at']) async def test_general_guidance_label(self): with patch.object(tl,'fetch_page',side_effect=[(self.oa['homepage_url'],'Nature Guide for authors'),('https://www.nature.com/authors','Publisher author services')]): links=await tl.discover(self.j) self.assertEqual(links['guidelines']['scope'],'general-publisher') async def test_failed_request_negative_cache_preserves_homepage(self): with patch.object(tl,'fetch_page',side_effect=TimeoutError) as fetch: first=await tl.discover(self.j); second=await tl.discover(self.j) self.assertEqual(fetch.call_count,1) self.assertEqual(first,second) self.assertEqual(first['homepage']['url'],self.oa['homepage_url']) self.assertIsNone(first['guidelines']['reviewed_at']) self.assertEqual(first['guidelines']['status'],'unavailable') async def test_page_identity_required(self): with patch.object(tl,'fetch_page',return_value=(self.oa['homepage_url'],'

Another Journal

Guide for authors')) as fetch: links=await tl.discover(self.j) self.assertIsNone(links['guidelines']['url']); self.assertEqual(fetch.call_count,1) async def test_unsafe_discovered_link_rejected(self): with patch.object(tl,'fetch_page',return_value=(self.oa['homepage_url'],'Nature Guide for authors')) as fetch: links=await tl.discover(self.j) self.assertIsNone(links['guidelines']['url']); self.assertEqual(fetch.call_count,1) def test_private_dns_and_redirects(self): with patch.object(tl.socket,'getaddrinfo',return_value=[(2,1,6,'',('127.0.0.1',443))]), patch.object(tl,'PinnedHTTPS') as connect: with self.assertRaises(ValueError): tl.fetch_page('https://nature.com',['nature.com']) connect.assert_not_called() fake=types.SimpleNamespace(status=302,getheader=lambda k,*args:'https://evil.com/' if k=='Location' else 'text/html') conn=types.SimpleNamespace(request=lambda *a,**k:None,getresponse=lambda:fake,close=lambda:None) with patch.object(tl.socket,'getaddrinfo',return_value=[(2,1,6,'',('8.8.8.8',443))]),patch.object(tl,'PinnedHTTPS',return_value=conn) as connect: with self.assertRaises(ValueError):tl.fetch_page('https://nature.com',['nature.com']) self.assertEqual(connect.call_count,1) def test_curated_records_preserve_review_dates(self): entry={'issns':[self.rec.issn_e],'url':'https://nature.com/authors','source':'https://nature.com/nature','reviewed_at':'2025-10-01','scope':'general-publisher'} with patch.object(tl,'curated_records',return_value={self.rec.scopus_id:entry}): self.assertEqual(tl.curated_guidance(self.rec)['reviewed_at'],'2025-10-01') self.assertEqual(tl.curated_guidance(self.rec)['status'],'curated') with patch.object(tl,'curated_records',return_value={self.rec.scopus_id:{**entry,'issns':['12345678']}}): self.assertIsNone(tl.curated_guidance(self.rec)) async def test_missing_metadata_is_cached(self): self.mock_oa.enrich_journal.return_value=None await tl.discover(self.j); await tl.discover(self.j) self.assertEqual(self.mock_oa.enrich_journal.await_count,1) async def test_concurrent_discovery_singleflight(self): import time def failed(*args): time.sleep(0.02) raise TimeoutError() with patch.object(tl,'fetch_page',side_effect=failed) as fetch: results=await asyncio.gather(tl.discover(dict(self.j)),tl.discover(dict(self.j))) self.assertEqual(fetch.call_count,1) self.assertEqual(results[0]['guidelines'],results[1]['guidelines']) def test_expired_cache_and_size_limit(self): tl.cache_put('a',tl.unavailable()) with patch.object(tl.time,'monotonic',return_value=10**15): self.assertIsNone(tl.cached('a')) for i in range(600): tl.cache_put(str(i),tl.unavailable()) self.assertEqual(len(tl._cache),512) def test_repository_choices_are_curated(self): repos=hydrate_selection([{'id':'zenodo','url':'https://evil.com'}]) strategy=hydrate_deposit_strategy({'primary_repository_id':'zenodo','primary_url':'https://evil.com'},repos) self.assertEqual(strategy['primary_url'],'https://zenodo.org') self.assertIsNone(strategy['secondary_url']) if __name__=='__main__': unittest.main()