Spaces:
Sleeping
Sleeping
Upload 11 files
Browse files- tests/conftest.py +60 -60
- tests/test_frontend_links.cjs +44 -0
- tests/test_frontend_release_a.py +51 -51
- tests/test_journal_policy_guard.py +44 -44
- tests/test_khazna_reload_auth.py +62 -62
- tests/test_oaworks_tri_state.py +65 -65
- tests/test_repository_schema.py +62 -62
- tests/test_subject_quartiles.py +38 -38
- tests/test_trusted_links.py +146 -0
- tests/test_trusted_links_api.py +23 -0
tests/conftest.py
CHANGED
|
@@ -1,60 +1,60 @@
|
|
| 1 |
-
"""Test bootstrap for the ResearchBee Release A regression suite."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import sys
|
| 5 |
-
import types
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
|
| 8 |
-
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
-
if str(ROOT) not in sys.path:
|
| 10 |
-
sys.path.insert(0, str(ROOT))
|
| 11 |
-
|
| 12 |
-
# The production image installs tenacity from requirements.txt. The execution
|
| 13 |
-
# environment used for static/offline review may not have it, so provide a tiny
|
| 14 |
-
# decorator-compatible fallback only when the real package is unavailable.
|
| 15 |
-
try: # pragma: no cover - exercised only in minimal review environments
|
| 16 |
-
import tenacity # noqa: F401
|
| 17 |
-
except ModuleNotFoundError: # pragma: no cover
|
| 18 |
-
module = types.ModuleType("tenacity")
|
| 19 |
-
|
| 20 |
-
def retry(*_args, **_kwargs):
|
| 21 |
-
def decorator(func):
|
| 22 |
-
return func
|
| 23 |
-
return decorator
|
| 24 |
-
|
| 25 |
-
def option(*_args, **_kwargs):
|
| 26 |
-
return object()
|
| 27 |
-
|
| 28 |
-
module.retry = retry
|
| 29 |
-
module.stop_after_attempt = option
|
| 30 |
-
module.wait_exponential = option
|
| 31 |
-
module.retry_if_exception_type = option
|
| 32 |
-
module.before_sleep_log = option
|
| 33 |
-
sys.modules["tenacity"] = module
|
| 34 |
-
|
| 35 |
-
# Same offline-review fallback for the OpenAI SDK. Production installs the real
|
| 36 |
-
# package from requirements.txt; these tests never make a model call.
|
| 37 |
-
try: # pragma: no cover
|
| 38 |
-
from openai import AsyncOpenAI # noqa: F401
|
| 39 |
-
except ModuleNotFoundError: # pragma: no cover
|
| 40 |
-
module = types.ModuleType("openai")
|
| 41 |
-
|
| 42 |
-
class OpenAIError(Exception):
|
| 43 |
-
pass
|
| 44 |
-
|
| 45 |
-
class RateLimitError(OpenAIError):
|
| 46 |
-
pass
|
| 47 |
-
|
| 48 |
-
class APIStatusError(OpenAIError):
|
| 49 |
-
def __init__(self, *args, status_code=500, **kwargs):
|
| 50 |
-
super().__init__(*args)
|
| 51 |
-
self.status_code = status_code
|
| 52 |
-
|
| 53 |
-
class AsyncOpenAI:
|
| 54 |
-
def __init__(self, *args, **kwargs):
|
| 55 |
-
self.chat = types.SimpleNamespace()
|
| 56 |
-
|
| 57 |
-
module.AsyncOpenAI = AsyncOpenAI
|
| 58 |
-
module.RateLimitError = RateLimitError
|
| 59 |
-
module.APIStatusError = APIStatusError
|
| 60 |
-
sys.modules["openai"] = module
|
|
|
|
| 1 |
+
"""Test bootstrap for the ResearchBee Release A regression suite."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
import types
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
+
if str(ROOT) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(ROOT))
|
| 11 |
+
|
| 12 |
+
# The production image installs tenacity from requirements.txt. The execution
|
| 13 |
+
# environment used for static/offline review may not have it, so provide a tiny
|
| 14 |
+
# decorator-compatible fallback only when the real package is unavailable.
|
| 15 |
+
try: # pragma: no cover - exercised only in minimal review environments
|
| 16 |
+
import tenacity # noqa: F401
|
| 17 |
+
except ModuleNotFoundError: # pragma: no cover
|
| 18 |
+
module = types.ModuleType("tenacity")
|
| 19 |
+
|
| 20 |
+
def retry(*_args, **_kwargs):
|
| 21 |
+
def decorator(func):
|
| 22 |
+
return func
|
| 23 |
+
return decorator
|
| 24 |
+
|
| 25 |
+
def option(*_args, **_kwargs):
|
| 26 |
+
return object()
|
| 27 |
+
|
| 28 |
+
module.retry = retry
|
| 29 |
+
module.stop_after_attempt = option
|
| 30 |
+
module.wait_exponential = option
|
| 31 |
+
module.retry_if_exception_type = option
|
| 32 |
+
module.before_sleep_log = option
|
| 33 |
+
sys.modules["tenacity"] = module
|
| 34 |
+
|
| 35 |
+
# Same offline-review fallback for the OpenAI SDK. Production installs the real
|
| 36 |
+
# package from requirements.txt; these tests never make a model call.
|
| 37 |
+
try: # pragma: no cover
|
| 38 |
+
from openai import AsyncOpenAI # noqa: F401
|
| 39 |
+
except ModuleNotFoundError: # pragma: no cover
|
| 40 |
+
module = types.ModuleType("openai")
|
| 41 |
+
|
| 42 |
+
class OpenAIError(Exception):
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
class RateLimitError(OpenAIError):
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
class APIStatusError(OpenAIError):
|
| 49 |
+
def __init__(self, *args, status_code=500, **kwargs):
|
| 50 |
+
super().__init__(*args)
|
| 51 |
+
self.status_code = status_code
|
| 52 |
+
|
| 53 |
+
class AsyncOpenAI:
|
| 54 |
+
def __init__(self, *args, **kwargs):
|
| 55 |
+
self.chat = types.SimpleNamespace()
|
| 56 |
+
|
| 57 |
+
module.AsyncOpenAI = AsyncOpenAI
|
| 58 |
+
module.RateLimitError = RateLimitError
|
| 59 |
+
module.APIStatusError = APIStatusError
|
| 60 |
+
sys.modules["openai"] = module
|
tests/test_frontend_links.cjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Node-only behavioral tests against the actual embedded functions.
|
| 2 |
+
Does not replace a real-browser visual test. */
|
| 3 |
+
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict'),path=require('node:path');
|
| 4 |
+
const html=fs.readFileSync(path.join(__dirname,'../ResearchBee_LibGuides.html'),'utf8');
|
| 5 |
+
function segment(start,end){return html.slice(html.indexOf(start),html.indexOf(end,html.indexOf(start)));}
|
| 6 |
+
const context={RB_API_BASE:"https://example.org",console,URL,AbortController,setTimeout,clearTimeout,Map,Set,document:{createElement:()=>({textContent:''})},RB_DISCOVERY_STATE:{generation:0,all:[]},RB_ROOT:{addEventListener:()=>{},querySelectorAll:()=>[]},rbText:x=>x,rbV18JournalKey:j=>j.id};
|
| 7 |
+
vm.createContext(context);
|
| 8 |
+
vm.runInContext('const RB_REQUESTS=new Map();const RB_ACTIVE_REQUESTS=new Set();let RB_SESSION_GENERATION=0;'+segment('function rbCancelAllRequests()','function rbRenderChatText(')+segment('function safeHttpsUrl(','function makeExternalLink(')+segment('function esc(str)','function confidenceBadge('),context);
|
| 9 |
+
let count=0;
|
| 10 |
+
function check(fn){fn();count++;}
|
| 11 |
+
(async()=>{
|
| 12 |
+
check(()=>assert.equal(vm.runInContext('safeHttpsUrl("javascript:alert(1)")',context),''));
|
| 13 |
+
check(()=>assert.equal(vm.runInContext('safeHttpsUrl("https://127.0.0.1")',context),''));
|
| 14 |
+
check(()=>assert.equal(vm.runInContext('rbJournalTitle({name:"Nature",journal_links:{homepage:{url:"https://nature.com/"}}}).includes("href=\\\"https://nature.com/\\\"")',context),true));
|
| 15 |
+
check(()=>assert.ok(vm.runInContext('rbJournalLinks({journal_links:{homepage:{status:"metadata-only",url:"https://nature.com"}}})',context).includes('Metadata only')));
|
| 16 |
+
vm.runInContext(segment('function renderFieldQuartiles(', '/* A journal too new'),context);
|
| 17 |
+
check(()=>{let out=vm.runInContext('renderFieldQuartiles([{field:"A",quartile:"Q2",profile_url:"https://www.scimagojr.com/journalsearch.php?q=123&tip=sid",category_url:"https://www.scimagojr.com/journalrank.php?category=2201",source:"SCImago SJR",year:"2025"},{field:"B",quartile:"Q4",profile_url:"https://www.scimagojr.com/journalsearch.php?q=123&tip=sid",source:"SCImago SJR",year:"2025"}])',context);assert.ok(out.includes('Q2'));assert.ok(out.includes('Q4'));assert.ok(out.includes('Not available'));assert.ok(!out.includes('Q1'));});
|
| 18 |
+
context.fetch=()=>new Promise(resolve=>context.resolveFetch=resolve);
|
| 19 |
+
const late=vm.runInContext('rbFetchJson("https://example.org")',context);
|
| 20 |
+
vm.runInContext('rbCancelAllRequests()',context);
|
| 21 |
+
context.resolveFetch({ok:true,json:async()=>({value:'stale'})});
|
| 22 |
+
await assert.rejects(late,e=>e.stale===true);count++;
|
| 23 |
+
// Exercise actual click handler: reset generation changes while fetch is pending.
|
| 24 |
+
let replaced=0;
|
| 25 |
+
const host={isConnected:true,dataset:{linkKey:'21206'},append:()=>{}};
|
| 26 |
+
const button={disabled:false,dataset:{canonicalId:'21206'},closest:()=>host};
|
| 27 |
+
context.event={target:{closest:()=>button}};
|
| 28 |
+
context.RB_ROOT.querySelectorAll=()=>[{dataset:{linkKey:'21206'},set outerHTML(v){replaced++;}}];
|
| 29 |
+
context.RB_DISCOVERY_STATE.all=[{journal_links:{canonical_id:'21206'}}];
|
| 30 |
+
let lookup=vm.runInContext('rbFindGuidelines(event)',context);
|
| 31 |
+
context.RB_DISCOVERY_STATE.generation++;
|
| 32 |
+
context.resolveFetch({ok:true,json:async()=>({canonical_id:'21206',guidelines:{url:'https://nature.com/authors'}})});
|
| 33 |
+
await lookup;check(()=>assert.equal(replaced,0));check(()=>assert.equal(context.RB_DISCOVERY_STATE.all[0].journal_links.guidelines,undefined));
|
| 34 |
+
// Successful click updates state and visible copies.
|
| 35 |
+
button.disabled=false;
|
| 36 |
+
context.fetch=async()=>({ok:true,json:async()=>({canonical_id:'21206',guidelines:{url:'https://nature.com/authors',status:'checked'}})});
|
| 37 |
+
await vm.runInContext('rbFindGuidelines(event)',context);
|
| 38 |
+
check(()=>assert.equal(replaced,1));check(()=>assert.equal(context.RB_DISCOVERY_STATE.all[0].journal_links.guidelines.status,'checked'));
|
| 39 |
+
// Failure preserves existing markup/homepage, reenables the action.
|
| 40 |
+
button.disabled=false; context.fetch=async()=>({ok:false,status:503,json:async()=>({detail:'Unavailable'})});
|
| 41 |
+
await vm.runInContext('rbFindGuidelines(event)',context);
|
| 42 |
+
check(()=>assert.equal(button.disabled,false));check(()=>assert.equal(replaced,1));
|
| 43 |
+
console.log(`${count} frontend behavioral checks passed (Node VM; no browser).`);
|
| 44 |
+
})().catch(e=>{console.error(e);process.exit(1)});
|
tests/test_frontend_release_a.py
CHANGED
|
@@ -1,51 +1,51 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
FRONTEND = Path(__file__).resolve().parents[1] / "ResearchBee_LibGuides.html"
|
| 6 |
-
TEXT = FRONTEND.read_text(encoding="utf-8")
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def between(start: str, end: str) -> str:
|
| 10 |
-
return TEXT.split(start, 1)[1].split(end, 1)[0]
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def test_citation_loader_preserves_accordion_container_and_tabs():
|
| 14 |
-
loader = between(
|
| 15 |
-
"window.ResearchBee._loadRelatedWorks = async function(uid)",
|
| 16 |
-
"// ── Deeper Analysis Strip",
|
| 17 |
-
)
|
| 18 |
-
assert "rwpanel-cited-${uid}" in loader
|
| 19 |
-
assert "document.getElementById(`rw-${uid}`)" not in loader
|
| 20 |
-
assert "panel.replaceChildren" not in loader or "renderWorkList(panel" in loader
|
| 21 |
-
assert "rwpanel-chain-${uid}" in TEXT
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def test_cover_letter_rendering_has_no_inline_event_handlers():
|
| 25 |
-
renderer = between("function renderCoverLetterBtn", "// ── Altmetric badge")
|
| 26 |
-
generator = between("// ── Cover letter generator", "// ── Subject browse cover letter")
|
| 27 |
-
subject = between("function renderSubjectQuartileCell", "// ── Export citations")
|
| 28 |
-
assert "onclick=" not in renderer
|
| 29 |
-
assert "onclick=" not in generator
|
| 30 |
-
assert "onclick=" not in subject
|
| 31 |
-
assert "addEventListener" in renderer
|
| 32 |
-
assert "textContent" in generator
|
| 33 |
-
assert "data-cover-id" in renderer
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def test_related_work_rendering_has_no_inline_handlers_or_raw_html_injection():
|
| 37 |
-
related = between("// ── Related works accordion", "// ── Deeper Analysis Strip")
|
| 38 |
-
citation = between("// ── Citation chaining", "// ── KU Journal Finder Guide card")
|
| 39 |
-
assert "onclick=" not in related
|
| 40 |
-
assert "onclick=" not in citation
|
| 41 |
-
assert "api.openalex.org/works?filter=cites" not in TEXT
|
| 42 |
-
assert "/api/openalex-cited-by" in citation
|
| 43 |
-
assert "title.textContent" in related
|
| 44 |
-
assert "authorLine.textContent" in related
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_subject_table_renders_all_field_quartiles():
|
| 48 |
-
assert "function renderSubjectQuartileCell" in TEXT
|
| 49 |
-
assert "journal.quartiles_by_field" in TEXT
|
| 50 |
-
assert "Quartile by field" in TEXT
|
| 51 |
-
assert "subject-q-item" in TEXT
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
FRONTEND = Path(__file__).resolve().parents[1] / "ResearchBee_LibGuides.html"
|
| 6 |
+
TEXT = FRONTEND.read_text(encoding="utf-8")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def between(start: str, end: str) -> str:
|
| 10 |
+
return TEXT.split(start, 1)[1].split(end, 1)[0]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_citation_loader_preserves_accordion_container_and_tabs():
|
| 14 |
+
loader = between(
|
| 15 |
+
"window.ResearchBee._loadRelatedWorks = async function(uid)",
|
| 16 |
+
"// ── Deeper Analysis Strip",
|
| 17 |
+
)
|
| 18 |
+
assert "rwpanel-cited-${uid}" in loader
|
| 19 |
+
assert "document.getElementById(`rw-${uid}`)" not in loader
|
| 20 |
+
assert "panel.replaceChildren" not in loader or "renderWorkList(panel" in loader
|
| 21 |
+
assert "rwpanel-chain-${uid}" in TEXT
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_cover_letter_rendering_has_no_inline_event_handlers():
|
| 25 |
+
renderer = between("function renderCoverLetterBtn", "// ── Altmetric badge")
|
| 26 |
+
generator = between("// ── Cover letter generator", "// ── Subject browse cover letter")
|
| 27 |
+
subject = between("function renderSubjectQuartileCell", "// ── Export citations")
|
| 28 |
+
assert "onclick=" not in renderer
|
| 29 |
+
assert "onclick=" not in generator
|
| 30 |
+
assert "onclick=" not in subject
|
| 31 |
+
assert "addEventListener" in renderer
|
| 32 |
+
assert "textContent" in generator
|
| 33 |
+
assert "data-cover-id" in renderer
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_related_work_rendering_has_no_inline_handlers_or_raw_html_injection():
|
| 37 |
+
related = between("// ── Related works accordion", "// ── Deeper Analysis Strip")
|
| 38 |
+
citation = between("// ── Citation chaining", "// ── KU Journal Finder Guide card")
|
| 39 |
+
assert "onclick=" not in related
|
| 40 |
+
assert "onclick=" not in citation
|
| 41 |
+
assert "api.openalex.org/works?filter=cites" not in TEXT
|
| 42 |
+
assert "/api/openalex-cited-by" in citation
|
| 43 |
+
assert "title.textContent" in related
|
| 44 |
+
assert "authorLine.textContent" in related
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_subject_table_renders_all_field_quartiles():
|
| 48 |
+
assert "function renderSubjectQuartileCell" in TEXT
|
| 49 |
+
assert "journal.quartiles_by_field" in TEXT
|
| 50 |
+
assert "Quartile by field" in TEXT
|
| 51 |
+
assert "subject-q-item" in TEXT
|
tests/test_journal_policy_guard.py
CHANGED
|
@@ -1,44 +1,44 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import app
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def test_journal_finder_discards_model_generated_policy_facts():
|
| 7 |
-
model_output = {
|
| 8 |
-
"journals": [
|
| 9 |
-
{
|
| 10 |
-
"name": "Example Journal",
|
| 11 |
-
"issn": "1234-5678",
|
| 12 |
-
"oa_compliance_note": "Deposit the AAM immediately under CC BY.",
|
| 13 |
-
"green_oa": {
|
| 14 |
-
"policy_status": "Confirmed",
|
| 15 |
-
"postprint": {
|
| 16 |
-
"allowed": "Yes",
|
| 17 |
-
"embargo": "0 months",
|
| 18 |
-
"licence": "CC BY",
|
| 19 |
-
},
|
| 20 |
-
},
|
| 21 |
-
}
|
| 22 |
-
],
|
| 23 |
-
"repository_recommendation": {
|
| 24 |
-
"immediate_open_deposit_possible": "Yes",
|
| 25 |
-
},
|
| 26 |
-
"next_actions": [
|
| 27 |
-
"Deposit the accepted manuscript immediately.",
|
| 28 |
-
"Read the journal author guidelines.",
|
| 29 |
-
],
|
| 30 |
-
"global_notes": "The journal permits Green OA.",
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
guarded = app.enforce_journal_policy_guard(model_output)
|
| 34 |
-
journal = guarded["journals"][0]
|
| 35 |
-
green = journal["green_oa"]
|
| 36 |
-
assert green["policy_status"] == "Not confirmed"
|
| 37 |
-
assert green["permission_status"] == "unknown"
|
| 38 |
-
assert green["embargo_status"] == "unknown"
|
| 39 |
-
assert green["postprint"]["allowed"] == "Unclear"
|
| 40 |
-
assert "DOI-based" in journal["oa_compliance_note"]
|
| 41 |
-
assert guarded["repository_recommendation"]["immediate_open_deposit_possible"] == "Unknown"
|
| 42 |
-
assert all("Deposit the accepted" not in action for action in guarded["next_actions"])
|
| 43 |
-
assert any("author guidelines" in action for action in guarded["next_actions"])
|
| 44 |
-
assert guarded["policy_source_mode"] == "not_checked_in_journal_finder"
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_journal_finder_discards_model_generated_policy_facts():
|
| 7 |
+
model_output = {
|
| 8 |
+
"journals": [
|
| 9 |
+
{
|
| 10 |
+
"name": "Example Journal",
|
| 11 |
+
"issn": "1234-5678",
|
| 12 |
+
"oa_compliance_note": "Deposit the AAM immediately under CC BY.",
|
| 13 |
+
"green_oa": {
|
| 14 |
+
"policy_status": "Confirmed",
|
| 15 |
+
"postprint": {
|
| 16 |
+
"allowed": "Yes",
|
| 17 |
+
"embargo": "0 months",
|
| 18 |
+
"licence": "CC BY",
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
}
|
| 22 |
+
],
|
| 23 |
+
"repository_recommendation": {
|
| 24 |
+
"immediate_open_deposit_possible": "Yes",
|
| 25 |
+
},
|
| 26 |
+
"next_actions": [
|
| 27 |
+
"Deposit the accepted manuscript immediately.",
|
| 28 |
+
"Read the journal author guidelines.",
|
| 29 |
+
],
|
| 30 |
+
"global_notes": "The journal permits Green OA.",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
guarded = app.enforce_journal_policy_guard(model_output)
|
| 34 |
+
journal = guarded["journals"][0]
|
| 35 |
+
green = journal["green_oa"]
|
| 36 |
+
assert green["policy_status"] == "Not confirmed"
|
| 37 |
+
assert green["permission_status"] == "unknown"
|
| 38 |
+
assert green["embargo_status"] == "unknown"
|
| 39 |
+
assert green["postprint"]["allowed"] == "Unclear"
|
| 40 |
+
assert "DOI-based" in journal["oa_compliance_note"]
|
| 41 |
+
assert guarded["repository_recommendation"]["immediate_open_deposit_possible"] == "Unknown"
|
| 42 |
+
assert all("Deposit the accepted" not in action for action in guarded["next_actions"])
|
| 43 |
+
assert any("author guidelines" in action for action in guarded["next_actions"])
|
| 44 |
+
assert guarded["policy_source_mode"] == "not_checked_in_journal_finder"
|
tests/test_khazna_reload_auth.py
CHANGED
|
@@ -1,62 +1,62 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import inspect
|
| 4 |
-
|
| 5 |
-
import pytest
|
| 6 |
-
from fastapi import HTTPException
|
| 7 |
-
from starlette.requests import Request
|
| 8 |
-
|
| 9 |
-
import app
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def request_with(headers=None):
|
| 13 |
-
raw_headers = [
|
| 14 |
-
(str(key).lower().encode("latin-1"), str(value).encode("latin-1"))
|
| 15 |
-
for key, value in (headers or {}).items()
|
| 16 |
-
]
|
| 17 |
-
return Request({
|
| 18 |
-
"type": "http",
|
| 19 |
-
"method": "POST",
|
| 20 |
-
"path": "/api/admin/reload-khazna",
|
| 21 |
-
"headers": raw_headers,
|
| 22 |
-
"client": ("127.0.0.1", 12345),
|
| 23 |
-
"server": ("testserver", 80),
|
| 24 |
-
"scheme": "http",
|
| 25 |
-
"query_string": b"",
|
| 26 |
-
})
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def test_reload_secret_must_be_configured(monkeypatch):
|
| 30 |
-
monkeypatch.delenv("KHAZNA_RELOAD_TOKEN", raising=False)
|
| 31 |
-
with pytest.raises(HTTPException) as exc:
|
| 32 |
-
app.require_khazna_reload_auth(request_with())
|
| 33 |
-
assert exc.value.status_code == 503
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def test_reload_rejects_missing_and_invalid_tokens(monkeypatch):
|
| 37 |
-
monkeypatch.setenv("KHAZNA_RELOAD_TOKEN", "correct-secret")
|
| 38 |
-
with pytest.raises(HTTPException) as missing:
|
| 39 |
-
app.require_khazna_reload_auth(request_with())
|
| 40 |
-
assert missing.value.status_code == 401
|
| 41 |
-
|
| 42 |
-
with pytest.raises(HTTPException) as invalid:
|
| 43 |
-
app.require_khazna_reload_auth(request_with({
|
| 44 |
-
app.KHAZNA_RELOAD_HEADER: "wrong-secret",
|
| 45 |
-
}))
|
| 46 |
-
assert invalid.value.status_code == 403
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def test_reload_accepts_header_or_bearer_token(monkeypatch):
|
| 50 |
-
monkeypatch.setenv("KHAZNA_RELOAD_TOKEN", "correct-secret")
|
| 51 |
-
app.require_khazna_reload_auth(request_with({
|
| 52 |
-
app.KHAZNA_RELOAD_HEADER: "correct-secret",
|
| 53 |
-
}))
|
| 54 |
-
app.require_khazna_reload_auth(request_with({
|
| 55 |
-
"Authorization": "Bearer correct-secret",
|
| 56 |
-
}))
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def test_reload_endpoint_calls_auth_guard_before_reload():
|
| 60 |
-
source = inspect.getsource(app.reload_khazna)
|
| 61 |
-
assert "require_khazna_reload_auth(request)" in source
|
| 62 |
-
assert source.index("require_khazna_reload_auth(request)") < source.index("khazna_index.reload")
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import inspect
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
from starlette.requests import Request
|
| 8 |
+
|
| 9 |
+
import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def request_with(headers=None):
|
| 13 |
+
raw_headers = [
|
| 14 |
+
(str(key).lower().encode("latin-1"), str(value).encode("latin-1"))
|
| 15 |
+
for key, value in (headers or {}).items()
|
| 16 |
+
]
|
| 17 |
+
return Request({
|
| 18 |
+
"type": "http",
|
| 19 |
+
"method": "POST",
|
| 20 |
+
"path": "/api/admin/reload-khazna",
|
| 21 |
+
"headers": raw_headers,
|
| 22 |
+
"client": ("127.0.0.1", 12345),
|
| 23 |
+
"server": ("testserver", 80),
|
| 24 |
+
"scheme": "http",
|
| 25 |
+
"query_string": b"",
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_reload_secret_must_be_configured(monkeypatch):
|
| 30 |
+
monkeypatch.delenv("KHAZNA_RELOAD_TOKEN", raising=False)
|
| 31 |
+
with pytest.raises(HTTPException) as exc:
|
| 32 |
+
app.require_khazna_reload_auth(request_with())
|
| 33 |
+
assert exc.value.status_code == 503
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_reload_rejects_missing_and_invalid_tokens(monkeypatch):
|
| 37 |
+
monkeypatch.setenv("KHAZNA_RELOAD_TOKEN", "correct-secret")
|
| 38 |
+
with pytest.raises(HTTPException) as missing:
|
| 39 |
+
app.require_khazna_reload_auth(request_with())
|
| 40 |
+
assert missing.value.status_code == 401
|
| 41 |
+
|
| 42 |
+
with pytest.raises(HTTPException) as invalid:
|
| 43 |
+
app.require_khazna_reload_auth(request_with({
|
| 44 |
+
app.KHAZNA_RELOAD_HEADER: "wrong-secret",
|
| 45 |
+
}))
|
| 46 |
+
assert invalid.value.status_code == 403
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_reload_accepts_header_or_bearer_token(monkeypatch):
|
| 50 |
+
monkeypatch.setenv("KHAZNA_RELOAD_TOKEN", "correct-secret")
|
| 51 |
+
app.require_khazna_reload_auth(request_with({
|
| 52 |
+
app.KHAZNA_RELOAD_HEADER: "correct-secret",
|
| 53 |
+
}))
|
| 54 |
+
app.require_khazna_reload_auth(request_with({
|
| 55 |
+
"Authorization": "Bearer correct-secret",
|
| 56 |
+
}))
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_reload_endpoint_calls_auth_guard_before_reload():
|
| 60 |
+
source = inspect.getsource(app.reload_khazna)
|
| 61 |
+
assert "require_khazna_reload_auth(request)" in source
|
| 62 |
+
assert source.index("require_khazna_reload_auth(request)") < source.index("khazna_index.reload")
|
tests/test_oaworks_tri_state.py
CHANGED
|
@@ -1,65 +1,65 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import oaworks
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def permission_payload(*, can_archive=True, embargo="absent"):
|
| 7 |
-
permission = {
|
| 8 |
-
"can_archive": can_archive,
|
| 9 |
-
"version": "acceptedVersion",
|
| 10 |
-
"versions": ["acceptedVersion"],
|
| 11 |
-
"locations": ["Institutional Repository"],
|
| 12 |
-
"issuer": {"id": ["1234-5678"]},
|
| 13 |
-
"score": 100,
|
| 14 |
-
}
|
| 15 |
-
if embargo != "absent":
|
| 16 |
-
permission["embargo_months"] = embargo
|
| 17 |
-
return {"best_permission": permission, "all_permissions": [permission]}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def test_allowed_permission_with_absent_embargo_is_unknown_not_immediate():
|
| 21 |
-
green = oaworks.parse_permissions(permission_payload(embargo="absent"))
|
| 22 |
-
assert green["permission_status"] == "allowed"
|
| 23 |
-
assert green["embargo_status"] == "unknown"
|
| 24 |
-
recommendation = oaworks.recommendation_from(green)
|
| 25 |
-
assert recommendation["immediate_open_deposit_possible"] == "Unknown"
|
| 26 |
-
assert recommendation["embargoed_deposit_needed"] == "Unknown"
|
| 27 |
-
assert recommendation["metadata_only_first"] == "Unknown"
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def test_explicit_zero_embargo_allows_immediate_open_deposit():
|
| 31 |
-
green = oaworks.parse_permissions(permission_payload(embargo=0))
|
| 32 |
-
assert green["embargo_status"] == "zero"
|
| 33 |
-
assert green["_embargo_months"] == 0
|
| 34 |
-
recommendation = oaworks.recommendation_from(green)
|
| 35 |
-
assert recommendation["immediate_open_deposit_possible"] == "Yes"
|
| 36 |
-
assert recommendation["embargoed_deposit_needed"] == "No"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def test_positive_embargo_requires_delayed_opening():
|
| 40 |
-
green = oaworks.parse_permissions(permission_payload(embargo=12))
|
| 41 |
-
assert green["embargo_status"] == "stated"
|
| 42 |
-
recommendation = oaworks.recommendation_from(green)
|
| 43 |
-
assert recommendation["immediate_open_deposit_possible"] == "No"
|
| 44 |
-
assert recommendation["embargoed_deposit_needed"] == "Yes"
|
| 45 |
-
assert recommendation["best_timing"] == "After a 12-month embargo"
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_unverified_permission_is_unknown_not_denied():
|
| 49 |
-
green = oaworks.not_confirmed("Example Journal", "1234-5678")
|
| 50 |
-
assert green["_can_archive"] is None
|
| 51 |
-
assert green["permission_status"] == "unknown"
|
| 52 |
-
recommendation = oaworks.recommendation_from(green)
|
| 53 |
-
assert recommendation["permission_status"] == "unknown"
|
| 54 |
-
assert recommendation["immediate_open_deposit_possible"] == "Unknown"
|
| 55 |
-
actions = oaworks.next_actions_from(green, "")
|
| 56 |
-
assert "could not determine" in actions[0]
|
| 57 |
-
assert "does not appear to be permitted" not in actions[0]
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def test_confirmed_denial_remains_denied():
|
| 61 |
-
green = oaworks.parse_permissions(permission_payload(can_archive=False))
|
| 62 |
-
assert green["permission_status"] == "denied"
|
| 63 |
-
recommendation = oaworks.recommendation_from(green)
|
| 64 |
-
assert recommendation["immediate_open_deposit_possible"] == "No"
|
| 65 |
-
assert recommendation["permission_status"] == "denied"
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import oaworks
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def permission_payload(*, can_archive=True, embargo="absent"):
|
| 7 |
+
permission = {
|
| 8 |
+
"can_archive": can_archive,
|
| 9 |
+
"version": "acceptedVersion",
|
| 10 |
+
"versions": ["acceptedVersion"],
|
| 11 |
+
"locations": ["Institutional Repository"],
|
| 12 |
+
"issuer": {"id": ["1234-5678"]},
|
| 13 |
+
"score": 100,
|
| 14 |
+
}
|
| 15 |
+
if embargo != "absent":
|
| 16 |
+
permission["embargo_months"] = embargo
|
| 17 |
+
return {"best_permission": permission, "all_permissions": [permission]}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_allowed_permission_with_absent_embargo_is_unknown_not_immediate():
|
| 21 |
+
green = oaworks.parse_permissions(permission_payload(embargo="absent"))
|
| 22 |
+
assert green["permission_status"] == "allowed"
|
| 23 |
+
assert green["embargo_status"] == "unknown"
|
| 24 |
+
recommendation = oaworks.recommendation_from(green)
|
| 25 |
+
assert recommendation["immediate_open_deposit_possible"] == "Unknown"
|
| 26 |
+
assert recommendation["embargoed_deposit_needed"] == "Unknown"
|
| 27 |
+
assert recommendation["metadata_only_first"] == "Unknown"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_explicit_zero_embargo_allows_immediate_open_deposit():
|
| 31 |
+
green = oaworks.parse_permissions(permission_payload(embargo=0))
|
| 32 |
+
assert green["embargo_status"] == "zero"
|
| 33 |
+
assert green["_embargo_months"] == 0
|
| 34 |
+
recommendation = oaworks.recommendation_from(green)
|
| 35 |
+
assert recommendation["immediate_open_deposit_possible"] == "Yes"
|
| 36 |
+
assert recommendation["embargoed_deposit_needed"] == "No"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_positive_embargo_requires_delayed_opening():
|
| 40 |
+
green = oaworks.parse_permissions(permission_payload(embargo=12))
|
| 41 |
+
assert green["embargo_status"] == "stated"
|
| 42 |
+
recommendation = oaworks.recommendation_from(green)
|
| 43 |
+
assert recommendation["immediate_open_deposit_possible"] == "No"
|
| 44 |
+
assert recommendation["embargoed_deposit_needed"] == "Yes"
|
| 45 |
+
assert recommendation["best_timing"] == "After a 12-month embargo"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_unverified_permission_is_unknown_not_denied():
|
| 49 |
+
green = oaworks.not_confirmed("Example Journal", "1234-5678")
|
| 50 |
+
assert green["_can_archive"] is None
|
| 51 |
+
assert green["permission_status"] == "unknown"
|
| 52 |
+
recommendation = oaworks.recommendation_from(green)
|
| 53 |
+
assert recommendation["permission_status"] == "unknown"
|
| 54 |
+
assert recommendation["immediate_open_deposit_possible"] == "Unknown"
|
| 55 |
+
actions = oaworks.next_actions_from(green, "")
|
| 56 |
+
assert "could not determine" in actions[0]
|
| 57 |
+
assert "does not appear to be permitted" not in actions[0]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_confirmed_denial_remains_denied():
|
| 61 |
+
green = oaworks.parse_permissions(permission_payload(can_archive=False))
|
| 62 |
+
assert green["permission_status"] == "denied"
|
| 63 |
+
recommendation = oaworks.recommendation_from(green)
|
| 64 |
+
assert recommendation["immediate_open_deposit_possible"] == "No"
|
| 65 |
+
assert recommendation["permission_status"] == "denied"
|
tests/test_repository_schema.py
CHANGED
|
@@ -1,62 +1,62 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from prompts import REPO_SYSTEM_PROMPT
|
| 4 |
-
from repositories import hydrate_deposit_strategy, hydrate_selection
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def test_repository_prompt_requires_exact_id_minimal_schema():
|
| 8 |
-
assert '"id": "exact registry id"' in REPO_SYSTEM_PROMPT
|
| 9 |
-
assert "Every repositories item MUST contain an exact registry id" in REPO_SYSTEM_PROMPT
|
| 10 |
-
assert "id, fit_reason, fit_confidence" in REPO_SYSTEM_PROMPT
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def test_hydration_requires_id_and_discards_model_facts():
|
| 14 |
-
selected = [
|
| 15 |
-
{
|
| 16 |
-
"id": "zenodo",
|
| 17 |
-
"fit_reason": "Good multidisciplinary fallback for these files.",
|
| 18 |
-
"fit_confidence": "High",
|
| 19 |
-
# All of these must be ignored in favour of the registry.
|
| 20 |
-
"name": "Fake Zenodo",
|
| 21 |
-
"url": "javascript:alert(1)",
|
| 22 |
-
"cost": "Expensive",
|
| 23 |
-
"access_model": "Invented",
|
| 24 |
-
"licences_supported": "Invented",
|
| 25 |
-
},
|
| 26 |
-
{"name": "Figshare", "fit_reason": "Missing id must not hydrate."},
|
| 27 |
-
{"id": "not-in-registry", "fit_reason": "Unknown id must not hydrate."},
|
| 28 |
-
{"id": "zenodo", "fit_reason": "Duplicate must not hydrate twice."},
|
| 29 |
-
]
|
| 30 |
-
|
| 31 |
-
hydrated = hydrate_selection(selected)
|
| 32 |
-
assert len(hydrated) == 1
|
| 33 |
-
repository = hydrated[0]
|
| 34 |
-
assert repository["id"] == "zenodo"
|
| 35 |
-
assert repository["name"] == "Zenodo"
|
| 36 |
-
assert repository["url"] == "https://zenodo.org"
|
| 37 |
-
assert repository["cost"] == "Free"
|
| 38 |
-
assert repository["fit_reason"].startswith("Good multidisciplinary")
|
| 39 |
-
assert repository["fit_confidence"] == "High"
|
| 40 |
-
assert "access_model" not in repository
|
| 41 |
-
assert "licences_supported" not in repository
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def test_deposit_strategy_resolves_only_hydrated_ids():
|
| 45 |
-
repositories = hydrate_selection([
|
| 46 |
-
{"id": "zenodo", "fit_reason": "Primary", "fit_confidence": "High"},
|
| 47 |
-
{"id": "figshare", "fit_reason": "Secondary", "fit_confidence": "Medium"},
|
| 48 |
-
])
|
| 49 |
-
strategy = hydrate_deposit_strategy(
|
| 50 |
-
{
|
| 51 |
-
"primary_repository_id": "zenodo",
|
| 52 |
-
"secondary_repository_id": "invented",
|
| 53 |
-
"metadata_standard": "Check repository guidance",
|
| 54 |
-
"deposit_workflow": ["Review terms", "Deposit files"],
|
| 55 |
-
},
|
| 56 |
-
repositories,
|
| 57 |
-
)
|
| 58 |
-
assert strategy["primary_repository_id"] == "zenodo"
|
| 59 |
-
assert strategy["primary_choice"] == "Zenodo"
|
| 60 |
-
# Invalid model id falls back to the next actually hydrated result.
|
| 61 |
-
assert strategy["secondary_repository_id"] == "figshare"
|
| 62 |
-
assert strategy["secondary_choice"] == "Figshare"
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from prompts import REPO_SYSTEM_PROMPT
|
| 4 |
+
from repositories import hydrate_deposit_strategy, hydrate_selection
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_repository_prompt_requires_exact_id_minimal_schema():
|
| 8 |
+
assert '"id": "exact registry id"' in REPO_SYSTEM_PROMPT
|
| 9 |
+
assert "Every repositories item MUST contain an exact registry id" in REPO_SYSTEM_PROMPT
|
| 10 |
+
assert "id, fit_reason, fit_confidence" in REPO_SYSTEM_PROMPT
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_hydration_requires_id_and_discards_model_facts():
|
| 14 |
+
selected = [
|
| 15 |
+
{
|
| 16 |
+
"id": "zenodo",
|
| 17 |
+
"fit_reason": "Good multidisciplinary fallback for these files.",
|
| 18 |
+
"fit_confidence": "High",
|
| 19 |
+
# All of these must be ignored in favour of the registry.
|
| 20 |
+
"name": "Fake Zenodo",
|
| 21 |
+
"url": "javascript:alert(1)",
|
| 22 |
+
"cost": "Expensive",
|
| 23 |
+
"access_model": "Invented",
|
| 24 |
+
"licences_supported": "Invented",
|
| 25 |
+
},
|
| 26 |
+
{"name": "Figshare", "fit_reason": "Missing id must not hydrate."},
|
| 27 |
+
{"id": "not-in-registry", "fit_reason": "Unknown id must not hydrate."},
|
| 28 |
+
{"id": "zenodo", "fit_reason": "Duplicate must not hydrate twice."},
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
hydrated = hydrate_selection(selected)
|
| 32 |
+
assert len(hydrated) == 1
|
| 33 |
+
repository = hydrated[0]
|
| 34 |
+
assert repository["id"] == "zenodo"
|
| 35 |
+
assert repository["name"] == "Zenodo"
|
| 36 |
+
assert repository["url"] == "https://zenodo.org"
|
| 37 |
+
assert repository["cost"] == "Free"
|
| 38 |
+
assert repository["fit_reason"].startswith("Good multidisciplinary")
|
| 39 |
+
assert repository["fit_confidence"] == "High"
|
| 40 |
+
assert "access_model" not in repository
|
| 41 |
+
assert "licences_supported" not in repository
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_deposit_strategy_resolves_only_hydrated_ids():
|
| 45 |
+
repositories = hydrate_selection([
|
| 46 |
+
{"id": "zenodo", "fit_reason": "Primary", "fit_confidence": "High"},
|
| 47 |
+
{"id": "figshare", "fit_reason": "Secondary", "fit_confidence": "Medium"},
|
| 48 |
+
])
|
| 49 |
+
strategy = hydrate_deposit_strategy(
|
| 50 |
+
{
|
| 51 |
+
"primary_repository_id": "zenodo",
|
| 52 |
+
"secondary_repository_id": "invented",
|
| 53 |
+
"metadata_standard": "Check repository guidance",
|
| 54 |
+
"deposit_workflow": ["Review terms", "Deposit files"],
|
| 55 |
+
},
|
| 56 |
+
repositories,
|
| 57 |
+
)
|
| 58 |
+
assert strategy["primary_repository_id"] == "zenodo"
|
| 59 |
+
assert strategy["primary_choice"] == "Zenodo"
|
| 60 |
+
# Invalid model id falls back to the next actually hydrated result.
|
| 61 |
+
assert strategy["secondary_repository_id"] == "figshare"
|
| 62 |
+
assert strategy["secondary_choice"] == "Figshare"
|
tests/test_subject_quartiles.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import app
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def test_subject_result_includes_all_field_quartiles(monkeypatch):
|
| 7 |
-
monkeypatch.setattr(app, "lookup_any", lambda _issns: {
|
| 8 |
-
"matched_issn": "1234-5678",
|
| 9 |
-
"quartile": "Q1",
|
| 10 |
-
"h_index": "88",
|
| 11 |
-
"publisher": "Example Publisher",
|
| 12 |
-
"open_access": "Yes",
|
| 13 |
-
"country": "UAE",
|
| 14 |
-
"categories": "Engineering (Q1); Energy (Q2)",
|
| 15 |
-
"sourceid": "999",
|
| 16 |
-
"quartiles_by_field": [
|
| 17 |
-
{"code": "2201", "field": "Engineering", "quartile": "Q1"},
|
| 18 |
-
{"code": "2102", "field": "Energy", "quartile": "Q2"},
|
| 19 |
-
],
|
| 20 |
-
"state": "indexed_ranked",
|
| 21 |
-
"currently_indexed": True,
|
| 22 |
-
"warn": False,
|
| 23 |
-
})
|
| 24 |
-
monkeypatch.setattr(app, "build_verify_links", lambda *_args: {"scimago": "https://example.test"})
|
| 25 |
-
monkeypatch.setattr(app, "is_ku_apc_covered", lambda *_args: False)
|
| 26 |
-
|
| 27 |
-
result = app.build_subject_journal_result({
|
| 28 |
-
"title": "Example Journal",
|
| 29 |
-
"issn": "1234-5678",
|
| 30 |
-
"quartile": "Q1",
|
| 31 |
-
"h_index": 80,
|
| 32 |
-
})
|
| 33 |
-
assert result["quartile"] == "Q1"
|
| 34 |
-
assert result["quartiles_by_field"] == [
|
| 35 |
-
{"code": "2201", "field": "Engineering", "quartile": "Q1"},
|
| 36 |
-
{"code": "2102", "field": "Energy", "quartile": "Q2"},
|
| 37 |
-
]
|
| 38 |
-
assert result["h_index"] == 88
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_subject_result_includes_all_field_quartiles(monkeypatch):
|
| 7 |
+
monkeypatch.setattr(app, "lookup_any", lambda _issns: {
|
| 8 |
+
"matched_issn": "1234-5678",
|
| 9 |
+
"quartile": "Q1",
|
| 10 |
+
"h_index": "88",
|
| 11 |
+
"publisher": "Example Publisher",
|
| 12 |
+
"open_access": "Yes",
|
| 13 |
+
"country": "UAE",
|
| 14 |
+
"categories": "Engineering (Q1); Energy (Q2)",
|
| 15 |
+
"sourceid": "999",
|
| 16 |
+
"quartiles_by_field": [
|
| 17 |
+
{"code": "2201", "field": "Engineering", "quartile": "Q1"},
|
| 18 |
+
{"code": "2102", "field": "Energy", "quartile": "Q2"},
|
| 19 |
+
],
|
| 20 |
+
"state": "indexed_ranked",
|
| 21 |
+
"currently_indexed": True,
|
| 22 |
+
"warn": False,
|
| 23 |
+
})
|
| 24 |
+
monkeypatch.setattr(app, "build_verify_links", lambda *_args: {"scimago": "https://example.test"})
|
| 25 |
+
monkeypatch.setattr(app, "is_ku_apc_covered", lambda *_args: False)
|
| 26 |
+
|
| 27 |
+
result = app.build_subject_journal_result({
|
| 28 |
+
"title": "Example Journal",
|
| 29 |
+
"issn": "1234-5678",
|
| 30 |
+
"quartile": "Q1",
|
| 31 |
+
"h_index": 80,
|
| 32 |
+
})
|
| 33 |
+
assert result["quartile"] == "Q1"
|
| 34 |
+
assert result["quartiles_by_field"] == [
|
| 35 |
+
{"code": "2201", "field": "Engineering", "quartile": "Q1"},
|
| 36 |
+
{"code": "2102", "field": "Energy", "quartile": "Q2"},
|
| 37 |
+
]
|
| 38 |
+
assert result["h_index"] == 88
|
tests/test_trusted_links.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline regression tests; runnable via pytest or stdlib unittest."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import sys
|
| 4 |
+
import types
|
| 5 |
+
import unittest
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from unittest.mock import patch, AsyncMock
|
| 8 |
+
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
| 9 |
+
import trusted_links as tl
|
| 10 |
+
from journal_lookup import get_index
|
| 11 |
+
from repositories import hydrate_selection, hydrate_deposit_strategy
|
| 12 |
+
|
| 13 |
+
class LinkTests(unittest.IsolatedAsyncioTestCase):
|
| 14 |
+
def setUp(self):
|
| 15 |
+
tl._cache.clear(); tl._metadata_cache.clear(); tl._locks.clear(); tl._gate=asyncio.Semaphore(4)
|
| 16 |
+
self.rec=get_index().lookup_exact(title='Nature')
|
| 17 |
+
self.j={'name':self.rec.title,'scopus_id':self.rec.scopus_id,'issn':self.rec.issn_e,'ranking':{}}
|
| 18 |
+
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'}
|
| 19 |
+
self.mock_oa=types.SimpleNamespace(enrich_journal=AsyncMock(return_value=self.oa))
|
| 20 |
+
self.modules=patch.dict(sys.modules,{'openalex':self.mock_oa});self.modules.start()
|
| 21 |
+
def tearDown(self): self.modules.stop()
|
| 22 |
+
|
| 23 |
+
def test_unsafe_urls(self):
|
| 24 |
+
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']:
|
| 25 |
+
self.assertIsNone(tl.safe_url(url),url)
|
| 26 |
+
self.assertFalse(tl.allowed('https://nature.com.evil.com',['nature.com']))
|
| 27 |
+
self.assertTrue(tl.allowed('https://www.nature.com/nature/',['nature.com']))
|
| 28 |
+
|
| 29 |
+
def test_identity_rejects_conflicting_id_issn_or_title(self):
|
| 30 |
+
self.assertIsNotNone(tl.canonical(self.j))
|
| 31 |
+
self.assertIsNone(tl.canonical({**self.j,'issn':'1234-5678'}))
|
| 32 |
+
self.assertIsNone(tl.canonical({**self.j,'name':'Not Nature'}))
|
| 33 |
+
self.assertIsNone(tl.canonical({'name':'Nature made up journal'}))
|
| 34 |
+
self.assertIsNone(tl.canonical({'scopus_id':'invalid','issn':self.rec.issn_e}))
|
| 35 |
+
|
| 36 |
+
async def test_homepage_metadata_dates_and_no_automatic_discovery(self):
|
| 37 |
+
with patch.object(tl,'fetch_page',side_effect=AssertionError('must not fetch')):
|
| 38 |
+
links=await tl.journal_links(self.j)
|
| 39 |
+
self.assertEqual(links['homepage']['status'],'metadata-only')
|
| 40 |
+
self.assertIsNone(links['homepage']['reviewed_at'])
|
| 41 |
+
self.assertEqual(links['homepage']['source_updated_at'],'2025-12-01')
|
| 42 |
+
self.assertIsNone(links['guidelines']['url'])
|
| 43 |
+
|
| 44 |
+
async def test_wrong_openalex_identity_rejected(self):
|
| 45 |
+
self.mock_oa.enrich_journal.return_value={**self.oa,'issn_l':'12345678','issns':[]}
|
| 46 |
+
self.assertIsNone((await tl.journal_links(self.j))['homepage']['url'])
|
| 47 |
+
|
| 48 |
+
async def test_category_quartiles_and_identifiers(self):
|
| 49 |
+
data=tl.metric_data()
|
| 50 |
+
record=next(r for r in data.values() if len({f['quartile'] for f in r['fields']})>1)
|
| 51 |
+
self.assertGreater(len({f['quartile'] for f in record['fields']}),1)
|
| 52 |
+
self.assertEqual(record['year'],'2025')
|
| 53 |
+
await tl.journal_links(self.j)
|
| 54 |
+
self.assertIn('tip=sid',self.j['verify_links']['scimago'])
|
| 55 |
+
self.assertEqual(self.j['verify_links']['scopus'],'https://www.scopus.com/sourceid/'+self.rec.scopus_id)
|
| 56 |
+
for f in self.j['quartiles_by_field']:
|
| 57 |
+
self.assertEqual(f['profile_url'],self.j['verify_links']['scimago'])
|
| 58 |
+
if f['category_url']: self.assertIn('category='+f['code'],f['category_url'])
|
| 59 |
+
|
| 60 |
+
async def test_missing_canonical_data(self):
|
| 61 |
+
j={'name':'Imaginary Journal','ranking':{}}
|
| 62 |
+
links=await tl.journal_links(j)
|
| 63 |
+
self.assertFalse(links['can_discover']); self.assertIsNone(links['homepage']['url'])
|
| 64 |
+
self.mock_oa.enrich_journal.assert_not_called()
|
| 65 |
+
|
| 66 |
+
async def test_success_cache_and_actual_relative_link(self):
|
| 67 |
+
home='<h1>Nature</h1><a href="/nature/for-authors">Author guidelines</a>'
|
| 68 |
+
guide='<title>Nature — author instructions</title>'
|
| 69 |
+
with patch.object(tl,'fetch_page',side_effect=[(self.oa['homepage_url'],home),('https://www.nature.com/nature/for-authors',guide)]) as fetch:
|
| 70 |
+
first=await tl.discover(self.j); second=await tl.discover(self.j)
|
| 71 |
+
self.assertEqual(fetch.call_count,2)
|
| 72 |
+
self.assertEqual(first['guidelines'],second['guidelines'])
|
| 73 |
+
self.assertEqual(first['guidelines']['scope'],'journal-specific')
|
| 74 |
+
self.assertEqual(first['guidelines']['status'],'checked')
|
| 75 |
+
self.assertTrue(first['guidelines']['reviewed_at'])
|
| 76 |
+
|
| 77 |
+
async def test_general_guidance_label(self):
|
| 78 |
+
with patch.object(tl,'fetch_page',side_effect=[(self.oa['homepage_url'],'Nature <a href="/authors">Guide for authors</a>'),('https://www.nature.com/authors','<title>Publisher author services</title>')]):
|
| 79 |
+
links=await tl.discover(self.j)
|
| 80 |
+
self.assertEqual(links['guidelines']['scope'],'general-publisher')
|
| 81 |
+
|
| 82 |
+
async def test_failed_request_negative_cache_preserves_homepage(self):
|
| 83 |
+
with patch.object(tl,'fetch_page',side_effect=TimeoutError) as fetch:
|
| 84 |
+
first=await tl.discover(self.j); second=await tl.discover(self.j)
|
| 85 |
+
self.assertEqual(fetch.call_count,1)
|
| 86 |
+
self.assertEqual(first,second)
|
| 87 |
+
self.assertEqual(first['homepage']['url'],self.oa['homepage_url'])
|
| 88 |
+
self.assertIsNone(first['guidelines']['reviewed_at'])
|
| 89 |
+
self.assertEqual(first['guidelines']['status'],'unavailable')
|
| 90 |
+
|
| 91 |
+
async def test_page_identity_required(self):
|
| 92 |
+
with patch.object(tl,'fetch_page',return_value=(self.oa['homepage_url'],'<h1>Another Journal</h1><a href="/authors">Guide for authors</a>')) as fetch:
|
| 93 |
+
links=await tl.discover(self.j)
|
| 94 |
+
self.assertIsNone(links['guidelines']['url']); self.assertEqual(fetch.call_count,1)
|
| 95 |
+
|
| 96 |
+
async def test_unsafe_discovered_link_rejected(self):
|
| 97 |
+
with patch.object(tl,'fetch_page',return_value=(self.oa['homepage_url'],'Nature <a href="https://evil.com">Guide for authors</a>')) as fetch:
|
| 98 |
+
links=await tl.discover(self.j)
|
| 99 |
+
self.assertIsNone(links['guidelines']['url']); self.assertEqual(fetch.call_count,1)
|
| 100 |
+
|
| 101 |
+
def test_private_dns_and_redirects(self):
|
| 102 |
+
with patch.object(tl.socket,'getaddrinfo',return_value=[(2,1,6,'',('127.0.0.1',443))]), patch.object(tl,'PinnedHTTPS') as connect:
|
| 103 |
+
with self.assertRaises(ValueError): tl.fetch_page('https://nature.com',['nature.com'])
|
| 104 |
+
connect.assert_not_called()
|
| 105 |
+
fake=types.SimpleNamespace(status=302,getheader=lambda k,*args:'https://evil.com/' if k=='Location' else 'text/html')
|
| 106 |
+
conn=types.SimpleNamespace(request=lambda *a,**k:None,getresponse=lambda:fake,close=lambda:None)
|
| 107 |
+
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:
|
| 108 |
+
with self.assertRaises(ValueError):tl.fetch_page('https://nature.com',['nature.com'])
|
| 109 |
+
self.assertEqual(connect.call_count,1)
|
| 110 |
+
|
| 111 |
+
def test_curated_records_preserve_review_dates(self):
|
| 112 |
+
entry={'issns':[self.rec.issn_e],'url':'https://nature.com/authors','source':'https://nature.com/nature','reviewed_at':'2025-10-01','scope':'general-publisher'}
|
| 113 |
+
with patch.object(tl,'curated_records',return_value={self.rec.scopus_id:entry}):
|
| 114 |
+
self.assertEqual(tl.curated_guidance(self.rec)['reviewed_at'],'2025-10-01')
|
| 115 |
+
self.assertEqual(tl.curated_guidance(self.rec)['status'],'curated')
|
| 116 |
+
with patch.object(tl,'curated_records',return_value={self.rec.scopus_id:{**entry,'issns':['12345678']}}):
|
| 117 |
+
self.assertIsNone(tl.curated_guidance(self.rec))
|
| 118 |
+
|
| 119 |
+
async def test_missing_metadata_is_cached(self):
|
| 120 |
+
self.mock_oa.enrich_journal.return_value=None
|
| 121 |
+
await tl.discover(self.j); await tl.discover(self.j)
|
| 122 |
+
self.assertEqual(self.mock_oa.enrich_journal.await_count,1)
|
| 123 |
+
|
| 124 |
+
async def test_concurrent_discovery_singleflight(self):
|
| 125 |
+
import time
|
| 126 |
+
def failed(*args):
|
| 127 |
+
time.sleep(0.02)
|
| 128 |
+
raise TimeoutError()
|
| 129 |
+
with patch.object(tl,'fetch_page',side_effect=failed) as fetch:
|
| 130 |
+
results=await asyncio.gather(tl.discover(dict(self.j)),tl.discover(dict(self.j)))
|
| 131 |
+
self.assertEqual(fetch.call_count,1)
|
| 132 |
+
self.assertEqual(results[0]['guidelines'],results[1]['guidelines'])
|
| 133 |
+
|
| 134 |
+
def test_expired_cache_and_size_limit(self):
|
| 135 |
+
tl.cache_put('a',tl.unavailable())
|
| 136 |
+
with patch.object(tl.time,'monotonic',return_value=10**15): self.assertIsNone(tl.cached('a'))
|
| 137 |
+
for i in range(600): tl.cache_put(str(i),tl.unavailable())
|
| 138 |
+
self.assertEqual(len(tl._cache),512)
|
| 139 |
+
|
| 140 |
+
def test_repository_choices_are_curated(self):
|
| 141 |
+
repos=hydrate_selection([{'id':'zenodo','url':'https://evil.com'}])
|
| 142 |
+
strategy=hydrate_deposit_strategy({'primary_repository_id':'zenodo','primary_url':'https://evil.com'},repos)
|
| 143 |
+
self.assertEqual(strategy['primary_url'],'https://zenodo.org')
|
| 144 |
+
self.assertIsNone(strategy['secondary_url'])
|
| 145 |
+
|
| 146 |
+
if __name__=='__main__': unittest.main()
|
tests/test_trusted_links_api.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Framework integration tests, executed by the Docker pytest build gate."""
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_discovery_endpoint_accepts_identity_only(monkeypatch):
|
| 7 |
+
captured=[]
|
| 8 |
+
async def fake_discover(value):
|
| 9 |
+
captured.append(value)
|
| 10 |
+
return {'homepage':{'url':None},'guidelines':{'url':None,'status':'unavailable'}}
|
| 11 |
+
monkeypatch.setattr(app,'discover',fake_discover)
|
| 12 |
+
monkeypatch.setattr(app,'check_rate_limit',lambda *args:True)
|
| 13 |
+
response=TestClient(app.app).post('/api/journal-links',json={
|
| 14 |
+
'scopus_id':'21206','url':'https://127.0.0.1/private'})
|
| 15 |
+
assert response.status_code==200
|
| 16 |
+
assert captured==[{'scopus_id':'21206','issn':''}]
|
| 17 |
+
assert response.json()['guidelines']['status']=='unavailable'
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_discovery_endpoint_rate_limited(monkeypatch):
|
| 21 |
+
monkeypatch.setattr(app,'check_rate_limit',lambda *args:False)
|
| 22 |
+
response=TestClient(app.app).post('/api/journal-links',json={'scopus_id':'21206'})
|
| 23 |
+
assert response.status_code==429
|