|
10 | 10 | _pdf_filename, |
11 | 11 | _sanitize_filename, |
12 | 12 | _sniff_content_type, |
| 13 | + _unique_path, |
13 | 14 | fetch_url_to_raw, |
14 | 15 | looks_like_url, |
15 | 16 | ) |
@@ -182,6 +183,9 @@ def get(self, key, default=None): |
182 | 183 | # read(N) returns chunks; read() with no arg returns rest |
183 | 184 | stream = io.BytesIO(body) |
184 | 185 | resp.read = stream.read |
| 186 | + # urllib's HTTPResponse exposes geturl(); default to empty string so |
| 187 | + # callers using `response.geturl() or url` fall through to the input URL. |
| 188 | + resp.geturl = lambda: "" |
185 | 189 | resp.__enter__ = lambda self: resp |
186 | 190 | resp.__exit__ = lambda *a: None |
187 | 191 | return resp |
@@ -359,3 +363,181 @@ def test_fetch_network_error_returns_none(tmp_path, capsys): |
359 | 363 | assert result is None |
360 | 364 | err = capsys.readouterr().err |
361 | 365 | assert "Network error" in err |
| 366 | + |
| 367 | + |
| 368 | +# --------------------------------------------------------------------------- |
| 369 | +# Self-review fixes from PR #55 review pass |
| 370 | +# --------------------------------------------------------------------------- |
| 371 | + |
| 372 | + |
| 373 | +def test_unique_path_returns_target_when_free(tmp_path): |
| 374 | + p = tmp_path / "foo.pdf" |
| 375 | + assert _unique_path(p) == p |
| 376 | + |
| 377 | + |
| 378 | +def test_unique_path_finds_next_free_slot(tmp_path): |
| 379 | + """_2 / _3 / … must be appended to the stem (not the suffix) and |
| 380 | + must keep probing until a free name is found.""" |
| 381 | + (tmp_path / "foo.pdf").write_bytes(b"a") |
| 382 | + (tmp_path / "foo_2.pdf").write_bytes(b"b") |
| 383 | + |
| 384 | + result = _unique_path(tmp_path / "foo.pdf") |
| 385 | + assert result == tmp_path / "foo_3.pdf" |
| 386 | + |
| 387 | + |
| 388 | +def test_unique_path_handles_no_suffix(tmp_path): |
| 389 | + """Files without an extension still get a usable suffix.""" |
| 390 | + (tmp_path / "README").write_text("x") |
| 391 | + result = _unique_path(tmp_path / "README") |
| 392 | + assert result.name == "README_2" |
| 393 | + |
| 394 | + |
| 395 | +def test_fetch_pdf_picks_unique_name_when_target_exists(tmp_path): |
| 396 | + """Two URLs that sanitize to the same filename must NOT silently |
| 397 | + overwrite — the second one gets `_2` appended.""" |
| 398 | + raw_dir = tmp_path / "raw" |
| 399 | + raw_dir.mkdir() |
| 400 | + (raw_dir / "paper.pdf").write_bytes(b"%PDF-existing\nfirst") |
| 401 | + |
| 402 | + body = b"%PDF-1.7\nsecond URL content" |
| 403 | + resp = _fake_response(body=body, headers={"Content-Type": "application/pdf"}) |
| 404 | + # Make response.geturl mimic real urllib (returns the input URL when |
| 405 | + # there's no redirect). The fake response builder doesn't set this. |
| 406 | + resp.geturl = lambda: "https://mirror.example.com/paper.pdf" |
| 407 | + |
| 408 | + with patch("urllib.request.urlopen", return_value=resp): |
| 409 | + result = fetch_url_to_raw("https://mirror.example.com/paper.pdf", tmp_path) |
| 410 | + |
| 411 | + # First file is untouched, second went to paper_2.pdf |
| 412 | + assert (raw_dir / "paper.pdf").read_bytes() == b"%PDF-existing\nfirst" |
| 413 | + assert result == raw_dir / "paper_2.pdf" |
| 414 | + assert result.read_bytes() == body |
| 415 | + |
| 416 | + |
| 417 | +def test_fetch_html_picks_unique_name_when_target_exists(tmp_path): |
| 418 | + """Two blog posts both titled 'Introduction' must NOT collide.""" |
| 419 | + raw_dir = tmp_path / "raw" |
| 420 | + raw_dir.mkdir() |
| 421 | + (raw_dir / "Introduction.md").write_text("first blog post body") |
| 422 | + |
| 423 | + resp = _fake_response(body=b"<html>", headers={"Content-Type": "text/html"}) |
| 424 | + |
| 425 | + second_md = "# Introduction\n\nA completely different second blog post body. " * 10 |
| 426 | + fake_meta = MagicMock() |
| 427 | + fake_meta.title = "Introduction" |
| 428 | + |
| 429 | + with patch("urllib.request.urlopen", return_value=resp), \ |
| 430 | + patch("trafilatura.fetch_url", return_value="<html>...</html>"), \ |
| 431 | + patch("trafilatura.extract", return_value=second_md), \ |
| 432 | + patch("trafilatura.extract_metadata", return_value=fake_meta): |
| 433 | + result = fetch_url_to_raw("https://blog2.example.com/post", tmp_path) |
| 434 | + |
| 435 | + assert (raw_dir / "Introduction.md").read_text() == "first blog post body" |
| 436 | + assert result == raw_dir / "Introduction_2.md" |
| 437 | + assert result.read_text() == second_md |
| 438 | + |
| 439 | + |
| 440 | +def test_fetch_pdf_uses_post_redirect_url_for_filename(tmp_path): |
| 441 | + """When urllib follows a redirect (DOI → publisher CDN, short URLs, |
| 442 | + etc.), the filename must be derived from the final URL — not the |
| 443 | + user's original input — when the response has no Content-Disposition |
| 444 | + to override either.""" |
| 445 | + body = b"%PDF-1.7\n..." |
| 446 | + resp = _fake_response( |
| 447 | + body=body, |
| 448 | + headers={"Content-Type": "application/pdf"}, # NO Content-Disposition |
| 449 | + ) |
| 450 | + # urllib's HTTPResponse.geturl() returns the post-redirect URL |
| 451 | + resp.geturl = lambda: "https://publisher.example.com/articles/2024/great-paper.pdf" |
| 452 | + |
| 453 | + with patch("urllib.request.urlopen", return_value=resp): |
| 454 | + result = fetch_url_to_raw("https://doi.org/10.1234/abc", tmp_path) |
| 455 | + |
| 456 | + # Filename comes from the redirected URL's basename, not "abc" |
| 457 | + assert result is not None |
| 458 | + assert result.name == "great-paper.pdf" |
| 459 | + |
| 460 | + |
| 461 | +def test_add_single_file_returns_true_on_success(tmp_path): |
| 462 | + """The new bool return contract: True when the file was actually |
| 463 | + indexed. Used by the URL-ingest cleanup path to decide whether the |
| 464 | + just-downloaded file in raw/ should be unlinked.""" |
| 465 | + from openkb.cli import add_single_file |
| 466 | + from openkb.converter import ConvertResult |
| 467 | + |
| 468 | + # Build a minimal KB scaffold |
| 469 | + (tmp_path / ".openkb").mkdir() |
| 470 | + (tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n") |
| 471 | + (tmp_path / ".openkb" / "hashes.json").write_text("{}") |
| 472 | + (tmp_path / "raw").mkdir() |
| 473 | + (tmp_path / "wiki" / "summaries").mkdir(parents=True) |
| 474 | + (tmp_path / "wiki" / "sources").mkdir(parents=True) |
| 475 | + (tmp_path / "wiki" / "concepts").mkdir(parents=True) |
| 476 | + (tmp_path / "wiki" / "log.md").write_text("") |
| 477 | + |
| 478 | + doc = tmp_path / "raw" / "x.md" |
| 479 | + doc.write_text("# Hello") |
| 480 | + source_path = tmp_path / "wiki" / "sources" / "x.md" |
| 481 | + source_path.write_text("# Hello converted") |
| 482 | + |
| 483 | + mock_result = ConvertResult( |
| 484 | + raw_path=doc, source_path=source_path, |
| 485 | + is_long_doc=False, file_hash="cafe" * 16, |
| 486 | + ) |
| 487 | + |
| 488 | + with patch("openkb.cli.convert_document", return_value=mock_result), \ |
| 489 | + patch("openkb.cli.asyncio.run"): |
| 490 | + added = add_single_file(doc, tmp_path) |
| 491 | + |
| 492 | + assert added is True |
| 493 | + |
| 494 | + |
| 495 | +def test_add_single_file_returns_false_on_skip(tmp_path): |
| 496 | + from openkb.cli import add_single_file |
| 497 | + from openkb.converter import ConvertResult |
| 498 | + |
| 499 | + (tmp_path / ".openkb").mkdir() |
| 500 | + (tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n") |
| 501 | + (tmp_path / ".openkb" / "hashes.json").write_text("{}") |
| 502 | + (tmp_path / "raw").mkdir() |
| 503 | + doc = tmp_path / "raw" / "x.md" |
| 504 | + doc.write_text("# Hello") |
| 505 | + |
| 506 | + skipped = ConvertResult(skipped=True) |
| 507 | + with patch("openkb.cli.convert_document", return_value=skipped): |
| 508 | + added = add_single_file(doc, tmp_path) |
| 509 | + |
| 510 | + assert added is False |
| 511 | + |
| 512 | + |
| 513 | +def test_url_ingest_cleans_up_orphan_on_dedup_skip(tmp_path, monkeypatch): |
| 514 | + """End-to-end: when the URL-fetched file is already in the registry, |
| 515 | + add_single_file returns False and the CLI must unlink it from raw/ |
| 516 | + so the user doesn't accumulate untracked duplicates.""" |
| 517 | + from click.testing import CliRunner |
| 518 | + from openkb.cli import cli |
| 519 | + from openkb.converter import ConvertResult |
| 520 | + |
| 521 | + # Minimal KB |
| 522 | + (tmp_path / ".openkb").mkdir() |
| 523 | + (tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n") |
| 524 | + (tmp_path / ".openkb" / "hashes.json").write_text("{}") |
| 525 | + (tmp_path / "raw").mkdir() |
| 526 | + |
| 527 | + # Fake the URL fetch — write directly to where url_ingest would |
| 528 | + fetched_path = tmp_path / "raw" / "paper.pdf" |
| 529 | + fetched_path.write_bytes(b"%PDF-fake") |
| 530 | + |
| 531 | + runner = CliRunner() |
| 532 | + # fetch_url_to_raw is lazy-imported inside `add`, so patch it at the |
| 533 | + # source module — that's where the `from ... import` resolves. |
| 534 | + with patch("openkb.cli._find_kb_dir", return_value=tmp_path), \ |
| 535 | + patch("openkb.url_ingest.fetch_url_to_raw", return_value=fetched_path), \ |
| 536 | + patch("openkb.cli.convert_document", |
| 537 | + return_value=ConvertResult(skipped=True)): |
| 538 | + result = runner.invoke(cli, ["add", "https://example.com/paper.pdf"]) |
| 539 | + |
| 540 | + assert result.exit_code == 0, result.output |
| 541 | + assert "[SKIP]" in result.output |
| 542 | + # Orphan cleanup: the URL-fetched file must be gone from raw/. |
| 543 | + assert not fetched_path.exists() |
0 commit comments