nikeshn commited on
Commit
3685d4c
·
verified ·
1 Parent(s): 4b2991e

Upload 11 files

Browse files
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