| null = null;
+ watch(searchQuery, () => {
+ if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = setTimeout(() => {
+ resetPageAndSnapshot();
+ void loadPublications();
+ }, 300);
+ });
+
+ onScopeDispose(() => {
+ if (searchDebounceTimer !== null) clearTimeout(searchDebounceTimer);
+ });
+
+ // --- Run-triggered refresh watcher ---
+
+ watch(
+ () => runStatus.latestRun,
+ async (nextRun, previousRun) => {
+ const nextRunId = nextRun && (nextRun.status === "running" || nextRun.status === "resolving") ? nextRun.id : null;
+ const previousRunId = previousRun && (previousRun.status === "running" || previousRun.status === "resolving") ? previousRun.id : null;
+ if (nextRunId === null || nextRunId === previousRunId) return;
+ resetPageAndSnapshot();
+ await loadPublications();
+ },
+ );
+
+ // --- Route watcher ---
+
+ watch(
+ () => [route.query.scholar, route.query.favorite, route.query.page],
+ async () => {
+ const previousScholar = selectedScholarFilter.value;
+ const previousFavorite = favoriteOnly.value;
+ const changed = syncFiltersFromRoute();
+ if (!changed) return;
+ if (selectedScholarFilter.value !== previousScholar || favoriteOnly.value !== previousFavorite) {
+ publicationSnapshot.value = null;
+ }
+ await loadPublications();
+ },
+ );
+
+ return {
+ loading,
+ mode,
+ favoriteOnly,
+ selectedScholarFilter,
+ searchQuery,
+ sortKey,
+ sortDirection,
+ currentPage,
+ pageSize,
+ scholars,
+ listState,
+ errorMessage,
+ errorRequestId,
+ successMessage,
+ selectedScholarName,
+ sortedPublications,
+ visibleUnreadKeys,
+ pageSizeValue,
+ hasNextPage,
+ hasPrevPage,
+ totalPages,
+ totalCount,
+ visibleCount,
+ visibleUnreadCount,
+ visibleFavoriteCount,
+ loadScholarFilters,
+ loadPublications,
+ resetPageAndSnapshot,
+ toggleSort,
+ sortMarker,
+ replacePublication,
+ syncFiltersFromRoute,
+ syncFiltersToRoute,
+ };
+}
diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts
index 2eddfbb..a689ba9 100644
--- a/frontend/src/features/publications/index.ts
+++ b/frontend/src/features/publications/index.ts
@@ -1,6 +1,21 @@
import { apiRequest } from "@/lib/api/client";
export type PublicationMode = "all" | "unread" | "latest";
+export type PublicationSortBy =
+ | "first_seen"
+ | "title"
+ | "year"
+ | "citations"
+ | "scholar"
+ | "pdf_status";
+
+export interface DisplayIdentifier {
+ kind: string;
+ value: string;
+ label: string;
+ url: string | null;
+ confidence_score: number;
+}
export interface PublicationItem {
publication_id: number;
@@ -11,7 +26,7 @@ export interface PublicationItem {
citation_count: number;
venue_text: string | null;
pub_url: string | null;
- doi: string | null;
+ display_identifier: DisplayIdentifier | null;
pdf_url: string | null;
pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed";
pdf_attempt_count: number;
@@ -35,6 +50,7 @@ export interface PublicationsResult {
total_count: number;
page: number;
page_size: number;
+ snapshot: string;
has_next: boolean;
has_prev: boolean;
publications: PublicationItem[];
@@ -44,8 +60,12 @@ export interface PublicationsQuery {
mode?: PublicationMode;
favoriteOnly?: boolean;
scholarProfileId?: number;
+ search?: string;
+ sortBy?: PublicationSortBy;
+ sortDir?: "asc" | "desc";
page?: number;
pageSize?: number;
+ snapshot?: string;
}
export interface PublicationSelection {
@@ -65,12 +85,24 @@ export async function listPublications(query: PublicationsQuery = {}): Promise 0) {
+ params.set("search", query.search.trim());
+ }
+ if (query.sortBy) {
+ params.set("sort_by", query.sortBy);
+ }
+ if (query.sortDir) {
+ params.set("sort_dir", query.sortDir);
+ }
const parsedPage = Number.isFinite(query.page) ? Math.max(1, Math.trunc(Number(query.page))) : 1;
const parsedPageSize = Number.isFinite(query.pageSize)
? Math.max(1, Math.min(500, Math.trunc(Number(query.pageSize))))
: 100;
params.set("page", String(parsedPage));
params.set("page_size", String(parsedPageSize));
+ if (query.snapshot && query.snapshot.trim().length > 0) {
+ params.set("snapshot", query.snapshot.trim());
+ }
const suffix = params.toString();
const response = await apiRequest(
diff --git a/frontend/src/features/runs/index.ts b/frontend/src/features/runs/index.ts
index 97d49c6..735776d 100644
--- a/frontend/src/features/runs/index.ts
+++ b/frontend/src/features/runs/index.ts
@@ -145,6 +145,13 @@ export async function triggerManualRun(): Promise<{
return response.data;
}
+export async function cancelRun(runId: number): Promise {
+ const response = await apiRequest(`/runs/${runId}/cancel`, {
+ method: "POST",
+ });
+ return response.data;
+}
+
export async function listQueueItems(limit = 200): Promise {
const response = await apiRequest(`/runs/queue/items?limit=${limit}`, {
method: "GET",
diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts
new file mode 100644
index 0000000..2d76eac
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts
@@ -0,0 +1,76 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { mount } from "@vue/test-utils";
+import ScholarBatchAdd from "./ScholarBatchAdd.vue";
+
+const defaultProps = { saving: false, loading: false };
+
+describe("ScholarBatchAdd", () => {
+ it("renders the heading and input", () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ expect(wrapper.text()).toContain("Add Scholar Profiles");
+ expect(wrapper.find("textarea").exists()).toBe(true);
+ });
+
+ it("shows parsed ID count as 0 for empty input", () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ expect(wrapper.text()).toContain("Parsed IDs:");
+ });
+
+ it("parses bare scholar IDs from textarea input", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue("A-UbBTPM15wL");
+ expect(wrapper.text()).toContain("1");
+ });
+
+ it("parses scholar IDs from Google Scholar URLs", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue(
+ "https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL",
+ );
+ expect(wrapper.text()).toContain("1");
+ });
+
+ it("deduplicates IDs from mixed input", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue(
+ "A-UbBTPM15wL\nhttps://scholar.google.com/citations?user=A-UbBTPM15wL",
+ );
+ expect(wrapper.text()).toContain("1");
+ });
+
+ it("parses multiple IDs separated by commas", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue("A-UbBTPM15wL, B-UbBTPM15wL");
+ expect(wrapper.text()).toContain("2");
+ });
+
+ it("emits add-scholars with parsed IDs on submit", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue("A-UbBTPM15wL");
+ await wrapper.find("form").trigger("submit");
+ const emitted = wrapper.emitted("add-scholars");
+ expect(emitted).toHaveLength(1);
+ expect(emitted![0][0]).toEqual(["A-UbBTPM15wL"]);
+ });
+
+ it("clears textarea after successful submit", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ const textarea = wrapper.find("textarea");
+ await textarea.setValue("A-UbBTPM15wL");
+ await wrapper.find("form").trigger("submit");
+ expect((textarea.element as HTMLTextAreaElement).value).toBe("");
+ });
+
+ it("does not emit when input contains no valid IDs", async () => {
+ const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
+ await wrapper.find("textarea").setValue("not-a-valid-id");
+ await wrapper.find("form").trigger("submit");
+ expect(wrapper.emitted("add-scholars")).toBeUndefined();
+ });
+
+ it("shows Adding... label when saving prop is true", () => {
+ const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
+ expect(wrapper.text()).toContain("Adding...");
+ });
+});
diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.vue b/frontend/src/features/scholars/components/ScholarBatchAdd.vue
new file mode 100644
index 0000000..bfafbda
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarBatchAdd.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
Add Scholar Profiles
+
+
+
Paste one or more Scholar IDs or profile URLs and add them in one action.
+
+
+
+
+
diff --git a/frontend/src/features/scholars/components/ScholarNameSearch.test.ts b/frontend/src/features/scholars/components/ScholarNameSearch.test.ts
new file mode 100644
index 0000000..56873f1
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarNameSearch.test.ts
@@ -0,0 +1,43 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { mount } from "@vue/test-utils";
+import ScholarNameSearch from "./ScholarNameSearch.vue";
+
+const defaultProps = {
+ trackedScholarIds: new Set(),
+ addingCandidateScholarId: null,
+};
+
+describe("ScholarNameSearch", () => {
+ it("renders the search heading", () => {
+ const wrapper = mount(ScholarNameSearch, {
+ props: defaultProps,
+ global: { stubs: { ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).toContain("Search by Name");
+ });
+
+ it("shows WIP badge indicating the feature is incomplete", () => {
+ const wrapper = mount(ScholarNameSearch, {
+ props: defaultProps,
+ global: { stubs: { ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).toContain("WIP");
+ });
+
+ it("renders the search input", () => {
+ const wrapper = mount(ScholarNameSearch, {
+ props: defaultProps,
+ global: { stubs: { ScholarAvatar: true } },
+ });
+ expect(wrapper.find("form").exists()).toBe(true);
+ });
+
+ it("shows warning about Google Scholar login challenges", () => {
+ const wrapper = mount(ScholarNameSearch, {
+ props: defaultProps,
+ global: { stubs: { ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).toContain("login challenge");
+ });
+});
diff --git a/frontend/src/features/scholars/components/ScholarNameSearch.vue b/frontend/src/features/scholars/components/ScholarNameSearch.vue
new file mode 100644
index 0000000..4a82af2
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarNameSearch.vue
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+ This helper remains paused because Google Scholar currently requires account login for reliable name search access.
+
+
+
+
+ WIP
+
+
+
+
+
+
+
+ Search
+
+
+
+ Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
+
+
+
+
+
+
+
+
+
+ {{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
+ for {{ searchResult.query }}
+
+
{{ searchResult.state }}
+
+
+
+ State reason: {{ searchResult.state_reason }}
+ . {{ searchResult.action_hint }}
+ . Warnings: {{ searchResult.warnings.join(", ") }}
+
+
+
+ Name search is degraded
+ This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
+
+
+
+
+
+
+
+
+
+
{{ candidate.display_name }}
+
{{ candidate.scholar_id }}
+
Tracked
+
+
{{ candidate.affiliation || "No affiliation provided" }}
+
+ Email: {{ candidate.email_domain }}
+ Cited by: {{ candidate.cited_by_count }}
+
+
+
+
+ {{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
+
+
+ Open profile
+
+
+
+
+
+
+
+
+ Name search remains disabled while login-gated responses are unresolved.
+
+
+
+
+
diff --git a/frontend/src/features/scholars/components/ScholarSettingsModal.test.ts b/frontend/src/features/scholars/components/ScholarSettingsModal.test.ts
new file mode 100644
index 0000000..6a3830b
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarSettingsModal.test.ts
@@ -0,0 +1,92 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+import { mount } from "@vue/test-utils";
+import ScholarSettingsModal from "./ScholarSettingsModal.vue";
+import type { ScholarProfile } from "@/features/scholars";
+
+function buildScholar(overrides: Partial = {}): ScholarProfile {
+ return {
+ id: 1,
+ scholar_id: "abcDEF123456",
+ display_name: "Dr. Test Scholar",
+ profile_image_url: null,
+ profile_image_source: "none" as const,
+ is_enabled: true,
+ baseline_completed: true,
+ last_run_dt: null,
+ last_run_status: null,
+ ...overrides,
+ };
+}
+
+const defaultProps = {
+ scholar: buildScholar(),
+ imageUrlDraft: "",
+ imageBusy: false,
+ imageSaving: false,
+ imageUploading: false,
+ saving: false,
+};
+
+describe("ScholarSettingsModal", () => {
+ it("renders scholar name and ID when scholar is provided", () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: defaultProps,
+ global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).toContain("Dr. Test Scholar");
+ expect(wrapper.text()).toContain("abcDEF123456");
+ });
+
+ it("renders nothing when scholar is null", () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: { ...defaultProps, scholar: null },
+ global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).not.toContain("Dr. Test Scholar");
+ });
+
+ it("emits close when close event fires", async () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: defaultProps,
+ global: { stubs: { RouterLink: true, ScholarAvatar: true } },
+ });
+ const modal = wrapper.findComponent({ name: "AppModal" });
+ if (modal.exists()) {
+ modal.vm.$emit("close");
+ expect(wrapper.emitted("close")).toHaveLength(1);
+ }
+ });
+
+ it("emits delete when delete button is clicked", async () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: defaultProps,
+ global: { stubs: { RouterLink: true, ScholarAvatar: true } },
+ });
+ const deleteButton = wrapper.findAll("button").find((b) => b.text().includes("Delete"));
+ if (deleteButton) {
+ await deleteButton.trigger("click");
+ expect(wrapper.emitted("delete")).toHaveLength(1);
+ }
+ });
+
+ it("emits toggle when enable/disable button is clicked", async () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: defaultProps,
+ global: { stubs: { RouterLink: true, ScholarAvatar: true } },
+ });
+ const toggleButton = wrapper.findAll("button").find((b) => b.text().includes("Disable"));
+ if (toggleButton) {
+ await toggleButton.trigger("click");
+ expect(wrapper.emitted("toggle")).toHaveLength(1);
+ }
+ });
+
+ it("shows Enable button for disabled scholars", () => {
+ const wrapper = mount(ScholarSettingsModal, {
+ props: { ...defaultProps, scholar: buildScholar({ is_enabled: false }) },
+ global: { stubs: { RouterLink: true, ScholarAvatar: true } },
+ });
+ expect(wrapper.text()).toContain("Enable");
+ });
+});
diff --git a/frontend/src/features/scholars/components/ScholarSettingsModal.vue b/frontend/src/features/scholars/components/ScholarSettingsModal.vue
new file mode 100644
index 0000000..4a129f8
--- /dev/null
+++ b/frontend/src/features/scholars/components/ScholarSettingsModal.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
{{ scholarLabel(scholar) }}
+
ID: {{ scholar.scholar_id }}
+
+
+
+
+
+
+ Profile image URL override
+
+
+
+
+ {{ imageSaving ? "Saving..." : "Save URL" }}
+
+
+
+
+ {{ imageUploading ? "Uploading..." : "Upload image" }}
+
+
+
+ Reset image
+
+
+
+
+
+
+ {{ scholar.is_enabled ? "Disable scholar" : "Enable scholar" }}
+
+
+ Delete scholar
+
+
+
+
+
diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts
index db19e76..ef3c8c4 100644
--- a/frontend/src/features/scholars/index.ts
+++ b/frontend/src/features/scholars/index.ts
@@ -54,7 +54,6 @@ export interface PublicationExportItem {
author_text: string | null;
venue_text: string | null;
pub_url: string | null;
- doi: string | null;
pdf_url: string | null;
is_read: boolean;
}
@@ -86,7 +85,7 @@ interface ScholarsListData {
scholars: ScholarProfile[];
}
-interface ScholarSearchData extends ScholarSearchResult {}
+interface ScholarSearchData extends ScholarSearchResult { }
export async function listScholars(): Promise {
const response = await apiRequest("/scholars", { method: "GET" });
diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue
index 7f5788e..b306d1e 100644
--- a/frontend/src/features/settings/SettingsAdminPanel.vue
+++ b/frontend/src/features/settings/SettingsAdminPanel.vue
@@ -1,439 +1,51 @@
@@ -473,354 +73,10 @@ watch(
/>
-
-
-
-
-
- Email
-
-
-
- Password
-
-
-
-
Role
-
-
-
- {{ creating ? "Creating..." : "Create user" }}
-
-
-
-
-
-
-
-
- Email
- Role
- Status
- Actions
-
-
-
-
-
-
-
- {{ user.email }}
-
-
- {{ userRoleLabel(user) }}
- {{ user.is_active ? "active" : "inactive" }}
-
-
- {{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
-
- Manage
-
-
-
-
-
-
-
-
-
-
-
Status: {{ integrityReport.status }}
-
Warnings: {{ integrityReport.warnings.length }}
-
Failures: {{ integrityReport.failures.length }}
-
Checked: {{ formatTimestamp(integrityReport.checked_at) }}
-
-
-
-
-
- Check
- Count
- Severity
- Message
-
-
-
-
- {{ check.name }}
- {{ check.count }}
- {{ check.severity }}
- {{ check.message }}
-
-
-
-
-
-
-
-
-
Publication Link Repair
-
-
-
-
-
- Scope
-
- Single user
- All users
-
-
-
- Target user
-
- Select user
-
- {{ user.email }} (ID {{ user.id }})
-
-
-
-
- Scholar profile IDs (optional)
-
-
-
- Requested by (optional)
-
-
-
-
- Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm
-
-
-
- All-users scope includes every scholar profile across all accounts.
-
-
-
- {{ runningRepair ? "Running..." : "Run repair job" }}
-
-
-
-
-
-
-
Job #{{ lastRepairResult.job_id }}
-
Status: {{ lastRepairResult.status }}
-
-
{{ JSON.stringify(lastRepairResult.summary, null, 2) }}
-
-
-
-
-
-
-
-
- Job
- Status
- Mode
- Requested by
- Created
- Links in scope
- Links deleted
-
-
-
-
- #{{ job.id }} · {{ job.job_name }}
- {{ job.status }}
- {{ job.dry_run ? "dry-run" : "apply" }}
- {{ job.requested_by || "n/a" }}
- {{ formatTimestamp(job.created_at) }}
- {{ summaryCount(job, "links_in_scope") }}
- {{ summaryCount(job, "links_deleted") }}
-
-
-
-
-
-
-
-
-
-
PDF Gathering Queue
-
-
-
-
- All statuses
- untracked
- queued
- running
- failed
- resolved
-
-
- 25 / page
- 50 / page
- 100 / page
-
-
- {{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
-
-
-
-
-
-
-
-
- Publication
- Status
- Attempts
- Failure reason
- Source
- Requested by
- Queued
- Last attempt
- Resolved
- Actions
-
-
-
-
-
-
-
- {{ item.status }}
- {{ item.attempt_count }}
- {{ item.last_failure_reason || "n/a" }}
- {{ item.last_source || "n/a" }}
- {{ item.requested_by_email || "n/a" }}
- {{ formatTimestamp(item.queued_at) }}
- {{ formatTimestamp(item.last_attempt_at) }}
- {{ formatTimestamp(item.resolved_at) }}
-
-
- {{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
-
-
-
-
-
-
-
-
{{ pdfQueueSummary }}
-
-
Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}
-
Prev
-
Next
-
-
-
+
+
+
+
-
-
-
-
-
-
-
{{ activeUser.email }}
-
-
Role: {{ userRoleLabel(activeUser) }}
-
Last updated: {{ formatTimestamp(activeUser.updated_at) }}
-
-
-
-
- New password
-
-
-
-
- {{ resettingPassword ? "Resetting..." : "Reset password" }}
-
-
- {{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
-
-
-
-
-
diff --git a/frontend/src/features/settings/components/AdminIntegritySection.test.ts b/frontend/src/features/settings/components/AdminIntegritySection.test.ts
new file mode 100644
index 0000000..c69ab73
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminIntegritySection.test.ts
@@ -0,0 +1,72 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import AdminIntegritySection from "./AdminIntegritySection.vue";
+
+vi.mock("@/features/admin_dbops", () => ({
+ getAdminDbIntegrityReport: vi.fn(),
+}));
+
+import { getAdminDbIntegrityReport, type AdminDbIntegrityReport } from "@/features/admin_dbops";
+
+const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
+
+function buildReport(overrides: Partial = {}): AdminDbIntegrityReport {
+ return {
+ status: "ok",
+ warnings: [],
+ failures: [],
+ checked_at: "2025-01-15T12:00:00Z",
+ checks: [
+ { name: "orphaned_publications", count: 0, severity: "metric", message: "No orphaned publications found." },
+ ],
+ ...overrides,
+ };
+}
+
+describe("AdminIntegritySection", () => {
+ beforeEach(() => {
+ mockedGetReport.mockReset();
+ });
+
+ it("renders the section heading", () => {
+ const wrapper = mount(AdminIntegritySection);
+ expect(wrapper.text()).toContain("Integrity Report");
+ });
+
+ it("exposes load function that fetches integrity report", async () => {
+ mockedGetReport.mockResolvedValueOnce(buildReport());
+ const wrapper = mount(AdminIntegritySection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(mockedGetReport).toHaveBeenCalled();
+ });
+
+ it("renders check results after loading", async () => {
+ mockedGetReport.mockResolvedValueOnce(buildReport());
+ const wrapper = mount(AdminIntegritySection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("orphaned_publications");
+ });
+
+ it("displays warning count when present", async () => {
+ mockedGetReport.mockResolvedValueOnce(buildReport({ warnings: ["w1", "w2", "w3"] }));
+ const wrapper = mount(AdminIntegritySection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("3");
+ });
+
+ it("renders status badge", async () => {
+ mockedGetReport.mockResolvedValueOnce(buildReport({ status: "failed" }));
+ const wrapper = mount(AdminIntegritySection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("failed");
+ });
+});
diff --git a/frontend/src/features/settings/components/AdminIntegritySection.vue b/frontend/src/features/settings/components/AdminIntegritySection.vue
new file mode 100644
index 0000000..2c536d5
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminIntegritySection.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
Status: {{ integrityReport.status }}
+
Warnings: {{ integrityReport.warnings.length }}
+
Failures: {{ integrityReport.failures.length }}
+
Checked: {{ formatTimestamp(integrityReport.checked_at) }}
+
+
+
+
+
+ Check
+ Count
+ Severity
+ Message
+
+
+
+
+ {{ check.name }}
+ {{ check.count }}
+ {{ check.severity }}
+ {{ check.message }}
+
+
+
+
+
diff --git a/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts b/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts
new file mode 100644
index 0000000..bdd0793
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts
@@ -0,0 +1,104 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
+
+vi.mock("@/features/admin_dbops", () => ({
+ listAdminPdfQueue: vi.fn(),
+ requeueAdminPdfLookup: vi.fn(),
+ requeueAllAdminPdfLookups: vi.fn(),
+}));
+
+import { listAdminPdfQueue } from "@/features/admin_dbops";
+
+const mockedListQueue = vi.mocked(listAdminPdfQueue);
+
+function buildQueueItem(overrides: Record = {}) {
+ return {
+ publication_id: 1,
+ title: "Test Paper",
+ display_identifier: null,
+ pdf_url: null,
+ status: "queued",
+ attempt_count: 0,
+ last_failure_reason: null,
+ last_failure_detail: null,
+ last_source: "unpaywall",
+ requested_by_user_id: null,
+ requested_by_email: null,
+ queued_at: "2025-01-15T12:00:00Z",
+ last_attempt_at: null,
+ resolved_at: null,
+ updated_at: "2025-01-15T12:00:00Z",
+ ...overrides,
+ };
+}
+
+describe("AdminPdfQueueSection", () => {
+ beforeEach(() => {
+ mockedListQueue.mockReset();
+ });
+
+ it("renders the PDF queue heading", () => {
+ const wrapper = mount(AdminPdfQueueSection);
+ expect(wrapper.text()).toContain("PDF");
+ });
+
+ it("exposes load function that fetches PDF queue", async () => {
+ mockedListQueue.mockResolvedValueOnce({
+ items: [buildQueueItem()],
+ total_count: 1,
+ has_next: false,
+ has_prev: false,
+ page: 1,
+ page_size: 50,
+ });
+ const wrapper = mount(AdminPdfQueueSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(mockedListQueue).toHaveBeenCalled();
+ });
+
+ it("renders queue items after loading", async () => {
+ mockedListQueue.mockResolvedValueOnce({
+ items: [buildQueueItem({ title: "My Paper" })],
+ total_count: 1,
+ has_next: false,
+ has_prev: false,
+ page: 1,
+ page_size: 50,
+ });
+ const wrapper = mount(AdminPdfQueueSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("My Paper");
+ });
+
+ it("renders status filter dropdown", () => {
+ const wrapper = mount(AdminPdfQueueSection);
+ expect(wrapper.text()).toContain("Status");
+ });
+
+ it("renders queue all button", () => {
+ const wrapper = mount(AdminPdfQueueSection);
+ expect(wrapper.text()).toContain("Queue all");
+ });
+
+ it("shows pagination summary after loading", async () => {
+ mockedListQueue.mockResolvedValueOnce({
+ items: Array.from({ length: 3 }, (_, i) => buildQueueItem({ publication_id: i + 1 })),
+ total_count: 3,
+ has_next: false,
+ has_prev: false,
+ page: 1,
+ page_size: 50,
+ });
+ const wrapper = mount(AdminPdfQueueSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("3");
+ });
+});
diff --git a/frontend/src/features/settings/components/AdminPdfQueueSection.vue b/frontend/src/features/settings/components/AdminPdfQueueSection.vue
new file mode 100644
index 0000000..fdeb98a
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminPdfQueueSection.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
PDF Gathering Queue
+
+
+
+
+ All statuses
+ untracked
+ queued
+ running
+ failed
+ resolved
+
+
+ 25 / page
+ 50 / page
+ 100 / page
+
+
+ {{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
+
+
+
+
+
+
+
+
+ Publication Status Attempts
+ Failure reason Source Requested by
+ Queued Last attempt Resolved Actions
+
+
+
+
+
+
+
+ {{ item.status }}
+ {{ item.attempt_count }}
+ {{ item.last_failure_reason || "n/a" }}
+ {{ item.last_source || "n/a" }}
+ {{ item.requested_by_email || "n/a" }}
+ {{ formatTimestamp(item.queued_at) }}
+ {{ formatTimestamp(item.last_attempt_at) }}
+ {{ formatTimestamp(item.resolved_at) }}
+
+
+ {{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
+
+
+
+
+
+
+
+
{{ pdfQueueSummary }}
+
+
Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}
+
Prev
+
Next
+
+
+
+
+
diff --git a/frontend/src/features/settings/components/AdminRepairsSection.test.ts b/frontend/src/features/settings/components/AdminRepairsSection.test.ts
new file mode 100644
index 0000000..c7c4685
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminRepairsSection.test.ts
@@ -0,0 +1,91 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import AdminRepairsSection from "./AdminRepairsSection.vue";
+
+vi.mock("@/features/admin_dbops", () => ({
+ listAdminDbRepairJobs: vi.fn(),
+ triggerPublicationLinkRepair: vi.fn(),
+ triggerPublicationNearDuplicateRepair: vi.fn(),
+ dropAllPublications: vi.fn(),
+}));
+
+import { listAdminDbRepairJobs, dropAllPublications } from "@/features/admin_dbops";
+
+const mockedListJobs = vi.mocked(listAdminDbRepairJobs);
+const mockedDrop = vi.mocked(dropAllPublications);
+
+function buildUser(overrides: Record = {}) {
+ return {
+ id: 1,
+ email: "admin@test.com",
+ is_admin: true,
+ is_active: true,
+ created_at: "2025-01-01T00:00:00Z",
+ updated_at: "2025-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+describe("AdminRepairsSection", () => {
+ beforeEach(() => {
+ mockedListJobs.mockReset();
+ mockedDrop.mockReset();
+ });
+
+ it("renders the repair sections", () => {
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ expect(wrapper.text()).toContain("Publication Link Repair");
+ expect(wrapper.text()).toContain("Near-Duplicate");
+ expect(wrapper.text()).toContain("Drop All Publications");
+ });
+
+ it("exposes load function that fetches repair jobs", async () => {
+ mockedListJobs.mockResolvedValueOnce([]);
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(mockedListJobs).toHaveBeenCalled();
+ });
+
+ it("renders repair job rows after loading", async () => {
+ mockedListJobs.mockResolvedValueOnce([
+ {
+ id: 1,
+ job_name: "publication_link_repair",
+ status: "completed",
+ dry_run: true,
+ requested_by: "admin@test.com",
+ scope: {},
+ summary: { links_in_scope: 100, links_deleted: 5 },
+ error_text: null,
+ started_at: "2025-01-15T12:00:00Z",
+ finished_at: "2025-01-15T12:01:00Z",
+ created_at: "2025-01-15T12:00:00Z",
+ updated_at: "2025-01-15T12:01:00Z",
+ },
+ ]);
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("publication_link_repair");
+ });
+
+ it("has typed confirmation guard for drop all publications", () => {
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ const dropButton = wrapper.findAll("button").find((b) => b.text().includes("Drop all publications"));
+ expect(dropButton?.attributes("disabled")).toBeDefined();
+ });
+
+ it("renders dry-run toggle in repair form", () => {
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ expect(wrapper.text()).toContain("Dry-run");
+ });
+
+ it("renders scope mode selector", () => {
+ const wrapper = mount(AdminRepairsSection, { props: { users: [buildUser()] } });
+ expect(wrapper.text()).toContain("Scope");
+ });
+});
diff --git a/frontend/src/features/settings/components/AdminRepairsSection.vue b/frontend/src/features/settings/components/AdminRepairsSection.vue
new file mode 100644
index 0000000..05eab7a
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminRepairsSection.vue
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+
+
+
Publication Link Repair
+
+
+
+
+ Scope
+
+ Single user
+ All users
+
+
+
+ Target user
+
+ Select user
+ {{ user.email }} (ID {{ user.id }})
+
+
+
+ Scholar profile IDs (optional)
+
+
+
+ Requested by (optional)
+
+
+
+
+ Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm
+
+
+ All-users scope includes every scholar profile across all accounts.
+
+
{{ runningRepair ? "Running..." : "Run repair job" }}
+
+
+
+
+
Job #{{ lastRepairResult.job_id }}
+
Status: {{ lastRepairResult.status }}
+
+
{{ JSON.stringify(lastRepairResult.summary, null, 2) }}
+
+
+
+
+
+
Near-Duplicate Publication Repair
+
+
+
+ Similarity threshold
+ Min shared tokens
+ Max year delta
+ Max preview clusters
+ Requested by (optional)
+
+
{{ runningNearDuplicateScan ? "Scanning..." : "Scan near-duplicate clusters" }}
+
+
+
+
+ Select Cluster Winner Members Similarity
+
+
+
+ {{ cluster.cluster_key }}
+ #{{ cluster.winner_publication_id }}
+ #{{ member.publication_id }} · {{ member.title }}
+ {{ cluster.similarity_score.toFixed(2) }}
+
+
+
+
+ Type '{{ APPLY_NEAR_DUPLICATES_CONFIRM_TEXT }}' to merge selected clusters
+ {{ applyingNearDuplicateRepair ? "Merging..." : "Merge selected clusters" }}
+
+
+
+
Job #{{ lastNearDuplicateResult.job_id }} Status: {{ lastNearDuplicateResult.status }}
+
{{ JSON.stringify(lastNearDuplicateResult.summary, null, 2) }}
+
+
+
+
+
+
+ Job Status Mode Requested by Created Links in scope Links deleted
+
+
+ #{{ job.id }} · {{ job.job_name }}
+ {{ job.status }}
+ {{ job.dry_run ? "dry-run" : "apply" }}
+ {{ job.requested_by || "n/a" }}
+ {{ formatTimestamp(job.created_at) }}
+ {{ summaryCount(job, "links_in_scope") }}
+ {{ summaryCount(job, "links_deleted") }}
+
+
+
+
+
+
+
+ This action is irreversible . It deletes every publication record across all users. The next ingestion run will re-populate all data from scratch.
+
+ Type '{{ DROP_PUBLICATIONS_CONFIRM_TEXT }}' to confirm
+ {{ droppingPublications ? "Dropping..." : "Drop all publications" }}
+
+
+
Deleted: {{ dropResult.deleted_count }}
+
{{ dropResult.message }}
+
+
+
+
diff --git a/frontend/src/features/settings/components/AdminUsersSection.test.ts b/frontend/src/features/settings/components/AdminUsersSection.test.ts
new file mode 100644
index 0000000..4926574
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminUsersSection.test.ts
@@ -0,0 +1,84 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import AdminUsersSection from "./AdminUsersSection.vue";
+
+vi.mock("@/features/admin_users", () => ({
+ listAdminUsers: vi.fn(),
+ createAdminUser: vi.fn(),
+ setAdminUserActive: vi.fn(),
+ resetAdminUserPassword: vi.fn(),
+}));
+
+import { listAdminUsers, createAdminUser } from "@/features/admin_users";
+
+const mockedListUsers = vi.mocked(listAdminUsers);
+const mockedCreateUser = vi.mocked(createAdminUser);
+
+function buildUser(overrides: Record = {}) {
+ return {
+ id: 1,
+ email: "user@example.com",
+ is_admin: false,
+ is_active: true,
+ created_at: "2025-01-01T00:00:00Z",
+ updated_at: "2025-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+describe("AdminUsersSection", () => {
+ beforeEach(() => {
+ mockedListUsers.mockReset();
+ mockedCreateUser.mockReset();
+ });
+
+ it("renders the create user form", () => {
+ const wrapper = mount(AdminUsersSection);
+ expect(wrapper.text()).toContain("Create user");
+ });
+
+ it("exposes load function that calls listAdminUsers", async () => {
+ mockedListUsers.mockResolvedValueOnce([buildUser()]);
+ const wrapper = mount(AdminUsersSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise; users: unknown[] };
+ await vm.load();
+ await flushPromises();
+ expect(mockedListUsers).toHaveBeenCalled();
+ expect(vm.users).toHaveLength(1);
+ });
+
+ it("renders user table after loading", async () => {
+ mockedListUsers.mockResolvedValueOnce([buildUser({ email: "alice@test.com" })]);
+ const wrapper = mount(AdminUsersSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await vm.load();
+ await flushPromises();
+ expect(wrapper.text()).toContain("alice@test.com");
+ });
+
+ it("propagates error when load fails", async () => {
+ mockedListUsers.mockRejectedValueOnce(new Error("Network error"));
+ const wrapper = mount(AdminUsersSection);
+ const vm = wrapper.vm as unknown as { load: () => Promise };
+ await expect(vm.load()).rejects.toThrow("Network error");
+ });
+
+ it("emits user creation via form submit", async () => {
+ mockedCreateUser.mockResolvedValueOnce(buildUser({ email: "new@test.com" }));
+ mockedListUsers.mockResolvedValue([]);
+ const wrapper = mount(AdminUsersSection);
+
+ const inputs = wrapper.findAll("input");
+ const emailInput = inputs.find((i) => (i.element as HTMLInputElement).type === "email");
+ const passwordInput = inputs.find((i) => (i.element as HTMLInputElement).type === "password");
+
+ if (emailInput && passwordInput) {
+ await emailInput.setValue("new@test.com");
+ await passwordInput.setValue("securepassword123");
+ await wrapper.find("form").trigger("submit");
+ await flushPromises();
+ expect(mockedCreateUser).toHaveBeenCalled();
+ }
+ });
+});
diff --git a/frontend/src/features/settings/components/AdminUsersSection.vue b/frontend/src/features/settings/components/AdminUsersSection.vue
new file mode 100644
index 0000000..18f6408
--- /dev/null
+++ b/frontend/src/features/settings/components/AdminUsersSection.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+ Email
+
+
+
+ Password
+
+
+
+
Role
+
+
+
{{ creating ? "Creating..." : "Create user" }}
+
+
+
+
+
+
+
+ Email
+ Role
+ Status
+ Actions
+
+
+
+
+
+
+
+ {{ user.email }}
+
+
+ {{ userRoleLabel(user) }}
+ {{ user.is_active ? "active" : "inactive" }}
+
+
+ {{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
+
+ Manage
+
+
+
+
+
+
+
+
+
+
+
+
{{ activeUser.email }}
+
+
Role: {{ userRoleLabel(activeUser) }}
+
Last updated: {{ formatTimestamp(activeUser.updated_at) }}
+
+
+
+ New password
+
+
+
+
{{ resettingPassword ? "Resetting..." : "Reset password" }}
+
+ {{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
+
+
+
+
+
+
diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts
index e8ef325..a1edce7 100644
--- a/frontend/src/features/settings/index.ts
+++ b/frontend/src/features/settings/index.ts
@@ -19,6 +19,9 @@ export interface UserSettings {
nav_visible_pages: string[];
policy: UserSettingsPolicy;
safety_state: ScrapeSafetyState;
+ openalex_api_key: string | null;
+ crossref_api_token: string | null;
+ crossref_api_mailto: string | null;
}
export interface UserSettingsUpdate {
@@ -26,6 +29,16 @@ export interface UserSettingsUpdate {
run_interval_minutes: number;
request_delay_seconds: number;
nav_visible_pages: string[];
+ openalex_api_key: string | null;
+ crossref_api_token: string | null;
+ crossref_api_mailto: string | null;
+}
+
+export interface AdminScholarHttpSettings {
+ user_agent: string;
+ rotate_user_agent: boolean;
+ accept_language: string;
+ cookie: string;
}
export interface ChangePasswordPayload {
@@ -54,3 +67,20 @@ export async function changePassword(payload: ChangePasswordPayload): Promise<{
});
return response.data;
}
+
+export async function fetchAdminScholarHttpSettings(): Promise {
+ const response = await apiRequest("/admin/settings/scholar-http", {
+ method: "GET",
+ });
+ return response.data;
+}
+
+export async function updateAdminScholarHttpSettings(
+ payload: AdminScholarHttpSettings,
+): Promise {
+ const response = await apiRequest("/admin/settings/scholar-http", {
+ method: "PUT",
+ body: payload,
+ });
+ return response.data;
+}
diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue
index e61df8d..fbd0b4c 100644
--- a/frontend/src/pages/DashboardPage.vue
+++ b/frontend/src/pages/DashboardPage.vue
@@ -1,7 +1,8 @@
-
-
-
-
-
-
- All records
- Unread
- New (latest run)
-
-
-
-
-
- Scholar
-
-
-
- All scholars
-
- {{ scholarLabel(scholar) }}
-
-
-
-
-
-
- Search
-
-
-
-
-
-
-
- {{ favoriteOnly ? "★" : "☆" }}
-
-
- {{ startRunButtonLabel }}
-
-
-
-
+
+
+
+
-
Scope: {{ scopeLabel }} | Showing {{ selectedScholarName }}
+
Scope: {{ scopeLabel }} | Showing {{ pub.selectedScholarName.value }}
Page size
@@ -919,9 +427,7 @@ watch(
100 / page
200 / page
-
+
Bulk task
-
+
@@ -961,157 +467,80 @@ watch(
type="checkbox"
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
:checked="allVisibleUnreadSelected"
- :disabled="visibleUnreadKeys.size === 0"
+ :disabled="pub.visibleUnreadKeys.value.size === 0"
aria-label="Select all visible unread publications"
@change="onToggleAllVisible"
/>
-
-
- ★ {{ sortMarker('favorite') }}
-
-
+ ★
-
- Title {{ sortMarker('title') }}
+
+ Title {{ pub.sortMarker('title') }}
-
- Scholar {{ sortMarker('scholar') }}
+
+ Scholar {{ pub.sortMarker('scholar') }}
+
+
+
+
+ PDF {{ pub.sortMarker('pdf_status') }}
- PDF
-
- Year {{ sortMarker('year') }}
+
+ Year {{ pub.sortMarker('year') }}
-
- Citations {{ sortMarker('citations') }}
-
-
-
-
- Read status {{ sortMarker('status') }}
+
+ Citations {{ pub.sortMarker('citations') }}
+ Read status
-
- First seen {{ sortMarker('first_seen') }}
+
+ First seen {{ pub.sortMarker('first_seen') }}
-
-
-
-
-
-
- {{ item.is_favorite ? "★" : "☆" }}
-
-
-
-
-
-
- {{ item.scholar_label }}
-
-
-
- PDF
-
-
- {{ isRetryingPublication(item) ? "..." : "retry" }}
-
- {{ pdfPendingLabel(item) }}
-
- {{ item.year ?? "n/a" }}
- {{ item.citation_count }}
-
-
-
- {{ item.is_new_in_latest_run ? "New" : "Seen" }}
-
-
- {{ item.is_read ? "Read" : "Unread" }}
-
-
-
- {{ formatDate(item.first_seen_at) }}
-
+
- visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
+ total {{ pub.totalCount.value }} · visible {{ pub.visibleCount.value }} · unread {{ pub.visibleUnreadCount.value }} · favorites {{ pub.visibleFavoriteCount.value }}
· selected {{ selectedCount }}
-
page {{ currentPage }} / {{ totalPages }}
-
+ page {{ pub.currentPage.value }} / {{ pub.totalPages.value }}
+
Prev
-
+
Next
+
@@ -1123,44 +552,4 @@ watch(
.sort-marker {
@apply text-[11px] leading-none text-ink-muted;
}
-
-.favorite-filter-button {
- @apply inline-flex min-h-10 h-10 w-10 items-center justify-center rounded-full border text-base leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
-}
-
-.favorite-filter-on {
- @apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
-}
-
-.favorite-filter-off {
- @apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
-}
-
-.favorite-star-button {
- @apply inline-flex h-7 w-7 items-center justify-center rounded-full border text-sm leading-none transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
-}
-
-.favorite-star-on {
- @apply border-warning-300 bg-warning-100 text-warning-700 hover:bg-warning-200;
-}
-
-.favorite-star-off {
- @apply border-stroke-default bg-surface-card-muted text-ink-muted hover:border-stroke-interactive hover:text-ink-secondary;
-}
-
-.pdf-link-button {
- @apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-success-border bg-state-success-bg text-state-success-text shadow-sm transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
-}
-
-.pdf-retry-button {
- @apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-state-warning-border bg-state-warning-bg text-state-warning-text transition hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-50;
-}
-
-.pdf-state-label {
- @apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium border-stroke-default bg-surface-card-muted text-secondary;
-}
-
-.status-badges-row {
- @apply inline-flex items-center gap-1 whitespace-nowrap;
-}
diff --git a/frontend/src/pages/RunsPage.vue b/frontend/src/pages/RunsPage.vue
index 711158c..49894b7 100644
--- a/frontend/src/pages/RunsPage.vue
+++ b/frontend/src/pages/RunsPage.vue
@@ -22,7 +22,9 @@ import {
type QueueItem,
type RunListItem,
} from "@/features/runs";
+import { formatCooldownCountdown } from "@/features/safety";
import { ApiRequestError } from "@/lib/api/errors";
+import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
@@ -33,8 +35,10 @@ const errorMessage = ref
(null);
const errorRequestId = ref(null);
const successMessage = ref(null);
const activeQueueItemId = ref(null);
+const auth = useAuthStore();
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
+const isCancelAnimating = ref(false);
function formatDate(value: string | null): string {
if (!value) {
@@ -62,7 +66,7 @@ function queueHealth() {
}
const queueCounts = computed(() => queueHealth());
-const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
+const activeRunId = computed(() => runStatus.isRunActive && runStatus.latestRun ? runStatus.latestRun.id : null);
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
@@ -86,6 +90,10 @@ const runButtonLabel = computed(() => {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
+ const remaining = runStatus.safetyState.cooldown_remaining_seconds;
+ if (remaining > 0) {
+ return `Cooldown (${formatCooldownCountdown(remaining)})`;
+ }
return "Safety cooldown";
}
if (runStatus.isLikelyRunning) {
@@ -151,6 +159,28 @@ async function onTriggerManualRun(): Promise {
}
}
+async function onCancelRun(): Promise {
+ if (!activeRunId.value) return;
+ errorMessage.value = null;
+ errorRequestId.value = null;
+ successMessage.value = null;
+ isCancelAnimating.value = true;
+
+ try {
+ const result = await runStatus.cancelActiveCheck();
+ if (result.kind === "success") {
+ successMessage.value = "Update check canceled successfully.";
+ await loadData();
+ } else {
+ errorMessage.value = result.message;
+ }
+ } catch {
+ errorMessage.value = "Unable to cancel the update check.";
+ } finally {
+ isCancelAnimating.value = false;
+ }
+}
+
async function runQueueAction(itemId: number, action: "retry" | "drop" | "clear"): Promise {
activeQueueItemId.value = itemId;
successMessage.value = null;
@@ -208,6 +238,22 @@ onMounted(() => {
:manual-run-allowed="userSettings.manualRunAllowed"
/>
+
+
+
+
+
+
+ Cancel check
+
+
{{ runButtonLabel }}
diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue
index 4c8fb57..df2143e 100644
--- a/frontend/src/pages/ScholarsPage.vue
+++ b/frontend/src/pages/ScholarsPage.vue
@@ -1,17 +1,14 @@
@@ -628,163 +439,12 @@ onMounted(() => {
-
-
-
-
Add Scholar Profiles
-
-
-
Paste one or more Scholar IDs or profile URLs and add them in one action.
-
-
-
-
- Scholar IDs or profile URLs
-
-
-
-
-
- Parsed IDs: {{ parsedBatchCount }}
-
-
- {{ saving ? "Adding..." : "Add scholars" }}
-
-
-
-
-
-
-
-
-
-
- This helper remains paused because Google Scholar currently requires account login for reliable name search access.
-
-
-
-
- WIP
-
-
-
-
-
-
-
- Search
-
-
-
- Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
-
-
-
-
-
-
-
-
-
- {{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
- for {{ searchResult.query }}
-
-
{{ searchResult.state }}
-
-
-
- State reason: {{ searchResult.state_reason }}
- . {{ searchResult.action_hint }}
- . Warnings: {{ searchResult.warnings.join(", ") }}
-
-
-
- Name search is degraded
-
- This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
-
-
-
-
-
-
-
-
-
-
-
-
{{ candidate.display_name }}
-
{{ candidate.scholar_id }}
-
Tracked
-
-
-
{{ candidate.affiliation || "No affiliation provided" }}
-
-
- Email: {{ candidate.email_domain }}
- Cited by: {{ candidate.cited_by_count }}
-
-
-
-
-
- {{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
-
-
- Open profile
-
-
-
-
-
-
-
-
- Name search remains disabled while login-gated responses are unresolved.
-
-
-
-
+
+
@@ -792,9 +452,7 @@ onMounted(() => {
Review tracked profiles, filter quickly, and open per-scholar settings when needed.
@@ -802,9 +460,7 @@ onMounted(() => {
Tracking status
-
+
{{ trackedCountLabel }}
@@ -813,32 +469,14 @@ onMounted(() => {
{{ importingData ? "Importing..." : "Import" }}
-
+
-
-
+
Filter tracked scholars
-
+
Sort
@@ -854,47 +492,24 @@ onMounted(() => {
-
-
-
+
+
-
-
+
-
@@ -908,22 +523,17 @@ onMounted(() => {
- Manage
+ Manage
@@ -936,98 +546,20 @@ onMounted(() => {
-
-
-
-
-
-
-
- {{ scholarLabel(activeScholarSettings) }}
-
-
- ID: {{ activeScholarSettings.scholar_id }}
-
-
-
-
-
-
-
- Profile image URL override
-
-
-
-
- {{ imageSavingScholarId === activeScholarSettings.id ? "Saving..." : "Save URL" }}
-
-
-
-
- {{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }}
-
-
-
- Reset image
-
-
-
-
-
-
- {{ activeScholarSettings.is_enabled ? "Disable scholar" : "Enable scholar" }}
-
-
- Delete scholar
-
-
-
-
+ { if (activeScholarSettings) imageUrlDraftByScholarId[activeScholarSettings.id] = v; }"
+ @save-image-url="onSaveImageUrl"
+ @upload-image="onUploadImage"
+ @reset-image="onResetImage"
+ @toggle="onToggleScholar"
+ @delete="onDeleteScholar"
+ />
diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue
index a7b00b1..4040f0e 100644
--- a/frontend/src/pages/SettingsPage.vue
+++ b/frontend/src/pages/SettingsPage.vue
@@ -14,9 +14,12 @@ import AppTabs, { type AppTabItem } from "@/components/ui/AppTabs.vue";
import SettingsAdminPanel from "@/features/settings/SettingsAdminPanel.vue";
import {
changePassword,
+ fetchAdminScholarHttpSettings,
fetchSettings,
+ type AdminScholarHttpSettings,
type UserSettings,
type UserSettingsUpdate,
+ updateAdminScholarHttpSettings,
updateSettings,
} from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors";
@@ -30,6 +33,7 @@ const TAB_ADMIN_USERS = "admin-users";
const TAB_ADMIN_INTEGRITY = "admin-integrity";
const TAB_ADMIN_REPAIRS = "admin-repairs";
const TAB_ADMIN_PDF = "admin-pdf";
+const TAB_ADMIN_INTEGRATIONS = "admin-integrations";
const auth = useAuthStore();
const userSettings = useUserSettingsStore();
@@ -39,12 +43,20 @@ const router = useRouter();
const loading = ref(true);
const saving = ref(false);
+const savingScholarHttp = ref(false);
const updatingPassword = ref(false);
const autoRunEnabled = ref(false);
const runIntervalMinutes = ref("60");
const requestDelaySeconds = ref("2");
const navVisiblePages = ref([]);
+const openalexApiKey = ref("");
+const crossrefApiToken = ref("");
+const crossrefApiMailto = ref("");
+const scholarHttpUserAgent = ref("");
+const scholarHttpRotateUserAgent = ref(false);
+const scholarHttpAcceptLanguage = ref("en-US,en;q=0.9");
+const scholarHttpCookie = ref("");
const currentPassword = ref("");
const newPassword = ref("");
@@ -72,6 +84,7 @@ const tabItems = computed(() => {
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
+ { id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
);
}
return tabs;
@@ -129,10 +142,21 @@ function hydrateSettings(settings: UserSettings): void {
requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
+ openalexApiKey.value = settings.openalex_api_key ?? "";
+ crossrefApiToken.value = settings.crossref_api_token ?? "";
+ crossrefApiMailto.value = settings.crossref_api_mailto ?? "";
+
userSettings.applySettings(settings);
runStatus.setSafetyState(settings.safety_state);
}
+function hydrateScholarHttpSettings(settings: AdminScholarHttpSettings): void {
+ scholarHttpUserAgent.value = settings.user_agent;
+ scholarHttpRotateUserAgent.value = Boolean(settings.rotate_user_agent);
+ scholarHttpAcceptLanguage.value = settings.accept_language;
+ scholarHttpCookie.value = settings.cookie;
+}
+
function parseBoundedInteger(value: string, label: string, minimum: number): number {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
@@ -151,6 +175,9 @@ async function loadSettings(): Promise {
try {
const settings = await fetchSettings();
hydrateSettings(settings);
+ if (auth.isAdmin) {
+ hydrateScholarHttpSettings(await fetchAdminScholarHttpSettings());
+ }
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
@@ -163,6 +190,32 @@ async function loadSettings(): Promise {
}
}
+async function onSaveScholarHttpSettings(): Promise {
+ savingScholarHttp.value = true;
+ errorMessage.value = null;
+ errorRequestId.value = null;
+ successMessage.value = null;
+ try {
+ const updated = await updateAdminScholarHttpSettings({
+ user_agent: scholarHttpUserAgent.value.trim(),
+ rotate_user_agent: scholarHttpRotateUserAgent.value,
+ accept_language: scholarHttpAcceptLanguage.value.trim(),
+ cookie: scholarHttpCookie.value.trim(),
+ });
+ hydrateScholarHttpSettings(updated);
+ successMessage.value = "Scholar HTTP profile updated.";
+ } catch (error) {
+ if (error instanceof ApiRequestError) {
+ errorMessage.value = error.message;
+ errorRequestId.value = error.requestId;
+ } else {
+ errorMessage.value = "Unable to save Scholar HTTP profile.";
+ }
+ } finally {
+ savingScholarHttp.value = false;
+ }
+}
+
async function onSaveSettings(): Promise {
saving.value = true;
errorMessage.value = null;
@@ -183,6 +236,9 @@ async function onSaveSettings(): Promise {
minRequestDelaySeconds.value,
),
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
+ openalex_api_key: openalexApiKey.value.trim() || null,
+ crossref_api_token: crossrefApiToken.value.trim() || null,
+ crossref_api_mailto: crossrefApiMailto.value.trim() || null,
};
const saved = await updateSettings(payload);
@@ -357,6 +413,93 @@ onMounted(async () => {
+
+
+ If no keys are provided, the system will gracefully fall back to free unauthenticated tiers where available.
+
+
+
+
OpenAlex
+
+ OpenAlex is a free index of the world's research. Providing a key unlocks a higher rate limit.
+ Values returned from the backend might be 'SET' to hide the raw key for security.
+
+
+ API Key
+
+
+
+
+
+
Crossref
+
+ Crossref metadata search for DOIs. A "mailto" address puts you in their "Polite Pool" for better performance.
+ A Plus API token unlocks the fastest tier.
+
+
+ API Token (Plus)
+
+
+
+ Mailto (Polite Pool)
+
+
+
+
+
+
Scholar Request Profile
+
+ Tune Scholar HTTP fingerprint behavior used by live scraper requests. Changes apply to new runs.
+
+
+ User-Agent override
+
+
+
+ Accept-Language
+
+
+
+ Cookie header
+
+
+
+
+
+ {{ savingScholarHttp ? "Saving..." : "Save Scholar request profile" }}
+
+
+
+
+
+
+
+ {{ saving ? "Saving..." : "Save integrations" }}
+
+
+
+
diff --git a/frontend/src/stores/run_status.test.ts b/frontend/src/stores/run_status.test.ts
index ec04bf4..53dc875 100644
--- a/frontend/src/stores/run_status.test.ts
+++ b/frontend/src/stores/run_status.test.ts
@@ -7,9 +7,10 @@ import { createDefaultSafetyState } from "@/features/safety";
vi.mock("@/features/runs", () => ({
listRuns: vi.fn(),
triggerManualRun: vi.fn(),
+ cancelRun: vi.fn(),
}));
-import { listRuns, triggerManualRun } from "@/features/runs";
+import { cancelRun, listRuns, triggerManualRun } from "@/features/runs";
import {
RUN_STATUS_POLL_INTERVAL_MS,
RUN_STATUS_STARTING_PHASE_MS,
@@ -48,14 +49,45 @@ function buildRunsPayload(runs: ReturnType[]) {
};
}
+class FakeEventSource {
+ static instances: FakeEventSource[] = [];
+
+ public readonly url: string;
+ public closed = false;
+ private listeners = new Map void>>();
+
+ constructor(url: string) {
+ this.url = url;
+ FakeEventSource.instances.push(this);
+ }
+
+ addEventListener(eventType: string, callback: (event: { data: string }) => void): void {
+ const existing = this.listeners.get(eventType) ?? [];
+ this.listeners.set(eventType, [...existing, callback]);
+ }
+
+ emit(eventType: string, payload: unknown): void {
+ const callbacks = this.listeners.get(eventType) ?? [];
+ for (const callback of callbacks) {
+ callback({ data: JSON.stringify(payload) });
+ }
+ }
+
+ close(): void {
+ this.closed = true;
+ }
+}
+
describe("run status store", () => {
const mockedListRuns = vi.mocked(listRuns);
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
+ const mockedCancelRun = vi.mocked(cancelRun);
beforeEach(() => {
setActivePinia(createPinia());
mockedListRuns.mockReset();
mockedTriggerManualRun.mockReset();
+ mockedCancelRun.mockReset();
vi.useRealTimers();
});
@@ -223,4 +255,125 @@ describe("run status store", () => {
await startPromise;
expect(store.isRunActive).toBe(true);
});
+
+ it("cancels an active check and transitions to canceled state", async () => {
+ mockedCancelRun.mockResolvedValueOnce({
+ run: buildRun({ id: 50, status: "canceled" }),
+ summary: {
+ succeeded_count: 0,
+ failed_count: 0,
+ partial_count: 0,
+ failed_state_counts: {},
+ failed_reason_counts: {},
+ scrape_failure_counts: {},
+ retry_counts: {
+ retries_scheduled_count: 0,
+ scholars_with_retries_count: 0,
+ retry_exhausted_count: 0,
+ },
+ alert_thresholds: {},
+ alert_flags: {},
+ },
+ scholar_results: [],
+ safety_state: createDefaultSafetyState(),
+ } as any);
+
+ const store = useRunStatusStore();
+ store.setLatestRun(buildRun({ id: 50, status: "running", end_dt: null }));
+ expect(store.isRunActive).toBe(true);
+
+ const result = await store.cancelActiveCheck();
+
+ expect(result.kind).toBe("success");
+ expect(store.latestRun?.status).toBe("canceled");
+ expect(store.isRunActive).toBe(false);
+ expect(store.isPolling).toBe(false);
+ });
+
+ it("cancels a resolving run using server status as source of truth", async () => {
+ mockedCancelRun.mockResolvedValueOnce({
+ run: buildRun({ id: 72, status: "failed" }),
+ summary: {
+ succeeded_count: 0,
+ failed_count: 1,
+ partial_count: 0,
+ failed_state_counts: {},
+ failed_reason_counts: {},
+ scrape_failure_counts: {},
+ retry_counts: {
+ retries_scheduled_count: 0,
+ scholars_with_retries_count: 0,
+ retry_exhausted_count: 0,
+ },
+ alert_thresholds: {},
+ alert_flags: {},
+ },
+ scholar_results: [],
+ safety_state: createDefaultSafetyState(),
+ } as any);
+
+ const store = useRunStatusStore();
+ store.setLatestRun(buildRun({ id: 72, status: "resolving", end_dt: null }));
+
+ const result = await store.cancelActiveCheck();
+
+ expect(result.kind).toBe("success");
+ expect(store.latestRun?.status).toBe("failed");
+ expect(store.isRunActive).toBe(false);
+ });
+
+ it("reconciles poll responses without regressing publication counters", async () => {
+ mockedListRuns.mockResolvedValueOnce(
+ buildRunsPayload([buildRun({ id: 99, status: "running", new_publication_count: 1, end_dt: null })]),
+ );
+
+ const store = useRunStatusStore();
+ await store.syncLatest();
+ store.latestRun = buildRun({ id: 99, status: "running", new_publication_count: 5, end_dt: null });
+
+ mockedListRuns.mockResolvedValueOnce(
+ buildRunsPayload([buildRun({ id: 99, status: "running", new_publication_count: 3, end_dt: null })]),
+ );
+ await store.syncLatest();
+
+ expect(store.latestRun?.new_publication_count).toBe(5);
+ });
+
+ it("applies identifier_updated SSE events to live publications", () => {
+ const previousEventSource = (globalThis as any).EventSource;
+ FakeEventSource.instances = [];
+ (globalThis as any).EventSource = FakeEventSource as any;
+ try {
+ const store = useRunStatusStore();
+ store.setLatestRun(buildRun({ id: 314, status: "running", end_dt: null }));
+
+ const stream = FakeEventSource.instances[0];
+ expect(stream).toBeDefined();
+ stream.emit("publication_discovered", {
+ publication_id: 22,
+ scholar_profile_id: 7,
+ scholar_label: "Ada Lovelace",
+ title: "Optimization Notes",
+ pub_url: null,
+ first_seen_at: "2026-02-26T10:00:00Z",
+ });
+ expect(store.livePublications).toHaveLength(1);
+ expect(store.livePublications[0].display_identifier).toBeNull();
+
+ stream.emit("identifier_updated", {
+ publication_id: 22,
+ display_identifier: {
+ kind: "doi",
+ value: "10.1000/xyz",
+ label: "DOI: 10.1000/xyz",
+ url: "https://doi.org/10.1000/xyz",
+ confidence_score: 0.95,
+ },
+ });
+ expect(store.livePublications[0].display_identifier?.kind).toBe("doi");
+ expect(store.livePublications[0].display_identifier?.value).toBe("10.1000/xyz");
+ } finally {
+ (globalThis as any).EventSource = previousEventSource;
+ }
+ });
});
diff --git a/frontend/src/stores/run_status.ts b/frontend/src/stores/run_status.ts
index 9c12540..3f90de0 100644
--- a/frontend/src/stores/run_status.ts
+++ b/frontend/src/stores/run_status.ts
@@ -1,11 +1,12 @@
import { defineStore } from "pinia";
-import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
+import { listRuns, triggerManualRun, cancelRun, type RunListItem } from "@/features/runs";
import {
createDefaultSafetyState,
normalizeSafetyState,
type ScrapeSafetyState,
} from "@/features/safety";
+import { type PublicationItem } from "@/features/publications";
import { ApiRequestError } from "@/lib/api/errors";
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
@@ -13,24 +14,33 @@ export const RUN_STATUS_STARTING_PHASE_MS = 1500;
export type StartManualCheckResult =
| {
- kind: "started";
- runId: number;
- reusedExistingRun: boolean;
- }
+ kind: "started";
+ runId: number;
+ reusedExistingRun: boolean;
+ }
| {
- kind: "already_running";
- runId: number | null;
- requestId: string | null;
- }
+ kind: "already_running";
+ runId: number | null;
+ requestId: string | null;
+ }
| {
- kind: "error";
- message: string;
- requestId: string | null;
- };
+ kind: "error";
+ message: string;
+ requestId: string | null;
+ };
+
+export type CancelCheckResult =
+ | { kind: "success" }
+ | { kind: "error"; message: string };
let pollTimer: ReturnType | null = null;
let syncPromise: Promise | null = null;
let submittingPhaseTimer: ReturnType | null = null;
+let eventSource: EventSource | null = null;
+let activeStreamRunId: number | null = null;
+const ACTIVE_STATUSES = new Set(["running", "resolving"]);
+
+type StreamDisplayIdentifier = PublicationItem["display_identifier"];
function parseRunId(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -67,6 +77,72 @@ function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | nu
return normalizeSafetyState(candidate);
}
+function isActiveStatus(value: string | null | undefined): boolean {
+ return value !== undefined && value !== null && ACTIVE_STATUSES.has(value);
+}
+
+function parsePublicationCount(value: unknown, fallback: number): number {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return Math.max(Math.trunc(value), 0);
+ }
+ return fallback;
+}
+
+function parseDisplayIdentifier(value: unknown): StreamDisplayIdentifier {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+ const payload = value as Record;
+ if (typeof payload.kind !== "string" || typeof payload.value !== "string" || typeof payload.label !== "string") {
+ return null;
+ }
+ if (typeof payload.confidence_score !== "number" || !Number.isFinite(payload.confidence_score)) {
+ return null;
+ }
+ const url = typeof payload.url === "string" ? payload.url : null;
+ return {
+ kind: payload.kind,
+ value: payload.value,
+ label: payload.label,
+ url,
+ confidence_score: payload.confidence_score,
+ };
+}
+
+function withUpdatedDisplayIdentifier(
+ items: PublicationItem[],
+ update: {
+ publicationId: number;
+ displayIdentifier: StreamDisplayIdentifier;
+ },
+): PublicationItem[] {
+ const { publicationId, displayIdentifier } = update;
+ let changed = false;
+ const next = items.map((item) => {
+ if (item.publication_id !== publicationId) {
+ return item;
+ }
+ changed = true;
+ return { ...item, display_identifier: displayIdentifier };
+ });
+ return changed ? next : items;
+}
+
+function reconcileRunCounters(previous: RunListItem | null, next: RunListItem | null): RunListItem | null {
+ if (previous === null || next === null) {
+ return next;
+ }
+ if (previous.id !== next.id) {
+ return next;
+ }
+ const previousCount = parsePublicationCount(previous.new_publication_count, 0);
+ const nextCount = parsePublicationCount(next.new_publication_count, previousCount);
+ return {
+ ...next,
+ new_publication_count: Math.max(previousCount, nextCount),
+ };
+}
+
function buildPlaceholderRunningRun(runId: number): RunListItem {
return {
id: runId,
@@ -91,13 +167,16 @@ export const useRunStatusStore = defineStore("runStatus", {
lastErrorMessage: null as string | null,
lastErrorRequestId: null as string | null,
lastSyncAt: null as number | null,
+ livePublications: [] as Array,
}),
getters: {
isRunActive(state): boolean {
- return state.isSubmitting || state.latestRun?.status === "running";
+ const s = state.latestRun?.status;
+ return state.isSubmitting || s === "running" || s === "resolving";
},
isLikelyRunning(state): boolean {
- return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
+ const s = state.latestRun?.status;
+ return s === "running" || s === "resolving" || state.assumeRunningFromSubmission;
},
canStart(): boolean {
return !this.isRunActive && !this.safetyState.cooldown_active;
@@ -125,7 +204,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
- if (run?.status === "running") {
+ if (isActiveStatus(run?.status)) {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
@@ -156,9 +235,88 @@ export const useRunStatusStore = defineStore("runStatus", {
updatePolling(): void {
if (this.isRunActive) {
this.startPolling();
+ this.updateEventSource();
return;
}
this.stopPolling();
+ this.updateEventSource();
+ },
+ updateEventSource(): void {
+ const targetRunId = isActiveStatus(this.latestRun?.status) ? this.latestRun?.id ?? null : null;
+ if (activeStreamRunId === targetRunId) {
+ return;
+ }
+ if (eventSource !== null) {
+ eventSource.close();
+ eventSource = null;
+ activeStreamRunId = null;
+ }
+ if (targetRunId !== null) {
+ if (typeof EventSource === "undefined") {
+ return;
+ }
+ activeStreamRunId = targetRunId;
+ this.livePublications = [];
+ eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`);
+ eventSource.addEventListener("publication_discovered", (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ if (this.latestRun && this.latestRun.id === targetRunId) {
+ const baseline = parsePublicationCount(this.latestRun.new_publication_count, 0);
+ const payloadCount = parsePublicationCount(data?.new_publication_count, baseline + 1);
+ this.latestRun.new_publication_count = Math.max(baseline, payloadCount);
+ }
+ this.livePublications.unshift({
+ publication_id: data.publication_id,
+ scholar_profile_id: data.scholar_profile_id,
+ scholar_label: data.scholar_label,
+ title: data.title,
+ pub_url: data.pub_url,
+ first_seen_at: data.first_seen_at,
+ year: null,
+ citation_count: 0,
+ venue_text: null,
+ display_identifier: null,
+ pdf_url: null,
+ pdf_status: "untracked",
+ pdf_attempt_count: 0,
+ pdf_failure_reason: null,
+ pdf_failure_detail: null,
+ is_read: false,
+ is_favorite: false,
+ is_new_in_latest_run: true,
+ });
+ if (this.livePublications.length > 50) {
+ this.livePublications.pop();
+ }
+ } catch (err) {
+ console.error("Failed to parse SSE event", err);
+ }
+ });
+ eventSource.addEventListener("identifier_updated", (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ const publicationId = parseRunId(data?.publication_id);
+ const displayIdentifier = parseDisplayIdentifier(data?.display_identifier);
+ if (publicationId === null || displayIdentifier === null) {
+ return;
+ }
+ this.livePublications = withUpdatedDisplayIdentifier(
+ this.livePublications,
+ {
+ publicationId,
+ displayIdentifier,
+ },
+ );
+ } catch (err) {
+ console.error("Failed to parse SSE event", err);
+ }
+ });
+ eventSource.onerror = () => {
+ // Reconnecting is handled automatically by EventSource,
+ // but if it's permanently closed, we could do something here.
+ };
+ }
},
async syncLatest(): Promise {
if (syncPromise) {
@@ -169,13 +327,13 @@ export const useRunStatusStore = defineStore("runStatus", {
syncPromise = (async () => {
try {
const payload = await listRuns({ limit: 1 });
- const latest = payload.runs[0] ?? null;
+ const latest = reconcileRunCounters(this.latestRun, payload.runs[0] ?? null);
this.latestRun = latest;
this.safetyState = normalizeSafetyState(payload.safety_state);
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
- if (latest?.status === "running") {
+ if (isActiveStatus(latest?.status)) {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
@@ -294,14 +452,36 @@ export const useRunStatusStore = defineStore("runStatus", {
} finally {
this.isSubmitting = false;
this.clearSubmittingPhaseTimer();
- if (this.latestRun?.status !== "running") {
+ if (!isActiveStatus(this.latestRun?.status)) {
this.assumeRunningFromSubmission = false;
}
this.updatePolling();
}
},
+ async cancelActiveCheck(): Promise<{ kind: "success" } | { kind: "error"; message: string }> {
+ if (!this.latestRun || !isActiveStatus(this.latestRun.status)) {
+ return { kind: "error", message: "No active run to cancel." };
+ }
+ try {
+ const response = await cancelRun(this.latestRun.id);
+ this.setLatestRun(reconcileRunCounters(this.latestRun, response.run));
+ this.setSafetyState(response.safety_state);
+ return { kind: "success" };
+ } catch (error) {
+ let errMessage = "Failed to cancel check.";
+ if (error instanceof ApiRequestError) {
+ errMessage = error.message;
+ }
+ return { kind: "error", message: errMessage };
+ }
+ },
reset(): void {
this.stopPolling();
+ if (eventSource !== null) {
+ eventSource.close();
+ eventSource = null;
+ activeStreamRunId = null;
+ }
this.clearSubmittingPhaseTimer();
this.latestRun = null;
this.isSubmitting = false;
@@ -310,6 +490,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastErrorRequestId = null;
this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState();
+ this.livePublications = [];
},
},
});
diff --git a/frontend/src/stores/user_settings.test.ts b/frontend/src/stores/user_settings.test.ts
index 72cbb81..46b25f5 100644
--- a/frontend/src/stores/user_settings.test.ts
+++ b/frontend/src/stores/user_settings.test.ts
@@ -43,6 +43,9 @@ describe("user settings store", () => {
last_evaluated_run_id: null,
},
},
+ openalex_api_key: null,
+ crossref_api_token: null,
+ crossref_api_mailto: null,
});
expect(store.networkFailureThreshold).toBe(2);
diff --git a/frontend/src/theme/presets/deutan-blue.js b/frontend/src/theme/presets/deutan-blue.js
new file mode 100644
index 0000000..1ab3010
--- /dev/null
+++ b/frontend/src/theme/presets/deutan-blue.js
@@ -0,0 +1,313 @@
+export default {
+ "id": "deutan-blue-75",
+ "label": "Deutan (Colorblind)",
+ "description": "Colorblind-oriented theme with blue-led emphasis and deutan-safer state separation.",
+ "modes": {
+ "light": {
+ "scale": {
+ "brand": {
+ "50": "#f3f7ff",
+ "100": "#e6efff",
+ "200": "#c9dbff",
+ "300": "#a6c1ff",
+ "400": "#7ea2f5",
+ "500": "#5f84dc",
+ "600": "#4a6bc0",
+ "700": "#3e589b",
+ "800": "#35497d",
+ "900": "#2e3f68",
+ "950": "#1d2742"
+ },
+ "info": {
+ "50": "#f0f7fa",
+ "100": "#dcedf4",
+ "200": "#bcd9e8",
+ "300": "#93bfd8",
+ "400": "#669fc4",
+ "500": "#4b82a8",
+ "600": "#3b6887",
+ "700": "#31546d",
+ "800": "#2b465a",
+ "900": "#253b4c",
+ "950": "#16242f"
+ },
+ "success": {
+ "50": "#f2f8ff",
+ "100": "#deefff",
+ "200": "#bddfff",
+ "300": "#93c9ff",
+ "400": "#62acef",
+ "500": "#3e8fd1",
+ "600": "#2f72ad",
+ "700": "#285c8c",
+ "800": "#244d73",
+ "900": "#213f5d",
+ "950": "#15273a"
+ },
+ "warning": {
+ "50": "#fff8ef",
+ "100": "#ffedd2",
+ "200": "#ffd7a3",
+ "300": "#ffbb6b",
+ "400": "#f49c38",
+ "500": "#db7f1d",
+ "600": "#b86516",
+ "700": "#945013",
+ "800": "#784214",
+ "900": "#633814",
+ "950": "#381d0a"
+ },
+ "danger": {
+ "50": "#fcf4fb",
+ "100": "#f7e6f5",
+ "200": "#efcdea",
+ "300": "#e2a7d8",
+ "400": "#ce79bf",
+ "500": "#b856a4",
+ "600": "#9b3f89",
+ "700": "#7f3470",
+ "800": "#692e5d",
+ "900": "#57294d",
+ "950": "#311428"
+ }
+ },
+ "surface": {
+ "app": "#f4f7fb",
+ "nav": "#ffffff",
+ "nav_active": "#dce9ff",
+ "card": "#ffffff",
+ "card_muted": "#edf3fb",
+ "table": "#ffffff",
+ "table_header": "#f4f7fb",
+ "input": "#ffffff",
+ "overlay": "#1c2430"
+ },
+ "text": {
+ "primary": "#1f2937",
+ "secondary": "#41536b",
+ "muted": "#6a7a8f",
+ "inverse": "#ffffff",
+ "link": "#3e589b"
+ },
+ "border": {
+ "default": "#d8e2ee",
+ "strong": "#bccbdb",
+ "subtle": "#edf3fb",
+ "interactive": "#7ea2f5"
+ },
+ "focus": {
+ "ring": "#5f84dc",
+ "ring_offset": "#f4f7fb"
+ },
+ "action": {
+ "primary": {
+ "bg": "#5f84dc",
+ "border": "#5f84dc",
+ "text": "#ffffff",
+ "hover_bg": "#4a6bc0",
+ "hover_border": "#4a6bc0",
+ "hover_text": "#ffffff"
+ },
+ "secondary": {
+ "bg": "#edf3fb",
+ "border": "#d8e2ee",
+ "text": "#1f2937",
+ "hover_bg": "#dce9ff",
+ "hover_border": "#bccbdb",
+ "hover_text": "#111827"
+ },
+ "ghost": {
+ "bg": "#f4f7fb",
+ "border": "#d8e2ee",
+ "text": "#3e589b",
+ "hover_bg": "#edf3fb",
+ "hover_border": "#bccbdb",
+ "hover_text": "#2e3f68"
+ },
+ "danger": {
+ "bg": "#f7e6f5",
+ "border": "#efcdea",
+ "text": "#7f3470",
+ "hover_bg": "#efcdea",
+ "hover_border": "#e2a7d8",
+ "hover_text": "#692e5d"
+ }
+ },
+ "state": {
+ "info": {
+ "bg": "#dcedf4",
+ "border": "#bcd9e8",
+ "text": "#31546d"
+ },
+ "success": {
+ "bg": "#deefff",
+ "border": "#bddfff",
+ "text": "#285c8c"
+ },
+ "warning": {
+ "bg": "#ffedd2",
+ "border": "#ffd7a3",
+ "text": "#945013"
+ },
+ "danger": {
+ "bg": "#f7e6f5",
+ "border": "#efcdea",
+ "text": "#7f3470"
+ }
+ }
+ },
+ "dark": {
+ "scale": {
+ "brand": {
+ "50": "#f3f7ff",
+ "100": "#e6efff",
+ "200": "#c9dbff",
+ "300": "#a6c1ff",
+ "400": "#7ea2f5",
+ "500": "#5f84dc",
+ "600": "#4a6bc0",
+ "700": "#3e589b",
+ "800": "#35497d",
+ "900": "#2e3f68",
+ "950": "#1d2742"
+ },
+ "info": {
+ "50": "#f0f7fa",
+ "100": "#dcedf4",
+ "200": "#bcd9e8",
+ "300": "#93bfd8",
+ "400": "#669fc4",
+ "500": "#4b82a8",
+ "600": "#3b6887",
+ "700": "#31546d",
+ "800": "#2b465a",
+ "900": "#253b4c",
+ "950": "#16242f"
+ },
+ "success": {
+ "50": "#f2f8ff",
+ "100": "#deefff",
+ "200": "#bddfff",
+ "300": "#93c9ff",
+ "400": "#62acef",
+ "500": "#3e8fd1",
+ "600": "#2f72ad",
+ "700": "#285c8c",
+ "800": "#244d73",
+ "900": "#213f5d",
+ "950": "#15273a"
+ },
+ "warning": {
+ "50": "#fff8ef",
+ "100": "#ffedd2",
+ "200": "#ffd7a3",
+ "300": "#ffbb6b",
+ "400": "#f49c38",
+ "500": "#db7f1d",
+ "600": "#b86516",
+ "700": "#945013",
+ "800": "#784214",
+ "900": "#633814",
+ "950": "#381d0a"
+ },
+ "danger": {
+ "50": "#fcf4fb",
+ "100": "#f7e6f5",
+ "200": "#efcdea",
+ "300": "#e2a7d8",
+ "400": "#ce79bf",
+ "500": "#b856a4",
+ "600": "#9b3f89",
+ "700": "#7f3470",
+ "800": "#692e5d",
+ "900": "#57294d",
+ "950": "#311428"
+ }
+ },
+ "surface": {
+ "app": "#111827",
+ "nav": "#172131",
+ "nav_active": "#3e589b",
+ "card": "#172131",
+ "card_muted": "#1d2a3d",
+ "table": "#172131",
+ "table_header": "#1d2a3d",
+ "input": "#0d1522",
+ "overlay": "#05070b"
+ },
+ "text": {
+ "primary": "#e8eef7",
+ "secondary": "#bfd0e6",
+ "muted": "#8ea1ba",
+ "inverse": "#111827",
+ "link": "#a6c1ff"
+ },
+ "border": {
+ "default": "#27364b",
+ "strong": "#334760",
+ "subtle": "#1d2a3d",
+ "interactive": "#5f84dc"
+ },
+ "focus": {
+ "ring": "#7ea2f5",
+ "ring_offset": "#111827"
+ },
+ "action": {
+ "primary": {
+ "bg": "#5f84dc",
+ "border": "#5f84dc",
+ "text": "#0b1020",
+ "hover_bg": "#7ea2f5",
+ "hover_border": "#7ea2f5",
+ "hover_text": "#0b1020"
+ },
+ "secondary": {
+ "bg": "#1d2a3d",
+ "border": "#334760",
+ "text": "#e8eef7",
+ "hover_bg": "#27364b",
+ "hover_border": "#41536b",
+ "hover_text": "#ffffff"
+ },
+ "ghost": {
+ "bg": "#111827",
+ "border": "#27364b",
+ "text": "#bfd0e6",
+ "hover_bg": "#1d2a3d",
+ "hover_border": "#334760",
+ "hover_text": "#ffffff"
+ },
+ "danger": {
+ "bg": "#311428",
+ "border": "#7f3470",
+ "text": "#efcdea",
+ "hover_bg": "#421b35",
+ "hover_border": "#9b3f89",
+ "hover_text": "#f7e6f5"
+ }
+ },
+ "state": {
+ "info": {
+ "bg": "#253b4c",
+ "border": "#31546d",
+ "text": "#dcedf4"
+ },
+ "success": {
+ "bg": "#213f5d",
+ "border": "#285c8c",
+ "text": "#deefff"
+ },
+ "warning": {
+ "bg": "#633814",
+ "border": "#945013",
+ "text": "#ffd7a3"
+ },
+ "danger": {
+ "bg": "#311428",
+ "border": "#7f3470",
+ "text": "#efcdea"
+ }
+ }
+ }
+ }
+};
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index e469967..2a0c15f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,9 @@ dependencies = [
"httpx>=0.28,<0.29",
"itsdangerous>=2.2,<3.0",
"python-multipart>=0.0.9,<0.1",
+ "rapidfuzz>=3.14.3",
"sqlalchemy>=2.0,<2.1",
+ "tenacity>=9.1.4",
"uvicorn[standard]>=0.34,<0.35",
]
@@ -25,10 +27,13 @@ dependencies = [
dev = [
"pytest>=8.3,<9.0",
"pytest-asyncio>=0.25,<0.26",
+ "python-semantic-release>=9.0,<10.0",
+ "ruff>=0.9",
+ "mypy>=1.14",
]
[tool.pytest.ini_options]
-addopts = "-q -m \"not integration\""
+addopts = "-q -m \"not integration\" --import-mode=importlib"
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
@@ -39,6 +44,31 @@ markers = [
"smoke: smoke tests for containerized runtime",
]
+[tool.ruff]
+target-version = "py312"
+line-length = 120
+extend-exclude = ["alembic/versions"]
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
+ignore = ["E501", "B008", "RUF001"]
+
+[tool.ruff.lint.isort]
+known-first-party = ["app"]
+
+[tool.mypy]
+python_version = "3.12"
+ignore_missing_imports = true
+check_untyped_defs = false
+warn_unused_ignores = true
+
+[tool.semantic_release]
+version_toml = ["pyproject.toml:project.version"]
+version_variables = ["frontend/package.json:version"]
+branch = "main"
+build_command = ""
+commit_message = "chore(release): v{version}"
+
[tool.setuptools]
include-package-data = true
diff --git a/scripts/check_frontend_api_contract.py b/scripts/check_frontend_api_contract.py
index 07ba856..52e6e97 100755
--- a/scripts/check_frontend_api_contract.py
+++ b/scripts/check_frontend_api_contract.py
@@ -103,9 +103,7 @@ def main() -> int:
print(f"- {method} {route}")
return 1
- print(
- f"Frontend API contract check passed: {len(frontend_routes)} frontend routes match backend definitions."
- )
+ print(f"Frontend API contract check passed: {len(frontend_routes)} frontend routes match backend definitions.")
return 0
diff --git a/scripts/db/check_integrity.py b/scripts/db/check_integrity.py
index b8d4725..bcb2c70 100755
--- a/scripts/db/check_integrity.py
+++ b/scripts/db/check_integrity.py
@@ -6,7 +6,7 @@ import asyncio
import json
from app.db.session import get_session_factory
-from app.services.domains.dbops import collect_integrity_report
+from app.services.dbops import collect_integrity_report
def build_parser() -> argparse.ArgumentParser:
diff --git a/scripts/db/repair_publication_links.py b/scripts/db/repair_publication_links.py
index 44ef56c..ebd00ab 100755
--- a/scripts/db/repair_publication_links.py
+++ b/scripts/db/repair_publication_links.py
@@ -6,7 +6,7 @@ import asyncio
import json
from app.db.session import get_session_factory
-from app.services.domains.dbops import run_publication_link_repair
+from app.services.dbops import run_publication_link_repair
def build_parser() -> argparse.ArgumentParser:
diff --git a/tests/conftest.py b/tests/conftest.py
index 4fa650c..323a396 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,12 +6,12 @@ import re
from collections.abc import AsyncIterator, Iterator
import pytest
-from alembic import command
from alembic.config import Config
-from sqlalchemy.engine import make_url
from sqlalchemy import text
+from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from alembic import command
from app.auth.deps import get_login_rate_limiter
from app.db.session import close_engine
from app.settings import settings
@@ -19,6 +19,8 @@ from app.settings import settings
RESET_SQL = text(
"""
TRUNCATE TABLE
+ arxiv_query_cache_entries,
+ arxiv_runtime_state,
author_search_cache_entries,
author_search_runtime_state,
data_repair_jobs,
@@ -50,9 +52,7 @@ def _resolve_test_database_url() -> str | None:
parsed = make_url(base)
if not parsed.database:
return None
- derived_database = (
- parsed.database if parsed.database.endswith("_test") else f"{parsed.database}_test"
- )
+ derived_database = parsed.database if parsed.database.endswith("_test") else f"{parsed.database}_test"
return parsed.set(database=derived_database).render_as_string(hide_password=False)
@@ -83,9 +83,7 @@ def ensure_test_database_exists(database_url: str) -> Iterator[None]:
if not database_name:
raise RuntimeError("TEST_DATABASE_URL must include a database name.")
if not DB_NAME_SAFE_RE.fullmatch(database_name):
- raise RuntimeError(
- "TEST_DATABASE_URL database name must match [A-Za-z0-9_]+ for safe auto-provisioning."
- )
+ raise RuntimeError("TEST_DATABASE_URL database name must match [A-Za-z0-9_]+ for safe auto-provisioning.")
admin_name = "postgres" if database_name != "postgres" else "template1"
admin_url = parsed.set(database=admin_name)
diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py
index d1f5c68..ce5ed3f 100644
--- a/tests/integration/helpers.py
+++ b/tests/integration/helpers.py
@@ -1,11 +1,36 @@
from __future__ import annotations
+import asyncio
+from pathlib import Path
+
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import PasswordService
+REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
+SAFETY_STATE_KEYS = {
+ "cooldown_active",
+ "cooldown_reason",
+ "cooldown_reason_label",
+ "cooldown_until",
+ "cooldown_remaining_seconds",
+ "recommended_action",
+ "counters",
+}
+SAFETY_COUNTER_KEYS = {
+ "consecutive_blocked_runs",
+ "consecutive_network_runs",
+ "cooldown_entry_count",
+ "blocked_start_count",
+ "last_blocked_failure_count",
+ "last_network_failure_count",
+ "last_evaluated_run_id",
+}
+ACTIVE_RUN_STATUSES = {"running", "resolving"}
+
+
def login_user(client: TestClient, *, email: str, password: str) -> None:
bootstrap_response = client.get("/api/v1/auth/csrf")
assert bootstrap_response.status_code == 200
@@ -53,3 +78,93 @@ async def insert_user(
user_id = int(result.scalar_one())
await db_session.commit()
return user_id
+
+
+def api_bootstrap_csrf_headers(client: TestClient) -> dict[str, str]:
+ bootstrap_response = client.get("/api/v1/auth/csrf")
+ assert bootstrap_response.status_code == 200
+ payload = bootstrap_response.json()["data"]
+ token = payload["csrf_token"]
+ assert isinstance(token, str) and token
+ return {"X-CSRF-Token": token}
+
+
+def api_csrf_headers(client: TestClient) -> dict[str, str]:
+ me_response = client.get("/api/v1/auth/me")
+ assert me_response.status_code == 200
+ body = me_response.json()
+ token = body["data"]["csrf_token"]
+ assert isinstance(token, str) and token
+ return {"X-CSRF-Token": token}
+
+
+def regression_fixture(name: str) -> str:
+ return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
+
+
+def assert_safety_state_contract(payload: dict[str, object]) -> None:
+ assert set(payload.keys()) == SAFETY_STATE_KEYS
+ counters = payload["counters"]
+ assert isinstance(counters, dict)
+ assert set(counters.keys()) == SAFETY_COUNTER_KEYS
+
+
+async def wait_for_run_complete(
+ client: TestClient,
+ run_id: int,
+ *,
+ max_retries: int = 300,
+ poll_interval: float = 0.2,
+) -> dict:
+ for _ in range(max_retries):
+ await asyncio.sleep(poll_interval)
+ r = client.get(f"/api/v1/runs/{run_id}")
+ assert r.status_code == 200
+ data = r.json()["data"]
+ if data["run"]["status"] not in ACTIVE_RUN_STATUSES:
+ return data
+ r = client.get(f"/api/v1/runs/{run_id}")
+ return r.json()["data"]
+
+
+async def seed_publication_link_for_user(
+ db_session: AsyncSession,
+ *,
+ user_id: int,
+ scholar_id: str,
+ title: str,
+ fingerprint: str,
+) -> tuple[int, int]:
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id, "scholar_id": scholar_id, "display_name": scholar_id},
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 4)
+ RETURNING id
+ """
+ ),
+ {"fingerprint": fingerprint, "title_raw": title, "title_normalized": title.lower()},
+ )
+ publication_id = int(publication_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES (:scholar_profile_id, :publication_id, false)
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
+ )
+ await db_session.commit()
+ return scholar_profile_id, publication_id
diff --git a/tests/integration/test_api_admin.py b/tests/integration/test_api_admin.py
new file mode 100644
index 0000000..fb63f34
--- /dev/null
+++ b/tests/integration/test_api_admin.py
@@ -0,0 +1,576 @@
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.main import app
+from app.settings import settings
+from tests.integration.helpers import (
+ api_bootstrap_csrf_headers,
+ api_csrf_headers,
+ insert_user,
+ login_user,
+ seed_publication_link_for_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_user_management_endpoints(db_session: AsyncSession) -> None:
+ admin_user_id = await insert_user(
+ db_session,
+ email="api-admin@example.com",
+ password="admin-password",
+ is_admin=True,
+ )
+ target_user_id = await insert_user(
+ db_session,
+ email="api-target@example.com",
+ password="target-password",
+ is_admin=False,
+ )
+ await insert_user(
+ db_session,
+ email="api-member@example.com",
+ password="member-password",
+ is_admin=False,
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-admin@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ list_response = client.get("/api/v1/admin/users")
+ assert list_response.status_code == 200
+ users = list_response.json()["data"]["users"]
+ assert any(item["email"] == "api-target@example.com" for item in users)
+
+ create_response = client.post(
+ "/api/v1/admin/users",
+ json={
+ "email": "api-created@example.com",
+ "password": "created-password",
+ "is_admin": True,
+ },
+ headers=headers,
+ )
+ assert create_response.status_code == 201
+ created_user = create_response.json()["data"]
+ assert created_user["email"] == "api-created@example.com"
+ created_user_id = int(created_user["id"])
+
+ deactivate_response = client.patch(
+ f"/api/v1/admin/users/{target_user_id}/active",
+ json={"is_active": False},
+ headers=headers,
+ )
+ assert deactivate_response.status_code == 200
+ assert deactivate_response.json()["data"]["is_active"] is False
+
+ reactivate_response = client.patch(
+ f"/api/v1/admin/users/{target_user_id}/active",
+ json={"is_active": True},
+ headers=headers,
+ )
+ assert reactivate_response.status_code == 200
+ assert reactivate_response.json()["data"]["is_active"] is True
+
+ reset_response = client.post(
+ f"/api/v1/admin/users/{target_user_id}/reset-password",
+ json={"new_password": "target-password-updated"},
+ headers=headers,
+ )
+ assert reset_response.status_code == 200
+ assert "Password reset" in reset_response.json()["data"]["message"]
+
+ self_deactivate = client.patch(
+ f"/api/v1/admin/users/{admin_user_id}/active",
+ json={"is_active": False},
+ headers=headers,
+ )
+ assert self_deactivate.status_code == 400
+ assert self_deactivate.json()["error"]["code"] == "cannot_deactivate_self"
+
+ logout_response = client.post("/api/v1/auth/logout", headers=headers)
+ assert logout_response.status_code == 200
+
+ target_headers = api_bootstrap_csrf_headers(client)
+ target_login = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-target@example.com", "password": "target-password-updated"},
+ headers=target_headers,
+ )
+ assert target_login.status_code == 200
+
+ non_admin_headers = {"X-CSRF-Token": target_login.json()["data"]["csrf_token"]}
+ forbidden_response = client.get("/api/v1/admin/users")
+ assert forbidden_response.status_code == 403
+ assert forbidden_response.json()["error"]["code"] == "forbidden"
+
+ forbidden_create = client.post(
+ "/api/v1/admin/users",
+ json={
+ "email": "should-not-work@example.com",
+ "password": "password-123",
+ "is_admin": False,
+ },
+ headers=non_admin_headers,
+ )
+ assert forbidden_create.status_code == 403
+
+ created_exists = await db_session.execute(
+ text("SELECT COUNT(*) FROM users WHERE id = :user_id"),
+ {"user_id": created_user_id},
+ )
+ assert created_exists.scalar_one() == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_scholar_http_settings_endpoints(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-admin-http@example.com",
+ password="admin-password",
+ is_admin=True,
+ )
+ await insert_user(
+ db_session,
+ email="api-member-http@example.com",
+ password="member-password",
+ is_admin=False,
+ )
+ previous_user_agent = settings.scholar_http_user_agent
+ previous_rotate = settings.scholar_http_rotate_user_agent
+ previous_accept_language = settings.scholar_http_accept_language
+ previous_cookie = settings.scholar_http_cookie
+ try:
+ client = TestClient(app)
+ login_user(client, email="api-admin-http@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ read_response = client.get("/api/v1/admin/settings/scholar-http")
+ assert read_response.status_code == 200
+
+ update_response = client.put(
+ "/api/v1/admin/settings/scholar-http",
+ json={
+ "user_agent": "Mozilla/5.0 Test Runner",
+ "rotate_user_agent": False,
+ "accept_language": "en-US,en;q=0.8",
+ "cookie": "SID=test-cookie",
+ },
+ headers=headers,
+ )
+ assert update_response.status_code == 200
+ payload = update_response.json()["data"]
+ assert payload["user_agent"] == "Mozilla/5.0 Test Runner"
+ assert payload["cookie"] == "SID=test-cookie"
+ assert settings.scholar_http_user_agent == "Mozilla/5.0 Test Runner"
+
+ client.post("/api/v1/auth/logout", headers=headers)
+ login_user(client, email="api-member-http@example.com", password="member-password")
+ forbidden_response = client.get("/api/v1/admin/settings/scholar-http")
+ assert forbidden_response.status_code == 403
+ finally:
+ object.__setattr__(settings, "scholar_http_user_agent", previous_user_agent)
+ object.__setattr__(settings, "scholar_http_rotate_user_agent", previous_rotate)
+ object.__setattr__(settings, "scholar_http_accept_language", previous_accept_language)
+ object.__setattr__(settings, "scholar_http_cookie", previous_cookie)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSession) -> None:
+ await insert_user(db_session, email="api-admin-dbops@example.com", password="admin-password", is_admin=True)
+ target_user_id = await insert_user(db_session, email="api-dbops-target@example.com", password="target-password")
+ _scholar_id, publication_id = await seed_publication_link_for_user(
+ db_session,
+ user_id=target_user_id,
+ scholar_id="dbopsTarget01",
+ title="DB Ops Target Paper",
+ fingerprint=f"{(target_user_id + 31):064x}",
+ )
+ client = TestClient(app)
+ login_user(client, email="api-admin-dbops@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ integrity_response = client.get("/api/v1/admin/db/integrity")
+ assert integrity_response.status_code == 200
+ integrity_payload = integrity_response.json()["data"]
+ assert integrity_payload["status"] in {"ok", "warning", "failed"}
+ assert any(check["name"] == "missing_pdf_url" for check in integrity_payload["checks"])
+
+ repair_response = client.post(
+ "/api/v1/admin/db/repairs/publication-links",
+ json={"user_id": target_user_id, "dry_run": True},
+ headers=headers,
+ )
+ assert repair_response.status_code == 200
+ repair_data = repair_response.json()["data"]
+ assert repair_data["status"] == "completed"
+ assert bool(repair_data["summary"]["dry_run"]) is True
+ job_id = int(repair_data["job_id"])
+
+ jobs_response = client.get("/api/v1/admin/db/repair-jobs?limit=10")
+ assert jobs_response.status_code == 200
+ jobs = jobs_response.json()["data"]["jobs"]
+ assert any(int(job["id"]) == job_id for job in jobs)
+
+ pdf_queue_response = client.get("/api/v1/admin/db/pdf-queue?limit=20")
+ assert pdf_queue_response.status_code == 200
+ pdf_queue_payload = pdf_queue_response.json()["data"]
+ assert isinstance(pdf_queue_payload["items"], list)
+ assert int(pdf_queue_payload["page"]) == 1
+ assert int(pdf_queue_payload["page_size"]) == 20
+ assert int(pdf_queue_payload["total_count"]) >= 0
+ assert isinstance(pdf_queue_payload["has_next"], bool)
+ assert isinstance(pdf_queue_payload["has_prev"], bool)
+ page_two_response = client.get("/api/v1/admin/db/pdf-queue?page=2&page_size=1")
+ assert page_two_response.status_code == 200
+ page_two_payload = page_two_response.json()["data"]
+ assert int(page_two_payload["page"]) == 2
+ assert int(page_two_payload["page_size"]) == 1
+ assert page_two_payload["has_prev"] is True
+ untracked_response = client.get("/api/v1/admin/db/pdf-queue?limit=20&status=untracked")
+ assert untracked_response.status_code == 200
+ assert any(item["status"] == "untracked" for item in untracked_response.json()["data"]["items"])
+
+ link_count_result = await db_session.execute(
+ text("SELECT count(*) FROM scholar_publications WHERE publication_id = :publication_id"),
+ {"publication_id": publication_id},
+ )
+ assert int(link_count_result.scalar_one()) == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_pdf_queue_requeue_endpoint(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ await insert_user(db_session, email="api-admin-pdf@example.com", password="admin-password", is_admin=True)
+ target_user_id = await insert_user(db_session, email="api-pdf-target@example.com", password="target-password")
+ _scholar_id, publication_id = await seed_publication_link_for_user(
+ db_session,
+ user_id=target_user_id,
+ scholar_id="dbopsPdf01",
+ title="PDF Queue Target",
+ fingerprint=f"{(target_user_id + 73):064x}",
+ )
+ monkeypatch.setattr(
+ "app.services.publications.pdf_queue.schedule_rows",
+ lambda **_kwargs: None,
+ )
+ client = TestClient(app)
+ login_user(client, email="api-admin-pdf@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ first_response = client.post(
+ f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
+ headers=headers,
+ )
+ assert first_response.status_code == 200
+ first_data = first_response.json()["data"]
+ assert first_data["publication_id"] == publication_id
+ assert first_data["queued"] is True
+ assert first_data["status"] == "queued"
+
+ second_response = client.post(
+ f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
+ headers=headers,
+ )
+ assert second_response.status_code == 200
+ second_data = second_response.json()["data"]
+ assert second_data["queued"] is False
+ assert second_data["status"] == "blocked"
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_pdf_queue_requeue_all_endpoint(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ await insert_user(db_session, email="api-admin-pdf-all@example.com", password="admin-password", is_admin=True)
+ target_user_id = await insert_user(db_session, email="api-pdf-all-target@example.com", password="target-password")
+ _scholar_id, _publication_id = await seed_publication_link_for_user(
+ db_session,
+ user_id=target_user_id,
+ scholar_id="dbopsPdfAll01",
+ title="PDF Queue All Target",
+ fingerprint=f"{(target_user_id + 83):064x}",
+ )
+ monkeypatch.setattr(
+ "app.services.publications.pdf_queue.schedule_rows",
+ lambda **_kwargs: None,
+ )
+ client = TestClient(app)
+ login_user(client, email="api-admin-pdf-all@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ response = client.post(
+ "/api/v1/admin/db/pdf-queue/requeue-all?limit=500",
+ headers=headers,
+ )
+ assert response.status_code == 200
+ payload = response.json()["data"]
+ assert int(payload["requested_count"]) >= 1
+ assert int(payload["queued_count"]) >= 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_session: AsyncSession) -> None:
+ await insert_user(db_session, email="api-admin-dbops2@example.com", password="admin-password", is_admin=True)
+ member_user_id = await insert_user(db_session, email="api-dbops-member@example.com", password="member-password")
+ client = TestClient(app)
+ login_user(client, email="api-dbops-member@example.com", password="member-password")
+ headers = api_csrf_headers(client)
+
+ forbidden_integrity = client.get("/api/v1/admin/db/integrity")
+ assert forbidden_integrity.status_code == 403
+ assert forbidden_integrity.json()["error"]["code"] == "forbidden"
+
+ forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
+ assert forbidden_pdf_queue.status_code == 403
+ assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden"
+
+ forbidden_requeue = client.post(
+ "/api/v1/admin/db/pdf-queue/1/requeue",
+ headers=headers,
+ )
+ assert forbidden_requeue.status_code == 403
+ assert forbidden_requeue.json()["error"]["code"] == "forbidden"
+
+ forbidden_requeue_all = client.post(
+ "/api/v1/admin/db/pdf-queue/requeue-all?limit=100",
+ headers=headers,
+ )
+ assert forbidden_requeue_all.status_code == 403
+ assert forbidden_requeue_all.json()["error"]["code"] == "forbidden"
+
+ forbidden_repair = client.post(
+ "/api/v1/admin/db/repairs/publication-links",
+ json={"user_id": member_user_id, "dry_run": True},
+ headers=headers,
+ )
+ assert forbidden_repair.status_code == 403
+ assert forbidden_repair.json()["error"]["code"] == "forbidden"
+
+ forbidden_near_duplicate = client.post(
+ "/api/v1/admin/db/repairs/publication-near-duplicates",
+ json={"dry_run": True},
+ headers=headers,
+ )
+ assert forbidden_near_duplicate.status_code == 403
+ assert forbidden_near_duplicate.json()["error"]["code"] == "forbidden"
+
+ admin_headers = api_bootstrap_csrf_headers(client)
+ admin_login = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-admin-dbops2@example.com", "password": "admin-password"},
+ headers=admin_headers,
+ )
+ assert admin_login.status_code == 200
+ post_login_headers = {"X-CSRF-Token": admin_login.json()["data"]["csrf_token"]}
+ invalid_scope = client.post(
+ "/api/v1/admin/db/repairs/publication-links",
+ json={"user_id": member_user_id, "dry_run": True},
+ headers=post_login_headers,
+ )
+ assert invalid_scope.status_code == 400
+ assert invalid_scope.json()["error"]["code"] == "invalid_repair_scope"
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_all_users_apply_requires_confirmation(db_session: AsyncSession) -> None:
+ await insert_user(db_session, email="api-admin-dbops3@example.com", password="admin-password", is_admin=True)
+ first_user_id = await insert_user(db_session, email="api-dbops-a@example.com", password="user-password")
+ second_user_id = await insert_user(db_session, email="api-dbops-b@example.com", password="user-password")
+ await seed_publication_link_for_user(
+ db_session,
+ user_id=first_user_id,
+ scholar_id="dbopsAll01",
+ title="DB Ops All User Paper One",
+ fingerprint=f"{(first_user_id + 61):064x}",
+ )
+ await seed_publication_link_for_user(
+ db_session,
+ user_id=second_user_id,
+ scholar_id="dbopsAll02",
+ title="DB Ops All User Paper Two",
+ fingerprint=f"{(second_user_id + 71):064x}",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-admin-dbops3@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ missing_confirmation = client.post(
+ "/api/v1/admin/db/repairs/publication-links",
+ json={"scope_mode": "all_users", "dry_run": False},
+ headers=headers,
+ )
+ assert missing_confirmation.status_code == 422
+ assert "confirmation_text" in str(missing_confirmation.json())
+
+ apply_response = client.post(
+ "/api/v1/admin/db/repairs/publication-links",
+ json={
+ "scope_mode": "all_users",
+ "dry_run": False,
+ "confirmation_text": "REPAIR ALL USERS",
+ },
+ headers=headers,
+ )
+ assert apply_response.status_code == 200
+ apply_data = apply_response.json()["data"]
+ assert apply_data["status"] == "completed"
+ assert apply_data["scope"]["scope_mode"] == "all_users"
+ assert int(apply_data["summary"]["links_deleted"]) >= 2
+
+ remaining_links = await db_session.execute(text("SELECT count(*) FROM scholar_publications"))
+ assert int(remaining_links.scalar_one()) == 0
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_admin_dbops_near_duplicate_repair_scan_and_apply(
+ db_session: AsyncSession,
+) -> None:
+ await insert_user(
+ db_session,
+ email="api-admin-near-dup@example.com",
+ password="admin-password",
+ is_admin=True,
+ )
+ user_id = await insert_user(
+ db_session,
+ email="api-near-dup-target@example.com",
+ password="user-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "nearDupScholar01",
+ "display_name": "Near Dup Target",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ first_pub = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 2014, 100)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1201):064x}",
+ "title_raw": "Adam: A method for stochastic optimization",
+ "title_normalized": "adam a method for stochastic optimization",
+ },
+ )
+ first_publication_id = int(first_pub.scalar_one())
+ second_pub = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, year, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 2015, 10)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1202):064x}",
+ "title_raw": "†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent.",
+ "title_normalized": "adam method for stochastic optimization",
+ },
+ )
+ second_publication_id = int(second_pub.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES (:scholar_profile_id, :first_publication_id, false),
+ (:scholar_profile_id, :second_publication_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "first_publication_id": first_publication_id,
+ "second_publication_id": second_publication_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-admin-near-dup@example.com", password="admin-password")
+ headers = api_csrf_headers(client)
+
+ scan_response = client.post(
+ "/api/v1/admin/db/repairs/publication-near-duplicates",
+ json={"dry_run": True, "max_clusters": 20},
+ headers=headers,
+ )
+ assert scan_response.status_code == 200
+ scan_data = scan_response.json()["data"]
+ assert scan_data["status"] == "completed"
+ assert int(scan_data["summary"]["candidate_cluster_count"]) >= 1
+ assert len(scan_data["clusters"]) >= 1
+ selected_key = str(scan_data["clusters"][0]["cluster_key"])
+
+ missing_confirmation = client.post(
+ "/api/v1/admin/db/repairs/publication-near-duplicates",
+ json={"dry_run": False, "selected_cluster_keys": [selected_key]},
+ headers=headers,
+ )
+ assert missing_confirmation.status_code == 422
+ assert "confirmation_text" in str(missing_confirmation.json())
+
+ apply_response = client.post(
+ "/api/v1/admin/db/repairs/publication-near-duplicates",
+ json={
+ "dry_run": False,
+ "selected_cluster_keys": [selected_key],
+ "confirmation_text": "MERGE SELECTED DUPLICATES",
+ },
+ headers=headers,
+ )
+ assert apply_response.status_code == 200
+ apply_data = apply_response.json()["data"]
+ assert apply_data["status"] == "completed"
+ assert int(apply_data["summary"]["merged_publications"]) >= 1
+
+ remaining = await db_session.execute(
+ text(
+ """
+ SELECT count(*)
+ FROM publications
+ WHERE id IN (:first_publication_id, :second_publication_id)
+ """
+ ),
+ {
+ "first_publication_id": first_publication_id,
+ "second_publication_id": second_publication_id,
+ },
+ )
+ assert int(remaining.scalar_one()) == 1
diff --git a/tests/integration/test_api_auth.py b/tests/integration/test_api_auth.py
new file mode 100644
index 0000000..6d4a0a6
--- /dev/null
+++ b/tests/integration/test_api_auth.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.main import app
+from tests.integration.helpers import (
+ api_bootstrap_csrf_headers,
+ insert_user,
+ login_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_me_requires_authentication() -> None:
+ client = TestClient(app)
+
+ response = client.get("/api/v1/auth/me")
+
+ assert response.status_code == 401
+ payload = response.json()
+ assert payload["error"]["code"] == "auth_required"
+ assert payload["error"]["message"] == "Authentication required."
+ assert "request_id" in payload["meta"]
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_envelope_contract_for_success_and_csrf_error() -> None:
+ client = TestClient(app)
+
+ success_response = client.get("/api/v1/auth/csrf")
+ assert success_response.status_code == 200
+ success_payload = success_response.json()
+ assert set(success_payload.keys()) == {"data", "meta"}
+ assert isinstance(success_payload["data"]["csrf_token"], str)
+ assert set(success_payload["meta"].keys()) == {"request_id"}
+
+ error_response = client.post(
+ "/api/v1/auth/login",
+ json={"email": "missing-csrf@example.com", "password": "irrelevant"},
+ )
+ assert error_response.status_code == 403
+ error_payload = error_response.json()
+ assert set(error_payload.keys()) == {"error", "meta"}
+ assert set(error_payload["error"].keys()) == {"code", "message", "details"}
+ assert error_payload["error"]["code"] == "csrf_invalid"
+ assert error_payload["error"]["details"] is None
+ assert set(error_payload["meta"].keys()) == {"request_id"}
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_csrf_bootstrap_returns_token_for_anonymous_and_authenticated(
+ db_session: AsyncSession,
+) -> None:
+ await insert_user(
+ db_session,
+ email="api-bootstrap@example.com",
+ password="api-password",
+ )
+ client = TestClient(app)
+
+ anonymous_response = client.get("/api/v1/auth/csrf")
+ assert anonymous_response.status_code == 200
+ anonymous_payload = anonymous_response.json()["data"]
+ assert isinstance(anonymous_payload["csrf_token"], str)
+ assert anonymous_payload["authenticated"] is False
+
+ login_user(client, email="api-bootstrap@example.com", password="api-password")
+ authenticated_response = client.get("/api/v1/auth/csrf")
+ assert authenticated_response.status_code == 200
+ authenticated_payload = authenticated_response.json()["data"]
+ assert isinstance(authenticated_payload["csrf_token"], str)
+ assert authenticated_payload["authenticated"] is True
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_me_returns_user_and_csrf_token(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-me@example.com",
+ password="api-password",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-me@example.com", password="api-password")
+
+ response = client.get("/api/v1/auth/me")
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["data"]["authenticated"] is True
+ assert payload["data"]["user"]["email"] == "api-me@example.com"
+ assert isinstance(payload["data"]["csrf_token"], str)
+ assert payload["meta"]["request_id"]
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_login_and_change_password_flow(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-login@example.com",
+ password="old-password",
+ )
+ client = TestClient(app)
+
+ missing_csrf = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-login@example.com", "password": "old-password"},
+ )
+ assert missing_csrf.status_code == 403
+ assert missing_csrf.json()["error"]["code"] == "csrf_missing"
+
+ login_headers = api_bootstrap_csrf_headers(client)
+ login_response = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-login@example.com", "password": "old-password"},
+ headers=login_headers,
+ )
+ assert login_response.status_code == 200
+ login_payload = login_response.json()["data"]
+ assert login_payload["authenticated"] is True
+ assert login_payload["user"]["email"] == "api-login@example.com"
+ assert isinstance(login_payload["csrf_token"], str)
+
+ bad_change_response = client.post(
+ "/api/v1/auth/change-password",
+ json={
+ "current_password": "not-correct",
+ "new_password": "new-password",
+ "confirm_password": "new-password",
+ },
+ headers={"X-CSRF-Token": login_payload["csrf_token"]},
+ )
+ assert bad_change_response.status_code == 400
+ assert bad_change_response.json()["error"]["code"] == "invalid_current_password"
+
+ change_response = client.post(
+ "/api/v1/auth/change-password",
+ json={
+ "current_password": "old-password",
+ "new_password": "new-password",
+ "confirm_password": "new-password",
+ },
+ headers={"X-CSRF-Token": login_payload["csrf_token"]},
+ )
+ assert change_response.status_code == 200
+ assert change_response.json()["data"]["message"] == "Password updated successfully."
+
+ logout_response = client.post(
+ "/api/v1/auth/logout",
+ headers={"X-CSRF-Token": login_payload["csrf_token"]},
+ )
+ assert logout_response.status_code == 200
+
+ relogin_headers = api_bootstrap_csrf_headers(client)
+ old_password_login = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-login@example.com", "password": "old-password"},
+ headers=relogin_headers,
+ )
+ assert old_password_login.status_code == 401
+ assert old_password_login.json()["error"]["code"] == "invalid_credentials"
+
+ fresh_headers = api_bootstrap_csrf_headers(client)
+ new_password_login = client.post(
+ "/api/v1/auth/login",
+ json={"email": "api-login@example.com", "password": "new-password"},
+ headers=fresh_headers,
+ )
+ assert new_password_login.status_code == 200
+ assert new_password_login.json()["data"]["authenticated"] is True
diff --git a/tests/integration/test_api_publications.py b/tests/integration/test_api_publications.py
new file mode 100644
index 0000000..96d3fc5
--- /dev/null
+++ b/tests/integration/test_api_publications.py
@@ -0,0 +1,573 @@
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.main import app
+from tests.integration.helpers import (
+ api_csrf_headers,
+ insert_user,
+ login_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_list_and_mark_read(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs@example.com",
+ password="api-password",
+ )
+
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "abcDEF123456",
+ "display_name": "Publication Owner",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ publication_a = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (
+ fingerprint_sha256,
+ title_raw,
+ title_normalized,
+ citation_count,
+ pdf_url
+ )
+ VALUES (:fingerprint, :title_raw, :title_normalized, 10, :pdf_url)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{user_id:064x}",
+ "title_raw": "Paper A",
+ "title_normalized": "paper a",
+ "pdf_url": "https://example.org/paper-a.pdf",
+ },
+ )
+ publication_a_id = int(publication_a.scalar_one())
+
+ publication_b = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 4)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1):064x}",
+ "title_raw": "Paper B",
+ "title_normalized": "paper b",
+ },
+ )
+ publication_b_id = int(publication_b.scalar_one())
+
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES
+ (:scholar_profile_id, :publication_a_id, false),
+ (:scholar_profile_id, :publication_b_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_a_id": publication_a_id,
+ "publication_b_id": publication_b_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ list_response = client.get("/api/v1/publications?mode=all")
+ assert list_response.status_code == 200
+ data = list_response.json()["data"]
+ assert data["mode"] == "all"
+ assert data["favorite_only"] is False
+ assert data["total_count"] == 2
+ assert data["unread_count"] == 2
+ assert data["favorites_count"] == 0
+ assert data["latest_count"] == 0
+ assert data["new_count"] == data["latest_count"]
+ assert data["page"] == 1
+ assert data["page_size"] == 100
+ assert data["has_prev"] is False
+ assert data["has_next"] is False
+ assert isinstance(data["publications"], list)
+ assert len(data["publications"]) == 2
+ assert all(bool(item["is_favorite"]) is False for item in data["publications"])
+ pdf_urls = {item["title"]: item["pdf_url"] for item in data["publications"]}
+ assert pdf_urls["Paper A"] == "https://example.org/paper-a.pdf"
+ assert pdf_urls["Paper B"] is None
+
+ latest_response = client.get("/api/v1/publications?mode=latest")
+ assert latest_response.status_code == 200
+ latest_data = latest_response.json()["data"]
+ assert latest_data["mode"] == "latest"
+ assert latest_data["latest_count"] == 0
+
+ unread_response = client.get("/api/v1/publications?mode=unread")
+ assert unread_response.status_code == 200
+ unread_data = unread_response.json()["data"]
+ assert unread_data["mode"] == "unread"
+ assert unread_data["unread_count"] == 2
+
+ alias_response = client.get("/api/v1/publications?mode=new")
+ assert alias_response.status_code == 200
+ alias_data = alias_response.json()["data"]
+ assert alias_data["mode"] == "latest"
+ assert alias_data["latest_count"] == latest_data["latest_count"]
+ assert alias_data["publications"] == latest_data["publications"]
+
+ mark_selected_response = client.post(
+ "/api/v1/publications/mark-read",
+ json={
+ "selections": [
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_a_id,
+ }
+ ]
+ },
+ headers=headers,
+ )
+ assert mark_selected_response.status_code == 200
+ assert mark_selected_response.json()["data"]["requested_count"] == 1
+ assert mark_selected_response.json()["data"]["updated_count"] == 1
+
+ read_state = await db_session.execute(
+ text(
+ """
+ SELECT publication_id, is_read
+ FROM scholar_publications
+ WHERE scholar_profile_id = :scholar_profile_id
+ ORDER BY publication_id
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id},
+ )
+ states = {int(row[0]): bool(row[1]) for row in read_state.all()}
+ assert states[publication_a_id] is True
+ assert states[publication_b_id] is False
+
+ mark_response = client.post("/api/v1/publications/mark-all-read", headers=headers)
+ assert mark_response.status_code == 200
+ assert mark_response.json()["data"]["updated_count"] == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_list_supports_pagination(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-paging@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "pagingScholar01",
+ "display_name": "Paging Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ publication_ids: list[int] = []
+ for index in range(3):
+ created = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 500 + index):064x}",
+ "title_raw": f"Paged Paper {index}",
+ "title_normalized": f"paged paper {index}",
+ },
+ )
+ publication_ids.append(int(created.scalar_one()))
+
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES
+ (:scholar_profile_id, :publication_1, false, false),
+ (:scholar_profile_id, :publication_2, false, false),
+ (:scholar_profile_id, :publication_3, false, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_1": publication_ids[0],
+ "publication_2": publication_ids[1],
+ "publication_3": publication_ids[2],
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-paging@example.com", password="api-password")
+
+ first_page = client.get("/api/v1/publications?mode=all&page=1&page_size=2")
+ assert first_page.status_code == 200
+ first_data = first_page.json()["data"]
+ assert first_data["total_count"] == 3
+ assert first_data["page"] == 1
+ assert first_data["page_size"] == 2
+ assert first_data["has_prev"] is False
+ assert first_data["has_next"] is True
+ assert len(first_data["publications"]) == 2
+
+ second_page = client.get("/api/v1/publications?mode=all&page=2&page_size=2")
+ assert second_page.status_code == 200
+ second_data = second_page.json()["data"]
+ assert second_data["total_count"] == 3
+ assert second_data["page"] == 2
+ assert second_data["page_size"] == 2
+ assert second_data["has_prev"] is True
+ assert second_data["has_next"] is False
+ assert len(second_data["publications"]) == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_search_pagination_uses_filtered_total_count(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-search-page@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "searchPagingScholar01",
+ "display_name": "Search Paging Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ titles = ["Alpha Optimization", "Alpha Learning", "Beta Methods"]
+ publication_ids: list[int] = []
+ for index, title in enumerate(titles):
+ created = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 900 + index):064x}",
+ "title_raw": title,
+ "title_normalized": title.lower(),
+ },
+ )
+ publication_ids.append(int(created.scalar_one()))
+ for publication_id in publication_ids:
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES (:scholar_profile_id, :publication_id, false, false)
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-search-page@example.com", password="api-password")
+
+ response = client.get("/api/v1/publications?mode=all&page=1&page_size=2&search=alpha")
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert int(data["total_count"]) == 2
+ assert data["has_next"] is False
+ assert len(data["publications"]) == 2
+ assert all("alpha" in str(item["title"]).lower() for item in data["publications"])
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_supports_sort_by_pdf_status(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-pdf-sort@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "pdfSortScholar01",
+ "display_name": "PDF Sort Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ resolved_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count, pdf_url)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1, :pdf_url)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1300):064x}",
+ "title_raw": "Resolved PDF",
+ "title_normalized": "resolved pdf",
+ "pdf_url": "https://example.org/resolved.pdf",
+ },
+ )
+ resolved_publication_id = int(resolved_result.scalar_one())
+ queued_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1301):064x}",
+ "title_raw": "Queued PDF",
+ "title_normalized": "queued pdf",
+ },
+ )
+ queued_publication_id = int(queued_result.scalar_one())
+ failed_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1302):064x}",
+ "title_raw": "Failed PDF",
+ "title_normalized": "failed pdf",
+ },
+ )
+ failed_publication_id = int(failed_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES
+ (:scholar_profile_id, :resolved_publication_id, false, false),
+ (:scholar_profile_id, :queued_publication_id, false, false),
+ (:scholar_profile_id, :failed_publication_id, false, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "resolved_publication_id": resolved_publication_id,
+ "queued_publication_id": queued_publication_id,
+ "failed_publication_id": failed_publication_id,
+ },
+ )
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO publication_pdf_jobs (publication_id, status, attempt_count)
+ VALUES (:queued_publication_id, 'queued', 1),
+ (:failed_publication_id, 'failed', 2)
+ """
+ ),
+ {
+ "queued_publication_id": queued_publication_id,
+ "failed_publication_id": failed_publication_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-pdf-sort@example.com", password="api-password")
+
+ response = client.get("/api/v1/publications?mode=all&sort_by=pdf_status&sort_dir=desc")
+ assert response.status_code == 200
+ publications = response.json()["data"]["publications"]
+ publication_ids = [int(item["publication_id"]) for item in publications]
+ assert publication_ids[0] == resolved_publication_id
+ assert publication_ids[-1] == failed_publication_id
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_favorite_toggle_and_filter(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-favorites@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "favoriteScholar01",
+ "display_name": "Favorite Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_a_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 9)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 101):064x}",
+ "title_raw": "Favorite Target",
+ "title_normalized": "favorite target",
+ },
+ )
+ publication_a_id = int(publication_a_result.scalar_one())
+ publication_b_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 2)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 102):064x}",
+ "title_raw": "Not Favorite",
+ "title_normalized": "not favorite",
+ },
+ )
+ publication_b_id = int(publication_b_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES
+ (:scholar_profile_id, :publication_a_id, false, false),
+ (:scholar_profile_id, :publication_b_id, false, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_a_id": publication_a_id,
+ "publication_b_id": publication_b_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-favorites@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ set_response = client.post(
+ f"/api/v1/publications/{publication_a_id}/favorite",
+ json={"scholar_profile_id": scholar_profile_id, "is_favorite": True},
+ headers=headers,
+ )
+ assert set_response.status_code == 200
+ set_data = set_response.json()["data"]
+ assert set_data["publication"]["publication_id"] == publication_a_id
+ assert set_data["publication"]["is_favorite"] is True
+
+ favorite_only_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
+ assert favorite_only_response.status_code == 200
+ favorite_only_data = favorite_only_response.json()["data"]
+ assert favorite_only_data["favorite_only"] is True
+ assert favorite_only_data["favorites_count"] == 1
+ assert favorite_only_data["total_count"] == 1
+ assert len(favorite_only_data["publications"]) == 1
+ assert int(favorite_only_data["publications"][0]["publication_id"]) == publication_a_id
+
+ clear_response = client.post(
+ f"/api/v1/publications/{publication_a_id}/favorite",
+ json={"scholar_profile_id": scholar_profile_id, "is_favorite": False},
+ headers=headers,
+ )
+ assert clear_response.status_code == 200
+ clear_data = clear_response.json()["data"]
+ assert clear_data["publication"]["publication_id"] == publication_a_id
+ assert clear_data["publication"]["is_favorite"] is False
+
+ favorite_only_after_clear_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
+ assert favorite_only_after_clear_response.status_code == 200
+ favorite_only_after_clear_data = favorite_only_after_clear_response.json()["data"]
+ assert favorite_only_after_clear_data["favorites_count"] == 0
+ assert favorite_only_after_clear_data["total_count"] == 0
+ assert favorite_only_after_clear_data["publications"] == []
+
+ favorite_state_result = await db_session.execute(
+ text(
+ """
+ SELECT is_favorite
+ FROM scholar_publications
+ WHERE scholar_profile_id = :scholar_profile_id
+ AND publication_id = :publication_id
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_a_id,
+ },
+ )
+ assert bool(favorite_state_result.scalar_one()) is False
diff --git a/tests/integration/test_api_publications_advanced.py b/tests/integration/test_api_publications_advanced.py
new file mode 100644
index 0000000..ce39cab
--- /dev/null
+++ b/tests/integration/test_api_publications_advanced.py
@@ -0,0 +1,337 @@
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.main import app
+from app.services.publications.types import PublicationListItem
+from tests.integration.helpers import (
+ api_csrf_headers,
+ insert_user,
+ login_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_list_schedules_background_enrichment(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-bg@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "background111",
+ "display_name": "Background Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 3)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 17):064x}",
+ "title_raw": "Background Target",
+ "title_normalized": "background target",
+ },
+ )
+ publication_id = int(publication_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES (:scholar_profile_id, :publication_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_id,
+ },
+ )
+ await db_session.commit()
+
+ async def _fake_schedule(db_session, *, user_id: int, request_email: str | None, items, max_items: int):
+ assert db_session is not None
+ assert user_id > 0
+ assert request_email == "api-pubs-bg@example.com"
+ assert int(max_items) > 0
+ assert any(int(item.publication_id) == publication_id for item in items)
+ return 1
+
+ monkeypatch.setattr(
+ "app.services.publications.application.schedule_missing_pdf_enrichment_for_user",
+ _fake_schedule,
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-bg@example.com", password="api-password")
+
+ response = client.get("/api/v1/publications?mode=all")
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert len(data["publications"]) == 1
+ assert int(data["publications"][0]["publication_id"]) == publication_id
+ assert data["publications"][0]["pdf_url"] is None
+ assert data["publications"][0]["pdf_status"] in {"untracked", "queued", "running", "failed"}
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publication_retry_pdf_queues_resolution_job(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-retry@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "retryScholar01",
+ "display_name": "Retry Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 7)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 9):064x}",
+ "title_raw": "Retry Target",
+ "title_normalized": "retry target",
+ },
+ )
+ publication_id = int(publication_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES (:scholar_profile_id, :publication_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_id,
+ },
+ )
+ await db_session.commit()
+
+ async def _fake_retry_scheduler(db_session, *, user_id: int, request_email: str | None, item: PublicationListItem):
+ assert db_session is not None
+ assert user_id > 0
+ assert request_email == "api-pubs-retry@example.com"
+ assert int(item.publication_id) == publication_id
+ return True
+
+ monkeypatch.setattr(
+ "app.services.publications.application.schedule_retry_pdf_enrichment_for_row",
+ _fake_retry_scheduler,
+ )
+
+ async def _fake_hydrate(db_session, *, items: list[PublicationListItem]):
+ assert db_session is not None
+ assert len(items) == 1
+ item = items[0]
+ return [
+ PublicationListItem(
+ publication_id=item.publication_id,
+ scholar_profile_id=item.scholar_profile_id,
+ scholar_label=item.scholar_label,
+ title=item.title,
+ year=item.year,
+ citation_count=item.citation_count,
+ venue_text=item.venue_text,
+ pub_url=item.pub_url,
+ pdf_url=item.pdf_url,
+ is_read=item.is_read,
+ first_seen_at=item.first_seen_at,
+ is_new_in_latest_run=item.is_new_in_latest_run,
+ pdf_status="queued",
+ pdf_attempt_count=1,
+ pdf_failure_reason="no_pdf_found",
+ pdf_failure_detail="no_pdf_found",
+ )
+ ]
+
+ monkeypatch.setattr(
+ "app.services.publications.application.hydrate_pdf_enrichment_state",
+ _fake_hydrate,
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-retry@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ response = client.post(
+ f"/api/v1/publications/{publication_id}/retry-pdf",
+ json={"scholar_profile_id": scholar_profile_id},
+ headers=headers,
+ )
+ assert response.status_code == 200
+ payload = response.json()["data"]
+ assert payload["queued"] is True
+ assert payload["resolved_pdf"] is False
+ assert payload["publication"]["publication_id"] == publication_id
+ assert payload["publication"]["pdf_url"] is None
+ assert payload["publication"]["pdf_status"] == "queued"
+ assert payload["publication"]["pdf_attempt_count"] == 1
+ assert payload["publication"]["pdf_failure_reason"] == "no_pdf_found"
+
+ stored = await db_session.execute(
+ text("SELECT pdf_url FROM publications WHERE id = :publication_id"),
+ {"publication_id": publication_id},
+ )
+ stored_pdf_url = stored.scalar_one()
+ assert stored_pdf_url is None
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_publications_unread_and_latest_modes_can_diverge(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pubs-mismatch@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "mismatchScholar01",
+ "display_name": "Mismatch Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ older_run_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
+ VALUES (:user_id, 'manual', 'success', 1, 1)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ older_run_id = int(older_run_result.scalar_one())
+
+ latest_run_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
+ VALUES (:user_id, 'scheduled', 'success', 1, 0)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ latest_run_id = int(latest_run_result.scalar_one())
+ assert latest_run_id > older_run_id
+
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 12)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 99):064x}",
+ "title_raw": "Unread But Not Latest",
+ "title_normalized": "unread but not latest",
+ },
+ )
+ publication_id = int(publication_result.scalar_one())
+
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (
+ scholar_profile_id,
+ publication_id,
+ is_read,
+ first_seen_run_id
+ )
+ VALUES (:scholar_profile_id, :publication_id, false, :first_seen_run_id)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_id,
+ "first_seen_run_id": older_run_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-pubs-mismatch@example.com", password="api-password")
+
+ unread_response = client.get("/api/v1/publications?mode=unread")
+ assert unread_response.status_code == 200
+ unread_data = unread_response.json()["data"]
+ assert unread_data["mode"] == "unread"
+ assert unread_data["unread_count"] == 1
+ assert unread_data["latest_count"] == 0
+ assert unread_data["new_count"] == 0
+ assert len(unread_data["publications"]) == 1
+ assert int(unread_data["publications"][0]["publication_id"]) == publication_id
+ assert unread_data["publications"][0]["is_new_in_latest_run"] is False
+
+ latest_response = client.get("/api/v1/publications?mode=latest")
+ assert latest_response.status_code == 200
+ latest_data = latest_response.json()["data"]
+ assert latest_data["mode"] == "latest"
+ assert latest_data["latest_count"] == 0
+ assert len(latest_data["publications"]) == 0
+
+ alias_response = client.get("/api/v1/publications?mode=new")
+ assert alias_response.status_code == 200
+ alias_data = alias_response.json()["data"]
+ assert alias_data["mode"] == "latest"
+ assert alias_data["latest_count"] == latest_data["latest_count"]
+ assert alias_data["publications"] == latest_data["publications"]
diff --git a/tests/integration/test_api_runs.py b/tests/integration/test_api_runs.py
new file mode 100644
index 0000000..e8f2f1a
--- /dev/null
+++ b/tests/integration/test_api_runs.py
@@ -0,0 +1,499 @@
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.runtime_deps import get_scholar_source
+from app.main import app
+from app.services.scholar.source import FetchResult
+from app.settings import settings
+from tests.integration.helpers import (
+ api_csrf_headers,
+ assert_safety_state_contract,
+ insert_user,
+ login_user,
+ regression_fixture,
+ wait_for_run_complete,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_manual_run_skips_unchanged_initial_page_for_scholar(
+ db_session: AsyncSession,
+) -> None:
+ await insert_user(
+ db_session,
+ email="api-skip-unchanged@example.com",
+ password="api-password",
+ )
+
+ profile_html = """
+
+
+
+
+
+ Skip Candidate
+ Articles 1-1
+
+
+
+
+ Stable Paper
+ A Author
+ Stable Venue
+
+ 5
+ 2024
+
+
+
+
+
+ """
+
+ class StubScholarSource:
+ async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
+ assert scholar_id == "abcDEF123456"
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ status_code=200,
+ final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ body=profile_html,
+ error=None,
+ )
+
+ async def fetch_profile_page_html(
+ self,
+ scholar_id: str,
+ *,
+ cstart: int,
+ pagesize: int,
+ ) -> FetchResult:
+ assert scholar_id == "abcDEF123456"
+ assert cstart == 0
+ assert pagesize == settings.ingestion_page_size
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ status_code=200,
+ final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ body=profile_html,
+ error=None,
+ )
+
+ app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
+ try:
+ with TestClient(app) as client:
+ login_user(client, email="api-skip-unchanged@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ create_response = client.post(
+ "/api/v1/scholars",
+ json={"scholar_id": "abcDEF123456"},
+ headers=headers,
+ )
+ assert create_response.status_code == 201
+
+ first_run_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "skip-unchanged-run-001"},
+ )
+ assert first_run_response.status_code == 200
+ first_run_id = int(first_run_response.json()["data"]["run_id"])
+
+ first_detail_data = await wait_for_run_complete(client, first_run_id)
+ first_results = first_detail_data["scholar_results"]
+ assert len(first_results) == 1
+ assert first_results[0]["state_reason"] != "no_change_initial_page_signature"
+
+ second_run_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "skip-unchanged-run-002"},
+ )
+ assert second_run_response.status_code == 200
+ second_run_id = int(second_run_response.json()["data"]["run_id"])
+
+ second_detail_data = await wait_for_run_complete(client, second_run_id)
+ second_results = second_detail_data["scholar_results"]
+ assert len(second_results) == 1
+ assert second_results[0]["state_reason"] == "no_change_initial_page_signature"
+ assert second_results[0]["publication_count"] == 0
+ assert second_results[0]["outcome"] == "success"
+ finally:
+ app.dependency_overrides.pop(get_scholar_source, None)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-runs@example.com",
+ password="api-password",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-runs@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ run_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "manual-run-0001"},
+ )
+ assert run_response.status_code == 200
+ run_payload = run_response.json()["data"]
+ assert "run_id" in run_payload
+ assert run_payload["status"] in {"running", "resolving", "success", "partial_failure", "failed"}
+ assert run_payload["reused_existing_run"] is False
+ assert run_payload["idempotency_key"] == "manual-run-0001"
+ assert_safety_state_contract(run_payload["safety_state"])
+ run_id = int(run_payload["run_id"])
+
+ stored_key = await db_session.execute(
+ text("SELECT idempotency_key FROM crawl_runs WHERE id = :run_id"),
+ {"run_id": run_id},
+ )
+ assert stored_key.scalar_one() == "manual-run-0001"
+
+ replay_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "manual-run-0001"},
+ )
+ assert replay_response.status_code in {200, 409}
+ if replay_response.status_code == 200:
+ replay_payload = replay_response.json()["data"]
+ assert replay_payload["run_id"] == run_payload["run_id"]
+ assert replay_payload["reused_existing_run"] is True
+ assert_safety_state_contract(replay_payload["safety_state"])
+ else:
+ replay_error = replay_response.json()["error"]
+ assert replay_error["code"] == "run_in_progress"
+
+ runs_response = client.get("/api/v1/runs")
+ assert runs_response.status_code == 200
+ assert len(runs_response.json()["data"]["runs"]) >= 1
+ assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
+
+ run_detail_response = client.get(f"/api/v1/runs/{run_id}")
+ assert run_detail_response.status_code == 200
+ detail_payload = run_detail_response.json()["data"]
+ assert "summary" in detail_payload
+ assert isinstance(detail_payload["scholar_results"], list)
+ assert_safety_state_contract(detail_payload["safety_state"])
+
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "abcDEF123456",
+ "display_name": "Queue Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ queue_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO ingestion_queue_items (
+ user_id,
+ scholar_profile_id,
+ resume_cstart,
+ reason,
+ status,
+ attempt_count,
+ next_attempt_dt,
+ dropped_reason,
+ dropped_at
+ )
+ VALUES (
+ :user_id,
+ :scholar_profile_id,
+ 7,
+ 'dropped',
+ 'dropped',
+ 2,
+ NOW(),
+ 'manual_drop',
+ NOW()
+ )
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id, "scholar_profile_id": scholar_profile_id},
+ )
+ queue_item_id = int(queue_result.scalar_one())
+ await db_session.commit()
+
+ queue_list_response = client.get("/api/v1/runs/queue/items")
+ assert queue_list_response.status_code == 200
+ assert any(int(item["id"]) == queue_item_id for item in queue_list_response.json()["data"]["queue_items"])
+
+ retry_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/retry", headers=headers)
+ assert retry_response.status_code == 200
+ assert retry_response.json()["data"]["status"] == "queued"
+
+ retry_again_response = client.post(
+ f"/api/v1/runs/queue/{queue_item_id}/retry",
+ headers=headers,
+ )
+ assert retry_again_response.status_code == 409
+ assert retry_again_response.json()["error"]["code"] == "queue_item_already_queued"
+
+ drop_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/drop", headers=headers)
+ assert drop_response.status_code == 200
+ assert drop_response.json()["data"]["status"] == "dropped"
+
+ clear_response = client.request(
+ "DELETE",
+ f"/api/v1/runs/queue/{queue_item_id}",
+ headers=headers,
+ )
+ assert clear_response.status_code == 200
+ assert clear_response.json()["data"]["status"] == "cleared"
+ assert clear_response.json()["data"]["message"] == "Queue item cleared."
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_manual_run_can_be_disabled_by_policy(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-runs-policy@example.com",
+ password="api-password",
+ )
+ client = TestClient(app)
+ login_user(client, email="api-runs-policy@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ previous_manual_allowed = settings.ingestion_manual_run_allowed
+ object.__setattr__(settings, "ingestion_manual_run_allowed", False)
+ try:
+ response = client.post("/api/v1/runs/manual", headers=headers)
+ assert response.status_code == 403
+ payload = response.json()
+ assert payload["error"]["code"] == "manual_runs_disabled"
+ assert payload["error"]["details"]["policy"]["manual_run_allowed"] is False
+ assert payload["error"]["details"]["safety_state"]["cooldown_active"] is False
+ finally:
+ object.__setattr__(settings, "ingestion_manual_run_allowed", previous_manual_allowed)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_manual_run_enforces_scrape_safety_cooldown(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-runs-safety@example.com",
+ password="api-password",
+ )
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "A1B2C3D4E5F6",
+ "display_name": "Safety Probe",
+ },
+ )
+ await db_session.commit()
+
+ blocked_fixture = regression_fixture("profile_AAAAAAAAAAAA.html")
+
+ class BlockedScholarSource:
+ async def fetch_profile_page_html(
+ self,
+ scholar_id: str,
+ *,
+ cstart: int,
+ pagesize: int,
+ ) -> FetchResult:
+ _ = (scholar_id, cstart, pagesize)
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&user=A1B2C3D4E5F6",
+ status_code=200,
+ final_url=(
+ "https://accounts.google.com/v3/signin/identifier"
+ "?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
+ ),
+ body=blocked_fixture,
+ error=None,
+ )
+
+ async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
+ _ = scholar_id
+ return await self.fetch_profile_page_html(
+ "A1B2C3D4E5F6",
+ cstart=0,
+ pagesize=settings.ingestion_page_size,
+ )
+
+ app.dependency_overrides[get_scholar_source] = lambda: BlockedScholarSource()
+
+ previous_blocked_cooldown_seconds = settings.ingestion_safety_cooldown_blocked_seconds
+ object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", 600)
+
+ try:
+ with TestClient(app) as client:
+ login_user(client, email="api-runs-safety@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ preflight_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "safety-cooldown-run-1"},
+ )
+ assert preflight_response.status_code == 429
+ preflight_payload = preflight_response.json()
+ assert preflight_payload["error"]["code"] == "scrape_cooldown_active"
+ preflight_state = preflight_payload["error"]["details"]["safety_state"]
+ assert_safety_state_contract(preflight_state)
+ assert preflight_state["cooldown_active"] is True
+
+ settings_response = client.get("/api/v1/settings")
+ assert settings_response.status_code == 200
+ safety_state = settings_response.json()["data"]["safety_state"]
+ assert_safety_state_contract(safety_state)
+ assert safety_state["cooldown_active"] is True
+ assert safety_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
+ assert int(safety_state["cooldown_remaining_seconds"]) > 0
+ assert int(safety_state["counters"]["cooldown_entry_count"]) >= 1
+
+ blocked_start_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "safety-cooldown-run-2"},
+ )
+ assert blocked_start_response.status_code == 429
+ blocked_payload = blocked_start_response.json()
+ assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
+ blocked_state = blocked_payload["error"]["details"]["safety_state"]
+ assert_safety_state_contract(blocked_state)
+ assert blocked_state["cooldown_active"] is True
+ assert blocked_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
+ assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
+
+ runs_response = client.get("/api/v1/runs")
+ assert runs_response.status_code == 200
+ assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
+ assert runs_response.json()["data"]["safety_state"]["cooldown_active"] is True
+ finally:
+ object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", previous_blocked_cooldown_seconds)
+ app.dependency_overrides.pop(get_scholar_source, None)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_manual_run_enforces_network_failure_safety_cooldown(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-runs-safety-network@example.com",
+ password="api-password",
+ )
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "NNN111NNN111",
+ "display_name": "Network Safety Probe",
+ },
+ )
+ await db_session.commit()
+
+ class NetworkFailureScholarSource:
+ async def fetch_profile_page_html(
+ self,
+ scholar_id: str,
+ *,
+ cstart: int,
+ pagesize: int,
+ ) -> FetchResult:
+ _ = (scholar_id, cstart, pagesize)
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&user=NNN111NNN111",
+ status_code=None,
+ final_url=None,
+ body="",
+ error="timed out",
+ )
+
+ async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
+ _ = scholar_id
+ return await self.fetch_profile_page_html(
+ "NNN111NNN111",
+ cstart=0,
+ pagesize=settings.ingestion_page_size,
+ )
+
+ app.dependency_overrides[get_scholar_source] = lambda: NetworkFailureScholarSource()
+
+ previous_network_threshold = settings.ingestion_alert_network_failure_threshold
+ previous_network_cooldown_seconds = settings.ingestion_safety_cooldown_network_seconds
+ previous_network_retries = settings.ingestion_network_error_retries
+ previous_retry_backoff = settings.ingestion_retry_backoff_seconds
+ object.__setattr__(settings, "ingestion_alert_network_failure_threshold", 1)
+ object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", 600)
+ object.__setattr__(settings, "ingestion_network_error_retries", 0)
+ object.__setattr__(settings, "ingestion_retry_backoff_seconds", 0.0)
+
+ try:
+ with TestClient(app) as client:
+ login_user(client, email="api-runs-safety-network@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ first_run_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-1"},
+ )
+ assert first_run_response.status_code == 200
+ first_run_id = int(first_run_response.json()["data"]["run_id"])
+ await wait_for_run_complete(client, first_run_id)
+
+ settings_response = client.get("/api/v1/settings")
+ assert settings_response.status_code == 200
+ safety_state = settings_response.json()["data"]["safety_state"]
+ assert_safety_state_contract(safety_state)
+ assert safety_state["cooldown_active"] is True
+ assert safety_state["cooldown_reason"] == "network_failure_threshold_exceeded"
+ assert int(safety_state["cooldown_remaining_seconds"]) > 0
+ assert int(safety_state["counters"]["last_network_failure_count"]) >= 1
+
+ blocked_start_response = client.post(
+ "/api/v1/runs/manual",
+ headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-2"},
+ )
+ assert blocked_start_response.status_code == 429
+ blocked_payload = blocked_start_response.json()
+ assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
+ blocked_state = blocked_payload["error"]["details"]["safety_state"]
+ assert_safety_state_contract(blocked_state)
+ assert blocked_state["cooldown_active"] is True
+ assert blocked_state["cooldown_reason"] == "network_failure_threshold_exceeded"
+ assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
+ finally:
+ object.__setattr__(settings, "ingestion_alert_network_failure_threshold", previous_network_threshold)
+ object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", previous_network_cooldown_seconds)
+ object.__setattr__(settings, "ingestion_network_error_retries", previous_network_retries)
+ object.__setattr__(settings, "ingestion_retry_backoff_seconds", previous_retry_backoff)
+ app.dependency_overrides.pop(get_scholar_source, None)
diff --git a/tests/integration/test_api_scholars.py b/tests/integration/test_api_scholars.py
new file mode 100644
index 0000000..7d912fd
--- /dev/null
+++ b/tests/integration/test_api_scholars.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.runtime_deps import get_scholar_source
+from app.main import app
+from app.services.scholar.source import FetchResult
+from app.settings import settings
+from tests.integration.helpers import (
+ api_csrf_headers,
+ insert_user,
+ login_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-scholars@example.com",
+ password="api-password",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-scholars@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ missing_csrf = client.post(
+ "/api/v1/scholars",
+ json={"scholar_id": "abcDEF123456"},
+ )
+ assert missing_csrf.status_code == 403
+ assert missing_csrf.json()["error"]["code"] == "csrf_invalid"
+
+ create_response = client.post(
+ "/api/v1/scholars",
+ json={"scholar_id": "abcDEF123456"},
+ headers=headers,
+ )
+ assert create_response.status_code == 201
+ created = create_response.json()["data"]
+ assert created["scholar_id"] == "abcDEF123456"
+ scholar_profile_id = int(created["id"])
+
+ list_response = client.get("/api/v1/scholars")
+ assert list_response.status_code == 200
+ scholars = list_response.json()["data"]["scholars"]
+ assert any(int(item["id"]) == scholar_profile_id for item in scholars)
+
+ toggle_response = client.patch(
+ f"/api/v1/scholars/{scholar_profile_id}/toggle",
+ headers=headers,
+ )
+ assert toggle_response.status_code == 200
+ assert toggle_response.json()["data"]["is_enabled"] is False
+
+ delete_response = client.delete(
+ f"/api/v1/scholars/{scholar_profile_id}",
+ headers=headers,
+ )
+ assert delete_response.status_code == 200
+ assert delete_response.json()["data"]["message"] == "Scholar deleted."
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_scholars_search_and_profile_image_management(
+ db_session: AsyncSession,
+ tmp_path: Path,
+) -> None:
+ await insert_user(
+ db_session,
+ email="api-scholar-images@example.com",
+ password="api-password",
+ )
+
+ class StubScholarSource:
+ async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
+ assert scholar_id == "abcDEF123456"
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ status_code=200,
+ final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
+ body=(
+ ""
+ ' '
+ ""
+ 'Ada Lovelace
'
+ ""
+ ),
+ error=None,
+ )
+
+ async def fetch_author_search_html(self, query: str, *, start: int) -> FetchResult:
+ assert query == "Ada Lovelace"
+ assert start == 0
+ return FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
+ status_code=200,
+ final_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
+ body=(
+ ''
+ '
'
+ '
Ada Lovelace '
+ '
Analytical Engine
'
+ '
Verified email at computing.example
'
+ '
Cited by 42
'
+ '
Mathematics '
+ "
"
+ ),
+ error=None,
+ )
+
+ previous_upload_dir = settings.scholar_image_upload_dir
+ previous_upload_max_bytes = settings.scholar_image_upload_max_bytes
+ previous_queue_enabled = settings.ingestion_continuation_queue_enabled
+ app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
+ object.__setattr__(settings, "scholar_image_upload_dir", str(tmp_path / "scholar_images"))
+ object.__setattr__(settings, "scholar_image_upload_max_bytes", 1_000_000)
+ object.__setattr__(settings, "ingestion_continuation_queue_enabled", False)
+
+ try:
+ client = TestClient(app)
+ login_user(client, email="api-scholar-images@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ search_response = client.get("/api/v1/scholars/search", params={"query": "Ada Lovelace", "limit": 5})
+ assert search_response.status_code == 200
+ search_payload = search_response.json()["data"]
+ assert search_payload["state"] == "ok"
+ assert len(search_payload["candidates"]) == 1
+ candidate = search_payload["candidates"][0]
+ assert candidate["scholar_id"] == "abcDEF123456"
+ assert candidate["profile_image_url"] == "https://scholar.google.com/citations/images/avatar_scholar_256.png"
+
+ create_response = client.post(
+ "/api/v1/scholars",
+ json={
+ "scholar_id": candidate["scholar_id"],
+ },
+ headers=headers,
+ )
+ assert create_response.status_code == 201
+ created = create_response.json()["data"]
+ scholar_profile_id = int(created["id"])
+ assert created["profile_image_source"] == "scraped"
+ assert created["profile_image_url"] == "https://images.example.com/ada.png"
+
+ set_url_response = client.put(
+ f"/api/v1/scholars/{scholar_profile_id}/image/url",
+ json={"image_url": "https://cdn.example.com/custom-avatar.png"},
+ headers=headers,
+ )
+ assert set_url_response.status_code == 200
+ set_url_data = set_url_response.json()["data"]
+ assert set_url_data["profile_image_source"] == "override"
+ assert set_url_data["profile_image_url"] == "https://cdn.example.com/custom-avatar.png"
+
+ uploaded_bytes = b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01"
+ upload_response = client.post(
+ f"/api/v1/scholars/{scholar_profile_id}/image/upload",
+ files={"image": ("avatar.png", uploaded_bytes, "image/png")},
+ headers=headers,
+ )
+ assert upload_response.status_code == 200
+ upload_data = upload_response.json()["data"]
+ assert upload_data["profile_image_source"] == "upload"
+ assert upload_data["profile_image_url"] == f"/scholar-images/{scholar_profile_id}/upload"
+
+ uploaded_image_response = client.get(f"/scholar-images/{scholar_profile_id}/upload")
+ assert uploaded_image_response.status_code == 200
+ assert uploaded_image_response.headers["content-type"].startswith("image/png")
+ assert uploaded_image_response.content == uploaded_bytes
+
+ clear_response = client.delete(
+ f"/api/v1/scholars/{scholar_profile_id}/image",
+ headers=headers,
+ )
+ assert clear_response.status_code == 200
+ clear_data = clear_response.json()["data"]
+ assert clear_data["profile_image_source"] == "scraped"
+ assert clear_data["profile_image_url"] == "https://images.example.com/ada.png"
+ finally:
+ app.dependency_overrides.pop(get_scholar_source, None)
+ object.__setattr__(settings, "scholar_image_upload_dir", previous_upload_dir)
+ object.__setattr__(
+ settings,
+ "scholar_image_upload_max_bytes",
+ previous_upload_max_bytes,
+ )
+ object.__setattr__(settings, "ingestion_continuation_queue_enabled", previous_queue_enabled)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_scholar_import_export_round_trip(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-import-export@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ RETURNING id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "abcDEF123456",
+ "display_name": "Existing Scholar",
+ },
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 500):064x}",
+ "title_raw": "Existing Publication",
+ "title_normalized": "existingpublication",
+ },
+ )
+ publication_id = int(publication_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
+ VALUES (:scholar_profile_id, :publication_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": publication_id,
+ },
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-import-export@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ export_response = client.get("/api/v1/scholars/export")
+ assert export_response.status_code == 200
+ export_payload = export_response.json()["data"]
+ assert export_payload["schema_version"] == 1
+ assert len(export_payload["scholars"]) == 1
+ assert len(export_payload["publications"]) == 1
+
+ import_response = client.post(
+ "/api/v1/scholars/import",
+ json={
+ "schema_version": 1,
+ "scholars": [
+ {
+ "scholar_id": "abcDEF123456",
+ "display_name": "Updated Scholar",
+ "is_enabled": False,
+ "profile_image_override_url": "https://cdn.example.com/avatar.png",
+ },
+ {
+ "scholar_id": "zzzYYY111222",
+ "display_name": "Imported Scholar",
+ "is_enabled": True,
+ "profile_image_override_url": None,
+ },
+ ],
+ "publications": [
+ {
+ "scholar_id": "abcDEF123456",
+ "title": "Existing Publication",
+ "year": 2024,
+ "citation_count": 8,
+ "author_text": "A. Author",
+ "venue_text": "Test Venue",
+ "pub_url": "https://example.org/existing",
+ "pdf_url": "https://example.org/existing.pdf",
+ "is_read": True,
+ },
+ {
+ "scholar_id": "zzzYYY111222",
+ "title": "Imported Publication",
+ "year": 2025,
+ "citation_count": 2,
+ "author_text": "B. Author",
+ "venue_text": "Another Venue",
+ "pub_url": "https://example.org/imported",
+ "pdf_url": "https://example.org/imported.pdf",
+ "is_read": False,
+ },
+ ],
+ },
+ headers=headers,
+ )
+ assert import_response.status_code == 200
+ import_data = import_response.json()["data"]
+ assert int(import_data["scholars_created"]) == 1
+ assert int(import_data["scholars_updated"]) >= 1
+ assert int(import_data["publications_created"]) == 1
+ assert int(import_data["links_created"]) == 1
+
+ updated_scholar_result = await db_session.execute(
+ text(
+ """
+ SELECT display_name, is_enabled, profile_image_override_url
+ FROM scholar_profiles
+ WHERE user_id = :user_id AND scholar_id = :scholar_id
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "abcDEF123456",
+ },
+ )
+ updated_scholar = updated_scholar_result.one()
+ assert updated_scholar[0] == "Updated Scholar"
+ assert updated_scholar[1] is False
+ assert updated_scholar[2] == "https://cdn.example.com/avatar.png"
+
+ imported_pub_result = await db_session.execute(
+ text(
+ """
+ SELECT p.title_raw, p.pdf_url
+ FROM publications p
+ WHERE p.title_raw = :title
+ """
+ ),
+ {"title": "Imported Publication"},
+ )
+ imported_pub = imported_pub_result.one()
+ assert imported_pub[0] == "Imported Publication"
+ assert imported_pub[1] == "https://example.org/imported.pdf"
+
+ updated_link_result = await db_session.execute(
+ text(
+ """
+ SELECT sp.is_read
+ FROM scholar_publications sp
+ JOIN scholar_profiles s ON s.id = sp.scholar_profile_id
+ JOIN publications p ON p.id = sp.publication_id
+ WHERE s.user_id = :user_id
+ AND s.scholar_id = :scholar_id
+ AND p.title_raw = :title
+ """
+ ),
+ {
+ "user_id": user_id,
+ "scholar_id": "abcDEF123456",
+ "title": "Existing Publication",
+ },
+ )
+ assert bool(updated_link_result.scalar_one()) is True
diff --git a/tests/integration/test_api_settings.py b/tests/integration/test_api_settings.py
new file mode 100644
index 0000000..aa9e2c1
--- /dev/null
+++ b/tests/integration/test_api_settings.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.main import app
+from app.services.settings import application as user_settings_service
+from app.settings import settings
+from tests.integration.helpers import (
+ api_csrf_headers,
+ assert_safety_state_contract,
+ insert_user,
+ login_user,
+)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-settings@example.com",
+ password="api-password",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-settings@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ get_response = client.get("/api/v1/settings")
+ assert get_response.status_code == 200
+ settings_payload = get_response.json()["data"]
+ assert "request_delay_seconds" in settings_payload
+ assert "nav_visible_pages" in settings_payload
+ assert settings_payload["policy"]["min_run_interval_minutes"] == user_settings_service.resolve_run_interval_minimum(
+ settings.ingestion_min_run_interval_minutes
+ )
+ assert settings_payload["policy"][
+ "min_request_delay_seconds"
+ ] == user_settings_service.resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds)
+ assert settings_payload["policy"]["automation_allowed"] is settings.ingestion_automation_allowed
+ assert settings_payload["policy"]["manual_run_allowed"] is settings.ingestion_manual_run_allowed
+ assert settings_payload["policy"]["blocked_failure_threshold"] == max(
+ 1,
+ int(settings.ingestion_alert_blocked_failure_threshold),
+ )
+ assert settings_payload["policy"]["network_failure_threshold"] == max(
+ 1,
+ int(settings.ingestion_alert_network_failure_threshold),
+ )
+ assert settings_payload["policy"]["cooldown_blocked_seconds"] == max(
+ 60,
+ int(settings.ingestion_safety_cooldown_blocked_seconds),
+ )
+ assert settings_payload["policy"]["cooldown_network_seconds"] == max(
+ 60,
+ int(settings.ingestion_safety_cooldown_network_seconds),
+ )
+ assert_safety_state_contract(settings_payload["safety_state"])
+ assert settings_payload["safety_state"]["cooldown_active"] is False
+
+ policy = settings_payload["policy"]
+ run_interval_minutes = max(45, int(policy["min_run_interval_minutes"]))
+ request_delay_seconds = max(6, int(policy["min_request_delay_seconds"]))
+
+ update_response = client.put(
+ "/api/v1/settings",
+ json={
+ "auto_run_enabled": True,
+ "run_interval_minutes": run_interval_minutes,
+ "request_delay_seconds": request_delay_seconds,
+ "nav_visible_pages": ["dashboard", "scholars", "publications", "settings", "runs"],
+ },
+ headers=headers,
+ )
+ assert update_response.status_code == 200
+ updated = update_response.json()["data"]
+ assert updated["auto_run_enabled"] is True
+ assert updated["run_interval_minutes"] == run_interval_minutes
+ assert updated["request_delay_seconds"] == request_delay_seconds
+ assert updated["nav_visible_pages"] == [
+ "dashboard",
+ "scholars",
+ "publications",
+ "settings",
+ "runs",
+ ]
+ assert "policy" in updated
+ assert_safety_state_contract(updated["safety_state"])
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_settings_enforce_env_minimums(db_session: AsyncSession) -> None:
+ await insert_user(
+ db_session,
+ email="api-settings-policy@example.com",
+ password="api-password",
+ )
+
+ client = TestClient(app)
+ login_user(client, email="api-settings-policy@example.com", password="api-password")
+ headers = api_csrf_headers(client)
+
+ previous_min_interval = settings.ingestion_min_run_interval_minutes
+ previous_min_delay = settings.ingestion_min_request_delay_seconds
+ object.__setattr__(settings, "ingestion_min_run_interval_minutes", 30)
+ object.__setattr__(settings, "ingestion_min_request_delay_seconds", 8)
+ try:
+ interval_response = client.put(
+ "/api/v1/settings",
+ json={
+ "auto_run_enabled": True,
+ "run_interval_minutes": 29,
+ "request_delay_seconds": 9,
+ "nav_visible_pages": ["dashboard", "scholars", "settings"],
+ },
+ headers=headers,
+ )
+ assert interval_response.status_code == 400
+ assert interval_response.json()["error"]["code"] == "invalid_settings"
+ assert interval_response.json()["error"]["message"] == "Check interval must be at least 30 minutes."
+
+ delay_response = client.put(
+ "/api/v1/settings",
+ json={
+ "auto_run_enabled": True,
+ "run_interval_minutes": 30,
+ "request_delay_seconds": 7,
+ "nav_visible_pages": ["dashboard", "scholars", "settings"],
+ },
+ headers=headers,
+ )
+ assert delay_response.status_code == 400
+ assert delay_response.json()["error"]["code"] == "invalid_settings"
+ assert delay_response.json()["error"]["message"] == "Request delay must be at least 8 seconds."
+ finally:
+ object.__setattr__(settings, "ingestion_min_run_interval_minutes", previous_min_interval)
+ object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous_min_delay)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_settings_clears_expired_scrape_safety_cooldown(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-settings-safety-expired@example.com",
+ password="api-password",
+ )
+ user_settings = await user_settings_service.get_or_create_settings(
+ db_session,
+ user_id=user_id,
+ )
+ user_settings.scrape_safety_state = {"blocked_start_count": 2}
+ user_settings.scrape_cooldown_reason = "blocked_failure_threshold_exceeded"
+ user_settings.scrape_cooldown_until = datetime.now(UTC) - timedelta(seconds=15)
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-settings-safety-expired@example.com", password="api-password")
+
+ settings_response = client.get("/api/v1/settings")
+ assert settings_response.status_code == 200
+ settings_safety_state = settings_response.json()["data"]["safety_state"]
+ assert_safety_state_contract(settings_safety_state)
+ assert settings_safety_state["cooldown_active"] is False
+ assert settings_safety_state["cooldown_reason"] is None
+ assert int(settings_safety_state["counters"]["blocked_start_count"]) == 2
+
+ runs_response = client.get("/api/v1/runs")
+ assert runs_response.status_code == 200
+ runs_safety_state = runs_response.json()["data"]["safety_state"]
+ assert_safety_state_contract(runs_safety_state)
+ assert runs_safety_state["cooldown_active"] is False
+ assert runs_safety_state["cooldown_reason"] is None
diff --git a/tests/integration/test_api_v1.py b/tests/integration/test_api_v1.py
deleted file mode 100644
index bb21f34..0000000
--- a/tests/integration/test_api_v1.py
+++ /dev/null
@@ -1,2314 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-
-import pytest
-from fastapi.testclient import TestClient
-from sqlalchemy import text
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.api.runtime_deps import get_scholar_source
-from app.main import app
-from app.services.domains.publications.types import PublicationListItem
-from app.services.domains.settings import application as user_settings_service
-from app.services.domains.scholar.source import FetchResult
-from app.settings import settings
-from tests.integration.helpers import insert_user, login_user
-
-REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
-SAFETY_STATE_KEYS = {
- "cooldown_active",
- "cooldown_reason",
- "cooldown_reason_label",
- "cooldown_until",
- "cooldown_remaining_seconds",
- "recommended_action",
- "counters",
-}
-SAFETY_COUNTER_KEYS = {
- "consecutive_blocked_runs",
- "consecutive_network_runs",
- "cooldown_entry_count",
- "blocked_start_count",
- "last_blocked_failure_count",
- "last_network_failure_count",
- "last_evaluated_run_id",
-}
-
-
-def _api_bootstrap_csrf_headers(client: TestClient) -> dict[str, str]:
- bootstrap_response = client.get("/api/v1/auth/csrf")
- assert bootstrap_response.status_code == 200
- payload = bootstrap_response.json()["data"]
- token = payload["csrf_token"]
- assert isinstance(token, str) and token
- return {"X-CSRF-Token": token}
-
-
-def _api_csrf_headers(client: TestClient) -> dict[str, str]:
- me_response = client.get("/api/v1/auth/me")
- assert me_response.status_code == 200
- body = me_response.json()
- token = body["data"]["csrf_token"]
- assert isinstance(token, str) and token
- return {"X-CSRF-Token": token}
-
-
-def _regression_fixture(name: str) -> str:
- return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
-
-
-def _assert_safety_state_contract(payload: dict[str, object]) -> None:
- assert set(payload.keys()) == SAFETY_STATE_KEYS
- counters = payload["counters"]
- assert isinstance(counters, dict)
- assert set(counters.keys()) == SAFETY_COUNTER_KEYS
-
-
-async def _seed_publication_link_for_user(
- db_session: AsyncSession,
- *,
- user_id: int,
- scholar_id: str,
- title: str,
- fingerprint: str,
-) -> tuple[int, int]:
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {"user_id": user_id, "scholar_id": scholar_id, "display_name": scholar_id},
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- publication_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 4)
- RETURNING id
- """
- ),
- {"fingerprint": fingerprint, "title_raw": title, "title_normalized": title.lower()},
- )
- publication_id = int(publication_result.scalar_one())
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
- VALUES (:scholar_profile_id, :publication_id, false)
- """
- ),
- {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
- )
- await db_session.commit()
- return scholar_profile_id, publication_id
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_me_requires_authentication() -> None:
- client = TestClient(app)
-
- response = client.get("/api/v1/auth/me")
-
- assert response.status_code == 401
- payload = response.json()
- assert payload["error"]["code"] == "auth_required"
- assert payload["error"]["message"] == "Authentication required."
- assert "request_id" in payload["meta"]
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_csrf_bootstrap_returns_token_for_anonymous_and_authenticated(
- db_session: AsyncSession,
-) -> None:
- await insert_user(
- db_session,
- email="api-bootstrap@example.com",
- password="api-password",
- )
- client = TestClient(app)
-
- anonymous_response = client.get("/api/v1/auth/csrf")
- assert anonymous_response.status_code == 200
- anonymous_payload = anonymous_response.json()["data"]
- assert isinstance(anonymous_payload["csrf_token"], str)
- assert anonymous_payload["authenticated"] is False
-
- login_user(client, email="api-bootstrap@example.com", password="api-password")
- authenticated_response = client.get("/api/v1/auth/csrf")
- assert authenticated_response.status_code == 200
- authenticated_payload = authenticated_response.json()["data"]
- assert isinstance(authenticated_payload["csrf_token"], str)
- assert authenticated_payload["authenticated"] is True
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_me_returns_user_and_csrf_token(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-me@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-me@example.com", password="api-password")
-
- response = client.get("/api/v1/auth/me")
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["data"]["authenticated"] is True
- assert payload["data"]["user"]["email"] == "api-me@example.com"
- assert isinstance(payload["data"]["csrf_token"], str)
- assert payload["meta"]["request_id"]
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_login_and_change_password_flow(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-login@example.com",
- password="old-password",
- )
- client = TestClient(app)
-
- missing_csrf = client.post(
- "/api/v1/auth/login",
- json={"email": "api-login@example.com", "password": "old-password"},
- )
- assert missing_csrf.status_code == 403
- assert missing_csrf.json()["error"]["code"] == "csrf_missing"
-
- login_headers = _api_bootstrap_csrf_headers(client)
- login_response = client.post(
- "/api/v1/auth/login",
- json={"email": "api-login@example.com", "password": "old-password"},
- headers=login_headers,
- )
- assert login_response.status_code == 200
- login_payload = login_response.json()["data"]
- assert login_payload["authenticated"] is True
- assert login_payload["user"]["email"] == "api-login@example.com"
- assert isinstance(login_payload["csrf_token"], str)
-
- bad_change_response = client.post(
- "/api/v1/auth/change-password",
- json={
- "current_password": "not-correct",
- "new_password": "new-password",
- "confirm_password": "new-password",
- },
- headers={"X-CSRF-Token": login_payload["csrf_token"]},
- )
- assert bad_change_response.status_code == 400
- assert bad_change_response.json()["error"]["code"] == "invalid_current_password"
-
- change_response = client.post(
- "/api/v1/auth/change-password",
- json={
- "current_password": "old-password",
- "new_password": "new-password",
- "confirm_password": "new-password",
- },
- headers={"X-CSRF-Token": login_payload["csrf_token"]},
- )
- assert change_response.status_code == 200
- assert change_response.json()["data"]["message"] == "Password updated successfully."
-
- logout_response = client.post(
- "/api/v1/auth/logout",
- headers={"X-CSRF-Token": login_payload["csrf_token"]},
- )
- assert logout_response.status_code == 200
-
- relogin_headers = _api_bootstrap_csrf_headers(client)
- old_password_login = client.post(
- "/api/v1/auth/login",
- json={"email": "api-login@example.com", "password": "old-password"},
- headers=relogin_headers,
- )
- assert old_password_login.status_code == 401
- assert old_password_login.json()["error"]["code"] == "invalid_credentials"
-
- fresh_headers = _api_bootstrap_csrf_headers(client)
- new_password_login = client.post(
- "/api/v1/auth/login",
- json={"email": "api-login@example.com", "password": "new-password"},
- headers=fresh_headers,
- )
- assert new_password_login.status_code == 200
- assert new_password_login.json()["data"]["authenticated"] is True
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_user_management_endpoints(db_session: AsyncSession) -> None:
- admin_user_id = await insert_user(
- db_session,
- email="api-admin@example.com",
- password="admin-password",
- is_admin=True,
- )
- target_user_id = await insert_user(
- db_session,
- email="api-target@example.com",
- password="target-password",
- is_admin=False,
- )
- await insert_user(
- db_session,
- email="api-member@example.com",
- password="member-password",
- is_admin=False,
- )
-
- client = TestClient(app)
- login_user(client, email="api-admin@example.com", password="admin-password")
- headers = _api_csrf_headers(client)
-
- list_response = client.get("/api/v1/admin/users")
- assert list_response.status_code == 200
- users = list_response.json()["data"]["users"]
- assert any(item["email"] == "api-target@example.com" for item in users)
-
- create_response = client.post(
- "/api/v1/admin/users",
- json={
- "email": "api-created@example.com",
- "password": "created-password",
- "is_admin": True,
- },
- headers=headers,
- )
- assert create_response.status_code == 201
- created_user = create_response.json()["data"]
- assert created_user["email"] == "api-created@example.com"
- created_user_id = int(created_user["id"])
-
- deactivate_response = client.patch(
- f"/api/v1/admin/users/{target_user_id}/active",
- json={"is_active": False},
- headers=headers,
- )
- assert deactivate_response.status_code == 200
- assert deactivate_response.json()["data"]["is_active"] is False
-
- reactivate_response = client.patch(
- f"/api/v1/admin/users/{target_user_id}/active",
- json={"is_active": True},
- headers=headers,
- )
- assert reactivate_response.status_code == 200
- assert reactivate_response.json()["data"]["is_active"] is True
-
- reset_response = client.post(
- f"/api/v1/admin/users/{target_user_id}/reset-password",
- json={"new_password": "target-password-updated"},
- headers=headers,
- )
- assert reset_response.status_code == 200
- assert "Password reset" in reset_response.json()["data"]["message"]
-
- self_deactivate = client.patch(
- f"/api/v1/admin/users/{admin_user_id}/active",
- json={"is_active": False},
- headers=headers,
- )
- assert self_deactivate.status_code == 400
- assert self_deactivate.json()["error"]["code"] == "cannot_deactivate_self"
-
- logout_response = client.post("/api/v1/auth/logout", headers=headers)
- assert logout_response.status_code == 200
-
- target_headers = _api_bootstrap_csrf_headers(client)
- target_login = client.post(
- "/api/v1/auth/login",
- json={"email": "api-target@example.com", "password": "target-password-updated"},
- headers=target_headers,
- )
- assert target_login.status_code == 200
-
- non_admin_headers = {"X-CSRF-Token": target_login.json()["data"]["csrf_token"]}
- forbidden_response = client.get("/api/v1/admin/users")
- assert forbidden_response.status_code == 403
- assert forbidden_response.json()["error"]["code"] == "forbidden"
-
- forbidden_create = client.post(
- "/api/v1/admin/users",
- json={
- "email": "should-not-work@example.com",
- "password": "password-123",
- "is_admin": False,
- },
- headers=non_admin_headers,
- )
- assert forbidden_create.status_code == 403
-
- created_exists = await db_session.execute(
- text("SELECT COUNT(*) FROM users WHERE id = :user_id"),
- {"user_id": created_user_id},
- )
- assert created_exists.scalar_one() == 1
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="api-admin-dbops@example.com", password="admin-password", is_admin=True)
- target_user_id = await insert_user(db_session, email="api-dbops-target@example.com", password="target-password")
- _scholar_id, publication_id = await _seed_publication_link_for_user(
- db_session,
- user_id=target_user_id,
- scholar_id="dbopsTarget01",
- title="DB Ops Target Paper",
- fingerprint=f"{(target_user_id + 31):064x}",
- )
- client = TestClient(app)
- login_user(client, email="api-admin-dbops@example.com", password="admin-password")
- headers = _api_csrf_headers(client)
-
- integrity_response = client.get("/api/v1/admin/db/integrity")
- assert integrity_response.status_code == 200
- integrity_payload = integrity_response.json()["data"]
- assert integrity_payload["status"] in {"ok", "warning", "failed"}
- assert any(
- check["name"] == "missing_pdf_url"
- for check in integrity_payload["checks"]
- )
-
- repair_response = client.post(
- "/api/v1/admin/db/repairs/publication-links",
- json={"user_id": target_user_id, "dry_run": True},
- headers=headers,
- )
- assert repair_response.status_code == 200
- repair_data = repair_response.json()["data"]
- assert repair_data["status"] == "completed"
- assert bool(repair_data["summary"]["dry_run"]) is True
- job_id = int(repair_data["job_id"])
-
- jobs_response = client.get("/api/v1/admin/db/repair-jobs?limit=10")
- assert jobs_response.status_code == 200
- jobs = jobs_response.json()["data"]["jobs"]
- assert any(int(job["id"]) == job_id for job in jobs)
-
- pdf_queue_response = client.get("/api/v1/admin/db/pdf-queue?limit=20")
- assert pdf_queue_response.status_code == 200
- pdf_queue_payload = pdf_queue_response.json()["data"]
- assert isinstance(pdf_queue_payload["items"], list)
- assert int(pdf_queue_payload["page"]) == 1
- assert int(pdf_queue_payload["page_size"]) == 20
- assert int(pdf_queue_payload["total_count"]) >= 0
- assert isinstance(pdf_queue_payload["has_next"], bool)
- assert isinstance(pdf_queue_payload["has_prev"], bool)
- page_two_response = client.get("/api/v1/admin/db/pdf-queue?page=2&page_size=1")
- assert page_two_response.status_code == 200
- page_two_payload = page_two_response.json()["data"]
- assert int(page_two_payload["page"]) == 2
- assert int(page_two_payload["page_size"]) == 1
- assert page_two_payload["has_prev"] is True
- untracked_response = client.get("/api/v1/admin/db/pdf-queue?limit=20&status=untracked")
- assert untracked_response.status_code == 200
- assert any(
- item["status"] == "untracked"
- for item in untracked_response.json()["data"]["items"]
- )
-
- link_count_result = await db_session.execute(
- text("SELECT count(*) FROM scholar_publications WHERE publication_id = :publication_id"),
- {"publication_id": publication_id},
- )
- assert int(link_count_result.scalar_one()) == 1
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_dbops_pdf_queue_requeue_endpoint(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- await insert_user(db_session, email="api-admin-pdf@example.com", password="admin-password", is_admin=True)
- target_user_id = await insert_user(db_session, email="api-pdf-target@example.com", password="target-password")
- _scholar_id, publication_id = await _seed_publication_link_for_user(
- db_session,
- user_id=target_user_id,
- scholar_id="dbopsPdf01",
- title="PDF Queue Target",
- fingerprint=f"{(target_user_id + 73):064x}",
- )
- monkeypatch.setattr(
- "app.services.domains.publications.pdf_queue._schedule_rows",
- lambda **_kwargs: None,
- )
- client = TestClient(app)
- login_user(client, email="api-admin-pdf@example.com", password="admin-password")
- headers = _api_csrf_headers(client)
-
- first_response = client.post(
- f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
- headers=headers,
- )
- assert first_response.status_code == 200
- first_data = first_response.json()["data"]
- assert first_data["publication_id"] == publication_id
- assert first_data["queued"] is True
- assert first_data["status"] == "queued"
-
- second_response = client.post(
- f"/api/v1/admin/db/pdf-queue/{publication_id}/requeue",
- headers=headers,
- )
- assert second_response.status_code == 200
- second_data = second_response.json()["data"]
- assert second_data["queued"] is False
- assert second_data["status"] == "blocked"
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_dbops_pdf_queue_requeue_all_endpoint(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- await insert_user(db_session, email="api-admin-pdf-all@example.com", password="admin-password", is_admin=True)
- target_user_id = await insert_user(db_session, email="api-pdf-all-target@example.com", password="target-password")
- _scholar_id, _publication_id = await _seed_publication_link_for_user(
- db_session,
- user_id=target_user_id,
- scholar_id="dbopsPdfAll01",
- title="PDF Queue All Target",
- fingerprint=f"{(target_user_id + 83):064x}",
- )
- monkeypatch.setattr(
- "app.services.domains.publications.pdf_queue._schedule_rows",
- lambda **_kwargs: None,
- )
- client = TestClient(app)
- login_user(client, email="api-admin-pdf-all@example.com", password="admin-password")
- headers = _api_csrf_headers(client)
-
- response = client.post(
- "/api/v1/admin/db/pdf-queue/requeue-all?limit=500",
- headers=headers,
- )
- assert response.status_code == 200
- payload = response.json()["data"]
- assert int(payload["requested_count"]) >= 1
- assert int(payload["queued_count"]) >= 1
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="api-admin-dbops2@example.com", password="admin-password", is_admin=True)
- member_user_id = await insert_user(db_session, email="api-dbops-member@example.com", password="member-password")
- client = TestClient(app)
- login_user(client, email="api-dbops-member@example.com", password="member-password")
- headers = _api_csrf_headers(client)
-
- forbidden_integrity = client.get("/api/v1/admin/db/integrity")
- assert forbidden_integrity.status_code == 403
- assert forbidden_integrity.json()["error"]["code"] == "forbidden"
-
- forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
- assert forbidden_pdf_queue.status_code == 403
- assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden"
-
- forbidden_requeue = client.post(
- "/api/v1/admin/db/pdf-queue/1/requeue",
- headers=headers,
- )
- assert forbidden_requeue.status_code == 403
- assert forbidden_requeue.json()["error"]["code"] == "forbidden"
-
- forbidden_requeue_all = client.post(
- "/api/v1/admin/db/pdf-queue/requeue-all?limit=100",
- headers=headers,
- )
- assert forbidden_requeue_all.status_code == 403
- assert forbidden_requeue_all.json()["error"]["code"] == "forbidden"
-
- forbidden_repair = client.post(
- "/api/v1/admin/db/repairs/publication-links",
- json={"user_id": member_user_id, "dry_run": True},
- headers=headers,
- )
- assert forbidden_repair.status_code == 403
- assert forbidden_repair.json()["error"]["code"] == "forbidden"
-
- admin_headers = _api_bootstrap_csrf_headers(client)
- admin_login = client.post(
- "/api/v1/auth/login",
- json={"email": "api-admin-dbops2@example.com", "password": "admin-password"},
- headers=admin_headers,
- )
- assert admin_login.status_code == 200
- post_login_headers = {"X-CSRF-Token": admin_login.json()["data"]["csrf_token"]}
- invalid_scope = client.post(
- "/api/v1/admin/db/repairs/publication-links",
- json={"user_id": member_user_id, "dry_run": True},
- headers=post_login_headers,
- )
- assert invalid_scope.status_code == 400
- assert invalid_scope.json()["error"]["code"] == "invalid_repair_scope"
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_admin_dbops_all_users_apply_requires_confirmation(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="api-admin-dbops3@example.com", password="admin-password", is_admin=True)
- first_user_id = await insert_user(db_session, email="api-dbops-a@example.com", password="user-password")
- second_user_id = await insert_user(db_session, email="api-dbops-b@example.com", password="user-password")
- await _seed_publication_link_for_user(
- db_session,
- user_id=first_user_id,
- scholar_id="dbopsAll01",
- title="DB Ops All User Paper One",
- fingerprint=f"{(first_user_id + 61):064x}",
- )
- await _seed_publication_link_for_user(
- db_session,
- user_id=second_user_id,
- scholar_id="dbopsAll02",
- title="DB Ops All User Paper Two",
- fingerprint=f"{(second_user_id + 71):064x}",
- )
-
- client = TestClient(app)
- login_user(client, email="api-admin-dbops3@example.com", password="admin-password")
- headers = _api_csrf_headers(client)
-
- missing_confirmation = client.post(
- "/api/v1/admin/db/repairs/publication-links",
- json={"scope_mode": "all_users", "dry_run": False},
- headers=headers,
- )
- assert missing_confirmation.status_code == 422
- assert "confirmation_text" in str(missing_confirmation.json())
-
- apply_response = client.post(
- "/api/v1/admin/db/repairs/publication-links",
- json={
- "scope_mode": "all_users",
- "dry_run": False,
- "confirmation_text": "REPAIR ALL USERS",
- },
- headers=headers,
- )
- assert apply_response.status_code == 200
- apply_data = apply_response.json()["data"]
- assert apply_data["status"] == "completed"
- assert apply_data["scope"]["scope_mode"] == "all_users"
- assert int(apply_data["summary"]["links_deleted"]) >= 2
-
- remaining_links = await db_session.execute(text("SELECT count(*) FROM scholar_publications"))
- assert int(remaining_links.scalar_one()) == 0
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-scholars@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-scholars@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- missing_csrf = client.post(
- "/api/v1/scholars",
- json={"scholar_id": "abcDEF123456"},
- )
- assert missing_csrf.status_code == 403
- assert missing_csrf.json()["error"]["code"] == "csrf_invalid"
-
- create_response = client.post(
- "/api/v1/scholars",
- json={"scholar_id": "abcDEF123456"},
- headers=headers,
- )
- assert create_response.status_code == 201
- created = create_response.json()["data"]
- assert created["scholar_id"] == "abcDEF123456"
- scholar_profile_id = int(created["id"])
-
- list_response = client.get("/api/v1/scholars")
- assert list_response.status_code == 200
- scholars = list_response.json()["data"]["scholars"]
- assert any(int(item["id"]) == scholar_profile_id for item in scholars)
-
- toggle_response = client.patch(
- f"/api/v1/scholars/{scholar_profile_id}/toggle",
- headers=headers,
- )
- assert toggle_response.status_code == 200
- assert toggle_response.json()["data"]["is_enabled"] is False
-
- delete_response = client.delete(
- f"/api/v1/scholars/{scholar_profile_id}",
- headers=headers,
- )
- assert delete_response.status_code == 200
- assert delete_response.json()["data"]["message"] == "Scholar deleted."
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_scholars_search_and_profile_image_management(
- db_session: AsyncSession,
- tmp_path: Path,
-) -> None:
- await insert_user(
- db_session,
- email="api-scholar-images@example.com",
- password="api-password",
- )
-
- class StubScholarSource:
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- assert scholar_id == "abcDEF123456"
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- status_code=200,
- final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- body=(
- ""
- ' '
- ""
- 'Ada Lovelace
'
- ""
- ),
- error=None,
- )
-
- async def fetch_author_search_html(self, query: str, *, start: int) -> FetchResult:
- assert query == "Ada Lovelace"
- assert start == 0
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
- status_code=200,
- final_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
- body=(
- ''
- '
'
- '
Ada Lovelace '
- '
Analytical Engine
'
- '
Verified email at computing.example
'
- '
Cited by 42
'
- '
Mathematics '
- "
"
- ),
- error=None,
- )
-
- previous_upload_dir = settings.scholar_image_upload_dir
- previous_upload_max_bytes = settings.scholar_image_upload_max_bytes
- app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
- object.__setattr__(settings, "scholar_image_upload_dir", str(tmp_path / "scholar_images"))
- object.__setattr__(settings, "scholar_image_upload_max_bytes", 1_000_000)
-
- try:
- client = TestClient(app)
- login_user(client, email="api-scholar-images@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- search_response = client.get("/api/v1/scholars/search", params={"query": "Ada Lovelace", "limit": 5})
- assert search_response.status_code == 200
- search_payload = search_response.json()["data"]
- assert search_payload["state"] == "ok"
- assert len(search_payload["candidates"]) == 1
- candidate = search_payload["candidates"][0]
- assert candidate["scholar_id"] == "abcDEF123456"
- assert candidate["profile_image_url"] == "https://scholar.google.com/citations/images/avatar_scholar_256.png"
-
- create_response = client.post(
- "/api/v1/scholars",
- json={
- "scholar_id": candidate["scholar_id"],
- },
- headers=headers,
- )
- assert create_response.status_code == 201
- created = create_response.json()["data"]
- scholar_profile_id = int(created["id"])
- assert created["profile_image_source"] == "scraped"
- assert created["profile_image_url"] == "https://images.example.com/ada.png"
-
- set_url_response = client.put(
- f"/api/v1/scholars/{scholar_profile_id}/image/url",
- json={"image_url": "https://cdn.example.com/custom-avatar.png"},
- headers=headers,
- )
- assert set_url_response.status_code == 200
- set_url_data = set_url_response.json()["data"]
- assert set_url_data["profile_image_source"] == "override"
- assert set_url_data["profile_image_url"] == "https://cdn.example.com/custom-avatar.png"
-
- uploaded_bytes = b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01"
- upload_response = client.post(
- f"/api/v1/scholars/{scholar_profile_id}/image/upload",
- files={"image": ("avatar.png", uploaded_bytes, "image/png")},
- headers=headers,
- )
- assert upload_response.status_code == 200
- upload_data = upload_response.json()["data"]
- assert upload_data["profile_image_source"] == "upload"
- assert upload_data["profile_image_url"] == f"/scholar-images/{scholar_profile_id}/upload"
-
- uploaded_image_response = client.get(f"/scholar-images/{scholar_profile_id}/upload")
- assert uploaded_image_response.status_code == 200
- assert uploaded_image_response.headers["content-type"].startswith("image/png")
- assert uploaded_image_response.content == uploaded_bytes
-
- clear_response = client.delete(
- f"/api/v1/scholars/{scholar_profile_id}/image",
- headers=headers,
- )
- assert clear_response.status_code == 200
- clear_data = clear_response.json()["data"]
- assert clear_data["profile_image_source"] == "scraped"
- assert clear_data["profile_image_url"] == "https://images.example.com/ada.png"
- finally:
- app.dependency_overrides.pop(get_scholar_source, None)
- object.__setattr__(settings, "scholar_image_upload_dir", previous_upload_dir)
- object.__setattr__(
- settings,
- "scholar_image_upload_max_bytes",
- previous_upload_max_bytes,
- )
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_scholar_import_export_round_trip(
- db_session: AsyncSession,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-import-export@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "abcDEF123456",
- "display_name": "Existing Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- publication_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 1)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 500):064x}",
- "title_raw": "Existing Publication",
- "title_normalized": "existingpublication",
- },
- )
- publication_id = int(publication_result.scalar_one())
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
- VALUES (:scholar_profile_id, :publication_id, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_id,
- },
- )
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-import-export@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- export_response = client.get("/api/v1/scholars/export")
- assert export_response.status_code == 200
- export_payload = export_response.json()["data"]
- assert export_payload["schema_version"] == 1
- assert len(export_payload["scholars"]) == 1
- assert len(export_payload["publications"]) == 1
-
- import_response = client.post(
- "/api/v1/scholars/import",
- json={
- "schema_version": 1,
- "scholars": [
- {
- "scholar_id": "abcDEF123456",
- "display_name": "Updated Scholar",
- "is_enabled": False,
- "profile_image_override_url": "https://cdn.example.com/avatar.png",
- },
- {
- "scholar_id": "zzzYYY111222",
- "display_name": "Imported Scholar",
- "is_enabled": True,
- "profile_image_override_url": None,
- },
- ],
- "publications": [
- {
- "scholar_id": "abcDEF123456",
- "title": "Existing Publication",
- "year": 2024,
- "citation_count": 8,
- "author_text": "A. Author",
- "venue_text": "Test Venue",
- "pub_url": "https://example.org/existing",
- "pdf_url": "https://example.org/existing.pdf",
- "is_read": True,
- },
- {
- "scholar_id": "zzzYYY111222",
- "title": "Imported Publication",
- "year": 2025,
- "citation_count": 2,
- "author_text": "B. Author",
- "venue_text": "Another Venue",
- "pub_url": "https://example.org/imported",
- "pdf_url": "https://example.org/imported.pdf",
- "is_read": False,
- },
- ],
- },
- headers=headers,
- )
- assert import_response.status_code == 200
- import_data = import_response.json()["data"]
- assert int(import_data["scholars_created"]) == 1
- assert int(import_data["scholars_updated"]) >= 1
- assert int(import_data["publications_created"]) == 1
- assert int(import_data["links_created"]) == 1
-
- updated_scholar_result = await db_session.execute(
- text(
- """
- SELECT display_name, is_enabled, profile_image_override_url
- FROM scholar_profiles
- WHERE user_id = :user_id AND scholar_id = :scholar_id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "abcDEF123456",
- },
- )
- updated_scholar = updated_scholar_result.one()
- assert updated_scholar[0] == "Updated Scholar"
- assert updated_scholar[1] is False
- assert updated_scholar[2] == "https://cdn.example.com/avatar.png"
-
- imported_pub_result = await db_session.execute(
- text(
- """
- SELECT p.title_raw, p.pdf_url
- FROM publications p
- WHERE p.title_raw = :title
- """
- ),
- {"title": "Imported Publication"},
- )
- imported_pub = imported_pub_result.one()
- assert imported_pub[0] == "Imported Publication"
- assert imported_pub[1] == "https://example.org/imported.pdf"
-
- updated_link_result = await db_session.execute(
- text(
- """
- SELECT sp.is_read
- FROM scholar_publications sp
- JOIN scholar_profiles s ON s.id = sp.scholar_profile_id
- JOIN publications p ON p.id = sp.publication_id
- WHERE s.user_id = :user_id
- AND s.scholar_id = :scholar_id
- AND p.title_raw = :title
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "abcDEF123456",
- "title": "Existing Publication",
- },
- )
- assert bool(updated_link_result.scalar_one()) is True
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_manual_run_skips_unchanged_initial_page_for_scholar(
- db_session: AsyncSession,
-) -> None:
- await insert_user(
- db_session,
- email="api-skip-unchanged@example.com",
- password="api-password",
- )
-
- profile_html = """
-
-
-
-
-
- Skip Candidate
- Articles 1-1
-
-
-
-
- Stable Paper
- A Author
- Stable Venue
-
- 5
- 2024
-
-
-
-
-
- """
-
- class StubScholarSource:
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- assert scholar_id == "abcDEF123456"
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- status_code=200,
- final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- body=profile_html,
- error=None,
- )
-
- async def fetch_profile_page_html(
- self,
- scholar_id: str,
- *,
- cstart: int,
- pagesize: int,
- ) -> FetchResult:
- assert scholar_id == "abcDEF123456"
- assert cstart == 0
- assert pagesize == settings.ingestion_page_size
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- status_code=200,
- final_url="https://scholar.google.com/citations?hl=en&user=abcDEF123456",
- body=profile_html,
- error=None,
- )
-
- app.dependency_overrides[get_scholar_source] = lambda: StubScholarSource()
- try:
- client = TestClient(app)
- login_user(client, email="api-skip-unchanged@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- create_response = client.post(
- "/api/v1/scholars",
- json={"scholar_id": "abcDEF123456"},
- headers=headers,
- )
- assert create_response.status_code == 201
-
- first_run_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "skip-unchanged-run-001"},
- )
- assert first_run_response.status_code == 200
- first_run_id = int(first_run_response.json()["data"]["run_id"])
-
- first_run_detail = client.get(f"/api/v1/runs/{first_run_id}")
- assert first_run_detail.status_code == 200
- first_results = first_run_detail.json()["data"]["scholar_results"]
- assert len(first_results) == 1
- assert first_results[0]["state_reason"] != "no_change_initial_page_signature"
-
- second_run_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "skip-unchanged-run-002"},
- )
- assert second_run_response.status_code == 200
- second_run_id = int(second_run_response.json()["data"]["run_id"])
-
- second_run_detail = client.get(f"/api/v1/runs/{second_run_id}")
- assert second_run_detail.status_code == 200
- second_results = second_run_detail.json()["data"]["scholar_results"]
- assert len(second_results) == 1
- assert second_results[0]["state_reason"] == "no_change_initial_page_signature"
- assert second_results[0]["publication_count"] == 0
- assert second_results[0]["outcome"] == "success"
- finally:
- app.dependency_overrides.pop(get_scholar_source, None)
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-settings@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-settings@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- get_response = client.get("/api/v1/settings")
- assert get_response.status_code == 200
- settings_payload = get_response.json()["data"]
- assert "request_delay_seconds" in settings_payload
- assert "nav_visible_pages" in settings_payload
- assert settings_payload["policy"]["min_run_interval_minutes"] == user_settings_service.resolve_run_interval_minimum(
- settings.ingestion_min_run_interval_minutes
- )
- assert settings_payload["policy"]["min_request_delay_seconds"] == user_settings_service.resolve_request_delay_minimum(
- settings.ingestion_min_request_delay_seconds
- )
- assert settings_payload["policy"]["automation_allowed"] is settings.ingestion_automation_allowed
- assert settings_payload["policy"]["manual_run_allowed"] is settings.ingestion_manual_run_allowed
- assert settings_payload["policy"]["blocked_failure_threshold"] == max(
- 1,
- int(settings.ingestion_alert_blocked_failure_threshold),
- )
- assert settings_payload["policy"]["network_failure_threshold"] == max(
- 1,
- int(settings.ingestion_alert_network_failure_threshold),
- )
- assert settings_payload["policy"]["cooldown_blocked_seconds"] == max(
- 60,
- int(settings.ingestion_safety_cooldown_blocked_seconds),
- )
- assert settings_payload["policy"]["cooldown_network_seconds"] == max(
- 60,
- int(settings.ingestion_safety_cooldown_network_seconds),
- )
- _assert_safety_state_contract(settings_payload["safety_state"])
- assert settings_payload["safety_state"]["cooldown_active"] is False
-
- update_response = client.put(
- "/api/v1/settings",
- json={
- "auto_run_enabled": True,
- "run_interval_minutes": 45,
- "request_delay_seconds": 6,
- "nav_visible_pages": ["dashboard", "scholars", "publications", "settings", "runs"],
- },
- headers=headers,
- )
- assert update_response.status_code == 200
- updated = update_response.json()["data"]
- assert updated["auto_run_enabled"] is True
- assert updated["run_interval_minutes"] == 45
- assert updated["request_delay_seconds"] == 6
- assert updated["nav_visible_pages"] == [
- "dashboard",
- "scholars",
- "publications",
- "settings",
- "runs",
- ]
- assert "policy" in updated
- _assert_safety_state_contract(updated["safety_state"])
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_settings_enforce_env_minimums(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-settings-policy@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-settings-policy@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- previous_min_interval = settings.ingestion_min_run_interval_minutes
- previous_min_delay = settings.ingestion_min_request_delay_seconds
- object.__setattr__(settings, "ingestion_min_run_interval_minutes", 30)
- object.__setattr__(settings, "ingestion_min_request_delay_seconds", 8)
- try:
- interval_response = client.put(
- "/api/v1/settings",
- json={
- "auto_run_enabled": True,
- "run_interval_minutes": 29,
- "request_delay_seconds": 9,
- "nav_visible_pages": ["dashboard", "scholars", "settings"],
- },
- headers=headers,
- )
- assert interval_response.status_code == 400
- assert interval_response.json()["error"]["code"] == "invalid_settings"
- assert interval_response.json()["error"]["message"] == "Check interval must be at least 30 minutes."
-
- delay_response = client.put(
- "/api/v1/settings",
- json={
- "auto_run_enabled": True,
- "run_interval_minutes": 30,
- "request_delay_seconds": 7,
- "nav_visible_pages": ["dashboard", "scholars", "settings"],
- },
- headers=headers,
- )
- assert delay_response.status_code == 400
- assert delay_response.json()["error"]["code"] == "invalid_settings"
- assert delay_response.json()["error"]["message"] == "Request delay must be at least 8 seconds."
- finally:
- object.__setattr__(settings, "ingestion_min_run_interval_minutes", previous_min_interval)
- object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous_min_delay)
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-runs@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-runs@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- run_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "manual-run-0001"},
- )
- assert run_response.status_code == 200
- run_payload = run_response.json()["data"]
- assert "run_id" in run_payload
- assert run_payload["status"] in {"success", "partial_failure", "failed"}
- assert run_payload["reused_existing_run"] is False
- assert run_payload["idempotency_key"] == "manual-run-0001"
- _assert_safety_state_contract(run_payload["safety_state"])
- run_id = int(run_payload["run_id"])
-
- stored_key = await db_session.execute(
- text("SELECT idempotency_key FROM crawl_runs WHERE id = :run_id"),
- {"run_id": run_id},
- )
- assert stored_key.scalar_one() == "manual-run-0001"
-
- replay_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "manual-run-0001"},
- )
- assert replay_response.status_code == 200
- replay_payload = replay_response.json()["data"]
- assert replay_payload["run_id"] == run_payload["run_id"]
- assert replay_payload["reused_existing_run"] is True
- _assert_safety_state_contract(replay_payload["safety_state"])
-
- runs_response = client.get("/api/v1/runs")
- assert runs_response.status_code == 200
- assert len(runs_response.json()["data"]["runs"]) >= 1
- _assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
-
- run_detail_response = client.get(f"/api/v1/runs/{run_id}")
- assert run_detail_response.status_code == 200
- detail_payload = run_detail_response.json()["data"]
- assert "summary" in detail_payload
- assert isinstance(detail_payload["scholar_results"], list)
- _assert_safety_state_contract(detail_payload["safety_state"])
-
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "abcDEF123456",
- "display_name": "Queue Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- queue_result = await db_session.execute(
- text(
- """
- INSERT INTO ingestion_queue_items (
- user_id,
- scholar_profile_id,
- resume_cstart,
- reason,
- status,
- attempt_count,
- next_attempt_dt,
- dropped_reason,
- dropped_at
- )
- VALUES (
- :user_id,
- :scholar_profile_id,
- 7,
- 'dropped',
- 'dropped',
- 2,
- NOW(),
- 'manual_drop',
- NOW()
- )
- RETURNING id
- """
- ),
- {"user_id": user_id, "scholar_profile_id": scholar_profile_id},
- )
- queue_item_id = int(queue_result.scalar_one())
- await db_session.commit()
-
- queue_list_response = client.get("/api/v1/runs/queue/items")
- assert queue_list_response.status_code == 200
- assert any(
- int(item["id"]) == queue_item_id
- for item in queue_list_response.json()["data"]["queue_items"]
- )
-
- retry_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/retry", headers=headers)
- assert retry_response.status_code == 200
- assert retry_response.json()["data"]["status"] == "queued"
-
- retry_again_response = client.post(
- f"/api/v1/runs/queue/{queue_item_id}/retry",
- headers=headers,
- )
- assert retry_again_response.status_code == 409
- assert retry_again_response.json()["error"]["code"] == "queue_item_already_queued"
-
- drop_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/drop", headers=headers)
- assert drop_response.status_code == 200
- assert drop_response.json()["data"]["status"] == "dropped"
-
- clear_response = client.request(
- "DELETE",
- f"/api/v1/runs/queue/{queue_item_id}",
- headers=headers,
- )
- assert clear_response.status_code == 200
- assert clear_response.json()["data"]["status"] == "cleared"
- assert clear_response.json()["data"]["message"] == "Queue item cleared."
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_manual_run_can_be_disabled_by_policy(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-runs-policy@example.com",
- password="api-password",
- )
- client = TestClient(app)
- login_user(client, email="api-runs-policy@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- previous_manual_allowed = settings.ingestion_manual_run_allowed
- object.__setattr__(settings, "ingestion_manual_run_allowed", False)
- try:
- response = client.post("/api/v1/runs/manual", headers=headers)
- assert response.status_code == 403
- payload = response.json()
- assert payload["error"]["code"] == "manual_runs_disabled"
- assert payload["error"]["details"]["policy"]["manual_run_allowed"] is False
- assert payload["error"]["details"]["safety_state"]["cooldown_active"] is False
- finally:
- object.__setattr__(settings, "ingestion_manual_run_allowed", previous_manual_allowed)
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_manual_run_enforces_scrape_safety_cooldown(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-runs-safety@example.com",
- password="api-password",
- )
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "A1B2C3D4E5F6",
- "display_name": "Safety Probe",
- },
- )
- await db_session.commit()
-
- blocked_fixture = _regression_fixture("profile_AAAAAAAAAAAA.html")
-
- class BlockedScholarSource:
- async def fetch_profile_page_html(
- self,
- scholar_id: str,
- *,
- cstart: int,
- pagesize: int,
- ) -> FetchResult:
- _ = (scholar_id, cstart, pagesize)
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&user=A1B2C3D4E5F6",
- status_code=200,
- final_url=(
- "https://accounts.google.com/v3/signin/identifier"
- "?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
- ),
- body=blocked_fixture,
- error=None,
- )
-
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- _ = scholar_id
- return await self.fetch_profile_page_html(
- "A1B2C3D4E5F6",
- cstart=0,
- pagesize=settings.ingestion_page_size,
- )
-
- app.dependency_overrides[get_scholar_source] = lambda: BlockedScholarSource()
-
- previous_blocked_threshold = settings.ingestion_alert_blocked_failure_threshold
- previous_blocked_cooldown_seconds = settings.ingestion_safety_cooldown_blocked_seconds
- object.__setattr__(settings, "ingestion_alert_blocked_failure_threshold", 1)
- object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", 600)
-
- try:
- client = TestClient(app)
- login_user(client, email="api-runs-safety@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- first_run_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "safety-cooldown-run-1"},
- )
- assert first_run_response.status_code == 200
-
- settings_response = client.get("/api/v1/settings")
- assert settings_response.status_code == 200
- safety_state = settings_response.json()["data"]["safety_state"]
- _assert_safety_state_contract(safety_state)
- assert safety_state["cooldown_active"] is True
- assert safety_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
- assert int(safety_state["cooldown_remaining_seconds"]) > 0
- assert int(safety_state["counters"]["cooldown_entry_count"]) >= 1
- assert int(safety_state["counters"]["last_blocked_failure_count"]) >= 1
-
- blocked_start_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "safety-cooldown-run-2"},
- )
- assert blocked_start_response.status_code == 429
- blocked_payload = blocked_start_response.json()
- assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
- blocked_state = blocked_payload["error"]["details"]["safety_state"]
- _assert_safety_state_contract(blocked_state)
- assert blocked_state["cooldown_active"] is True
- assert blocked_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
- assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
-
- runs_response = client.get("/api/v1/runs")
- assert runs_response.status_code == 200
- _assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
- assert runs_response.json()["data"]["safety_state"]["cooldown_active"] is True
- finally:
- object.__setattr__(settings, "ingestion_alert_blocked_failure_threshold", previous_blocked_threshold)
- object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", previous_blocked_cooldown_seconds)
- app.dependency_overrides.pop(get_scholar_source, None)
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_manual_run_enforces_network_failure_safety_cooldown(
- db_session: AsyncSession,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-runs-safety-network@example.com",
- password="api-password",
- )
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "NNN111NNN111",
- "display_name": "Network Safety Probe",
- },
- )
- await db_session.commit()
-
- class NetworkFailureScholarSource:
- async def fetch_profile_page_html(
- self,
- scholar_id: str,
- *,
- cstart: int,
- pagesize: int,
- ) -> FetchResult:
- _ = (scholar_id, cstart, pagesize)
- return FetchResult(
- requested_url="https://scholar.google.com/citations?hl=en&user=NNN111NNN111",
- status_code=None,
- final_url=None,
- body="",
- error="timed out",
- )
-
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- _ = scholar_id
- return await self.fetch_profile_page_html(
- "NNN111NNN111",
- cstart=0,
- pagesize=settings.ingestion_page_size,
- )
-
- app.dependency_overrides[get_scholar_source] = lambda: NetworkFailureScholarSource()
-
- previous_network_threshold = settings.ingestion_alert_network_failure_threshold
- previous_network_cooldown_seconds = settings.ingestion_safety_cooldown_network_seconds
- previous_network_retries = settings.ingestion_network_error_retries
- previous_retry_backoff = settings.ingestion_retry_backoff_seconds
- object.__setattr__(settings, "ingestion_alert_network_failure_threshold", 1)
- object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", 600)
- object.__setattr__(settings, "ingestion_network_error_retries", 0)
- object.__setattr__(settings, "ingestion_retry_backoff_seconds", 0.0)
-
- try:
- client = TestClient(app)
- login_user(client, email="api-runs-safety-network@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- first_run_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-1"},
- )
- assert first_run_response.status_code == 200
-
- settings_response = client.get("/api/v1/settings")
- assert settings_response.status_code == 200
- safety_state = settings_response.json()["data"]["safety_state"]
- _assert_safety_state_contract(safety_state)
- assert safety_state["cooldown_active"] is True
- assert safety_state["cooldown_reason"] == "network_failure_threshold_exceeded"
- assert int(safety_state["cooldown_remaining_seconds"]) > 0
- assert int(safety_state["counters"]["last_network_failure_count"]) >= 1
-
- blocked_start_response = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-2"},
- )
- assert blocked_start_response.status_code == 429
- blocked_payload = blocked_start_response.json()
- assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
- blocked_state = blocked_payload["error"]["details"]["safety_state"]
- _assert_safety_state_contract(blocked_state)
- assert blocked_state["cooldown_active"] is True
- assert blocked_state["cooldown_reason"] == "network_failure_threshold_exceeded"
- assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
- finally:
- object.__setattr__(settings, "ingestion_alert_network_failure_threshold", previous_network_threshold)
- object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", previous_network_cooldown_seconds)
- object.__setattr__(settings, "ingestion_network_error_retries", previous_network_retries)
- object.__setattr__(settings, "ingestion_retry_backoff_seconds", previous_retry_backoff)
- app.dependency_overrides.pop(get_scholar_source, None)
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_settings_clears_expired_scrape_safety_cooldown(
- db_session: AsyncSession,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-settings-safety-expired@example.com",
- password="api-password",
- )
- user_settings = await user_settings_service.get_or_create_settings(
- db_session,
- user_id=user_id,
- )
- user_settings.scrape_safety_state = {"blocked_start_count": 2}
- user_settings.scrape_cooldown_reason = "blocked_failure_threshold_exceeded"
- user_settings.scrape_cooldown_until = datetime.now(timezone.utc) - timedelta(seconds=15)
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-settings-safety-expired@example.com", password="api-password")
-
- settings_response = client.get("/api/v1/settings")
- assert settings_response.status_code == 200
- settings_safety_state = settings_response.json()["data"]["safety_state"]
- _assert_safety_state_contract(settings_safety_state)
- assert settings_safety_state["cooldown_active"] is False
- assert settings_safety_state["cooldown_reason"] is None
- assert int(settings_safety_state["counters"]["blocked_start_count"]) == 2
-
- runs_response = client.get("/api/v1/runs")
- assert runs_response.status_code == 200
- runs_safety_state = runs_response.json()["data"]["safety_state"]
- _assert_safety_state_contract(runs_safety_state)
- assert runs_safety_state["cooldown_active"] is False
- assert runs_safety_state["cooldown_reason"] is None
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publications_list_and_mark_read(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs@example.com",
- password="api-password",
- )
-
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "abcDEF123456",
- "display_name": "Publication Owner",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
-
- publication_a = await db_session.execute(
- text(
- """
- INSERT INTO publications (
- fingerprint_sha256,
- title_raw,
- title_normalized,
- citation_count,
- pdf_url
- )
- VALUES (:fingerprint, :title_raw, :title_normalized, 10, :pdf_url)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{user_id:064x}",
- "title_raw": "Paper A",
- "title_normalized": "paper a",
- "pdf_url": "https://example.org/paper-a.pdf",
- },
- )
- publication_a_id = int(publication_a.scalar_one())
-
- publication_b = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 4)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 1):064x}",
- "title_raw": "Paper B",
- "title_normalized": "paper b",
- },
- )
- publication_b_id = int(publication_b.scalar_one())
-
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
- VALUES
- (:scholar_profile_id, :publication_a_id, false),
- (:scholar_profile_id, :publication_b_id, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_a_id": publication_a_id,
- "publication_b_id": publication_b_id,
- },
- )
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-pubs@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- list_response = client.get("/api/v1/publications?mode=all")
- assert list_response.status_code == 200
- data = list_response.json()["data"]
- assert data["mode"] == "all"
- assert data["favorite_only"] is False
- assert data["total_count"] == 2
- assert data["unread_count"] == 2
- assert data["favorites_count"] == 0
- assert data["latest_count"] == 0
- assert data["new_count"] == data["latest_count"]
- assert data["page"] == 1
- assert data["page_size"] == 100
- assert data["has_prev"] is False
- assert data["has_next"] is False
- assert isinstance(data["publications"], list)
- assert len(data["publications"]) == 2
- assert all(bool(item["is_favorite"]) is False for item in data["publications"])
- pdf_urls = {item["title"]: item["pdf_url"] for item in data["publications"]}
- assert pdf_urls["Paper A"] == "https://example.org/paper-a.pdf"
- assert pdf_urls["Paper B"] is None
-
- latest_response = client.get("/api/v1/publications?mode=latest")
- assert latest_response.status_code == 200
- latest_data = latest_response.json()["data"]
- assert latest_data["mode"] == "latest"
- assert latest_data["latest_count"] == 0
-
- unread_response = client.get("/api/v1/publications?mode=unread")
- assert unread_response.status_code == 200
- unread_data = unread_response.json()["data"]
- assert unread_data["mode"] == "unread"
- assert unread_data["unread_count"] == 2
-
- alias_response = client.get("/api/v1/publications?mode=new")
- assert alias_response.status_code == 200
- alias_data = alias_response.json()["data"]
- assert alias_data["mode"] == "latest"
- assert alias_data["latest_count"] == latest_data["latest_count"]
- assert alias_data["publications"] == latest_data["publications"]
-
- mark_selected_response = client.post(
- "/api/v1/publications/mark-read",
- json={
- "selections": [
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_a_id,
- }
- ]
- },
- headers=headers,
- )
- assert mark_selected_response.status_code == 200
- assert mark_selected_response.json()["data"]["requested_count"] == 1
- assert mark_selected_response.json()["data"]["updated_count"] == 1
-
- read_state = await db_session.execute(
- text(
- """
- SELECT publication_id, is_read
- FROM scholar_publications
- WHERE scholar_profile_id = :scholar_profile_id
- ORDER BY publication_id
- """
- ),
- {"scholar_profile_id": scholar_profile_id},
- )
- states = {int(row[0]): bool(row[1]) for row in read_state.all()}
- assert states[publication_a_id] is True
- assert states[publication_b_id] is False
-
- mark_response = client.post("/api/v1/publications/mark-all-read", headers=headers)
- assert mark_response.status_code == 200
- assert mark_response.json()["data"]["updated_count"] == 1
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publications_list_supports_pagination(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs-paging@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "pagingScholar01",
- "display_name": "Paging Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
-
- publication_ids: list[int] = []
- for index in range(3):
- created = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 1)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 500 + index):064x}",
- "title_raw": f"Paged Paper {index}",
- "title_normalized": f"paged paper {index}",
- },
- )
- publication_ids.append(int(created.scalar_one()))
-
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
- VALUES
- (:scholar_profile_id, :publication_1, false, false),
- (:scholar_profile_id, :publication_2, false, false),
- (:scholar_profile_id, :publication_3, false, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_1": publication_ids[0],
- "publication_2": publication_ids[1],
- "publication_3": publication_ids[2],
- },
- )
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-pubs-paging@example.com", password="api-password")
-
- first_page = client.get("/api/v1/publications?mode=all&page=1&page_size=2")
- assert first_page.status_code == 200
- first_data = first_page.json()["data"]
- assert first_data["total_count"] == 3
- assert first_data["page"] == 1
- assert first_data["page_size"] == 2
- assert first_data["has_prev"] is False
- assert first_data["has_next"] is True
- assert len(first_data["publications"]) == 2
-
- second_page = client.get("/api/v1/publications?mode=all&page=2&page_size=2")
- assert second_page.status_code == 200
- second_data = second_page.json()["data"]
- assert second_data["total_count"] == 3
- assert second_data["page"] == 2
- assert second_data["page_size"] == 2
- assert second_data["has_prev"] is True
- assert second_data["has_next"] is False
- assert len(second_data["publications"]) == 1
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publications_favorite_toggle_and_filter(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs-favorites@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "favoriteScholar01",
- "display_name": "Favorite Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- publication_a_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 9)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 101):064x}",
- "title_raw": "Favorite Target",
- "title_normalized": "favorite target",
- },
- )
- publication_a_id = int(publication_a_result.scalar_one())
- publication_b_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 2)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 102):064x}",
- "title_raw": "Not Favorite",
- "title_normalized": "not favorite",
- },
- )
- publication_b_id = int(publication_b_result.scalar_one())
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
- VALUES
- (:scholar_profile_id, :publication_a_id, false, false),
- (:scholar_profile_id, :publication_b_id, false, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_a_id": publication_a_id,
- "publication_b_id": publication_b_id,
- },
- )
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-pubs-favorites@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- set_response = client.post(
- f"/api/v1/publications/{publication_a_id}/favorite",
- json={"scholar_profile_id": scholar_profile_id, "is_favorite": True},
- headers=headers,
- )
- assert set_response.status_code == 200
- set_data = set_response.json()["data"]
- assert set_data["publication"]["publication_id"] == publication_a_id
- assert set_data["publication"]["is_favorite"] is True
-
- favorite_only_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
- assert favorite_only_response.status_code == 200
- favorite_only_data = favorite_only_response.json()["data"]
- assert favorite_only_data["favorite_only"] is True
- assert favorite_only_data["favorites_count"] == 1
- assert favorite_only_data["total_count"] == 1
- assert len(favorite_only_data["publications"]) == 1
- assert int(favorite_only_data["publications"][0]["publication_id"]) == publication_a_id
-
- clear_response = client.post(
- f"/api/v1/publications/{publication_a_id}/favorite",
- json={"scholar_profile_id": scholar_profile_id, "is_favorite": False},
- headers=headers,
- )
- assert clear_response.status_code == 200
- clear_data = clear_response.json()["data"]
- assert clear_data["publication"]["publication_id"] == publication_a_id
- assert clear_data["publication"]["is_favorite"] is False
-
- favorite_only_after_clear_response = client.get("/api/v1/publications?mode=all&favorite_only=true")
- assert favorite_only_after_clear_response.status_code == 200
- favorite_only_after_clear_data = favorite_only_after_clear_response.json()["data"]
- assert favorite_only_after_clear_data["favorites_count"] == 0
- assert favorite_only_after_clear_data["total_count"] == 0
- assert favorite_only_after_clear_data["publications"] == []
-
- favorite_state_result = await db_session.execute(
- text(
- """
- SELECT is_favorite
- FROM scholar_publications
- WHERE scholar_profile_id = :scholar_profile_id
- AND publication_id = :publication_id
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_a_id,
- },
- )
- assert bool(favorite_state_result.scalar_one()) is False
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publications_list_schedules_background_enrichment(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs-bg@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "background111",
- "display_name": "Background Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- publication_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 3)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 17):064x}",
- "title_raw": "Background Target",
- "title_normalized": "background target",
- },
- )
- publication_id = int(publication_result.scalar_one())
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
- VALUES (:scholar_profile_id, :publication_id, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_id,
- },
- )
- await db_session.commit()
-
- async def _fake_schedule(db_session, *, user_id: int, request_email: str | None, items, max_items: int):
- assert db_session is not None
- assert user_id > 0
- assert request_email == "api-pubs-bg@example.com"
- assert int(max_items) > 0
- assert any(int(item.publication_id) == publication_id for item in items)
- return 1
-
- monkeypatch.setattr(
- "app.services.domains.publications.application.schedule_missing_pdf_enrichment_for_user",
- _fake_schedule,
- )
-
- client = TestClient(app)
- login_user(client, email="api-pubs-bg@example.com", password="api-password")
-
- response = client.get("/api/v1/publications?mode=all")
- assert response.status_code == 200
- data = response.json()["data"]
- assert len(data["publications"]) == 1
- assert int(data["publications"][0]["publication_id"]) == publication_id
- assert data["publications"][0]["pdf_url"] is None
- assert data["publications"][0]["pdf_status"] in {"untracked", "queued", "running", "failed"}
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publication_retry_pdf_queues_resolution_job(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs-retry@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "retryScholar01",
- "display_name": "Retry Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- publication_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 7)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 9):064x}",
- "title_raw": "Retry Target",
- "title_normalized": "retry target",
- },
- )
- publication_id = int(publication_result.scalar_one())
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
- VALUES (:scholar_profile_id, :publication_id, false)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_id,
- },
- )
- await db_session.commit()
-
- async def _fake_retry_scheduler(db_session, *, user_id: int, request_email: str | None, item: PublicationListItem):
- assert db_session is not None
- assert user_id > 0
- assert request_email == "api-pubs-retry@example.com"
- assert int(item.publication_id) == publication_id
- return True
-
- monkeypatch.setattr(
- "app.services.domains.publications.application.schedule_retry_pdf_enrichment_for_row",
- _fake_retry_scheduler,
- )
-
- async def _fake_hydrate(db_session, *, items: list[PublicationListItem]):
- assert db_session is not None
- assert len(items) == 1
- item = items[0]
- return [
- PublicationListItem(
- publication_id=item.publication_id,
- scholar_profile_id=item.scholar_profile_id,
- scholar_label=item.scholar_label,
- title=item.title,
- year=item.year,
- citation_count=item.citation_count,
- venue_text=item.venue_text,
- pub_url=item.pub_url,
- doi=item.doi,
- pdf_url=item.pdf_url,
- is_read=item.is_read,
- first_seen_at=item.first_seen_at,
- is_new_in_latest_run=item.is_new_in_latest_run,
- pdf_status="queued",
- pdf_attempt_count=1,
- pdf_failure_reason="no_pdf_found",
- pdf_failure_detail="no_pdf_found",
- )
- ]
-
- monkeypatch.setattr(
- "app.services.domains.publications.application.hydrate_pdf_enrichment_state",
- _fake_hydrate,
- )
-
- client = TestClient(app)
- login_user(client, email="api-pubs-retry@example.com", password="api-password")
- headers = _api_csrf_headers(client)
-
- response = client.post(
- f"/api/v1/publications/{publication_id}/retry-pdf",
- json={"scholar_profile_id": scholar_profile_id},
- headers=headers,
- )
- assert response.status_code == 200
- payload = response.json()["data"]
- assert payload["queued"] is True
- assert payload["resolved_pdf"] is False
- assert payload["publication"]["publication_id"] == publication_id
- assert payload["publication"]["pdf_url"] is None
- assert payload["publication"]["pdf_status"] == "queued"
- assert payload["publication"]["pdf_attempt_count"] == 1
- assert payload["publication"]["pdf_failure_reason"] == "no_pdf_found"
-
- stored = await db_session.execute(
- text("SELECT doi, pdf_url FROM publications WHERE id = :publication_id"),
- {"publication_id": publication_id},
- )
- stored_doi, stored_pdf_url = stored.one()
- assert stored_doi is None
- assert stored_pdf_url is None
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_publications_unread_and_latest_modes_can_diverge(
- db_session: AsyncSession,
-) -> None:
- user_id = await insert_user(
- db_session,
- email="api-pubs-mismatch@example.com",
- password="api-password",
- )
- scholar_result = await db_session.execute(
- text(
- """
- INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
- VALUES (:user_id, :scholar_id, :display_name, true)
- RETURNING id
- """
- ),
- {
- "user_id": user_id,
- "scholar_id": "mismatchScholar01",
- "display_name": "Mismatch Scholar",
- },
- )
- scholar_profile_id = int(scholar_result.scalar_one())
-
- older_run_result = await db_session.execute(
- text(
- """
- INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
- VALUES (:user_id, 'manual', 'success', 1, 1)
- RETURNING id
- """
- ),
- {"user_id": user_id},
- )
- older_run_id = int(older_run_result.scalar_one())
-
- latest_run_result = await db_session.execute(
- text(
- """
- INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
- VALUES (:user_id, 'scheduled', 'success', 1, 0)
- RETURNING id
- """
- ),
- {"user_id": user_id},
- )
- latest_run_id = int(latest_run_result.scalar_one())
- assert latest_run_id > older_run_id
-
- publication_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 12)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 99):064x}",
- "title_raw": "Unread But Not Latest",
- "title_normalized": "unread but not latest",
- },
- )
- publication_id = int(publication_result.scalar_one())
-
- await db_session.execute(
- text(
- """
- INSERT INTO scholar_publications (
- scholar_profile_id,
- publication_id,
- is_read,
- first_seen_run_id
- )
- VALUES (:scholar_profile_id, :publication_id, false, :first_seen_run_id)
- """
- ),
- {
- "scholar_profile_id": scholar_profile_id,
- "publication_id": publication_id,
- "first_seen_run_id": older_run_id,
- },
- )
- await db_session.commit()
-
- client = TestClient(app)
- login_user(client, email="api-pubs-mismatch@example.com", password="api-password")
-
- unread_response = client.get("/api/v1/publications?mode=unread")
- assert unread_response.status_code == 200
- unread_data = unread_response.json()["data"]
- assert unread_data["mode"] == "unread"
- assert unread_data["unread_count"] == 1
- assert unread_data["latest_count"] == 0
- assert unread_data["new_count"] == 0
- assert len(unread_data["publications"]) == 1
- assert int(unread_data["publications"][0]["publication_id"]) == publication_id
- assert unread_data["publications"][0]["is_new_in_latest_run"] is False
-
- latest_response = client.get("/api/v1/publications?mode=latest")
- assert latest_response.status_code == 200
- latest_data = latest_response.json()["data"]
- assert latest_data["mode"] == "latest"
- assert latest_data["latest_count"] == 0
- assert len(latest_data["publications"]) == 0
-
- alias_response = client.get("/api/v1/publications?mode=new")
- assert alias_response.status_code == 200
- alias_data = alias_response.json()["data"]
- assert alias_data["mode"] == "latest"
- assert alias_data["latest_count"] == latest_data["latest_count"]
- assert alias_data["publications"] == latest_data["publications"]
diff --git a/tests/integration/test_data_repair_jobs.py b/tests/integration/test_data_repair_jobs.py
index f291866..1e65489 100644
--- a/tests/integration/test_data_repair_jobs.py
+++ b/tests/integration/test_data_repair_jobs.py
@@ -4,7 +4,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
-from app.services.domains.dbops import run_publication_link_repair
+from app.services.dbops import run_publication_link_repair
from tests.integration.helpers import insert_user
@@ -160,10 +160,7 @@ async def _assert_apply_effects(
)
baseline_completed = await _count_rows(
db_session,
- sql=(
- "SELECT count(*) FROM scholar_profiles "
- "WHERE id = :scholar_profile_id AND baseline_completed = false"
- ),
+ sql=("SELECT count(*) FROM scholar_profiles WHERE id = :scholar_profile_id AND baseline_completed = false"),
params={"scholar_profile_id": scholar_profile_id},
)
publication_count = await _count_rows(
diff --git a/tests/integration/test_db_integrity.py b/tests/integration/test_db_integrity.py
index b6dfb62..d510344 100644
--- a/tests/integration/test_db_integrity.py
+++ b/tests/integration/test_db_integrity.py
@@ -4,7 +4,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
-from app.services.domains.dbops.integrity import collect_integrity_report
+from app.services.dbops.integrity import collect_integrity_report
def _check_counts(report: dict) -> dict[str, int]:
diff --git a/tests/integration/test_deferred_enrichment.py b/tests/integration/test_deferred_enrichment.py
new file mode 100644
index 0000000..5ad5700
--- /dev/null
+++ b/tests/integration/test_deferred_enrichment.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import CrawlRun, Publication, RunStatus, RunTriggerType, ScholarProfile, ScholarPublication
+from app.services.ingestion.enrichment import EnrichmentRunner
+from app.services.openalex.types import OpenAlexWork
+from tests.integration.helpers import insert_user
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession) -> None:
+ # 1. Setup: Create user and scholar
+ user_id = await insert_user(db_session, email="test@example.com", password="password123")
+
+ scholar = ScholarProfile(
+ user_id=user_id,
+ scholar_id="SaiiI5MAAAAJ",
+ display_name="Test Scholar",
+ is_enabled=True,
+ )
+ db_session.add(scholar)
+ await db_session.flush()
+
+ # 2. Simulate a previous FAILED run that left an un-enriched publication
+ failed_run = CrawlRun(
+ user_id=user_id,
+ status=RunStatus.FAILED,
+ trigger_type=RunTriggerType.MANUAL,
+ start_dt=datetime.now(UTC),
+ )
+ db_session.add(failed_run)
+ await db_session.flush()
+
+ pub = Publication(
+ title_raw="A fast quantum mechanical algorithm for database search",
+ title_normalized="a fast quantum mechanical algorithm for database search",
+ fingerprint_sha256="dummy_fingerprint_for_test",
+ author_text="LK Grover",
+ openalex_enriched=False,
+ )
+ db_session.add(pub)
+ await db_session.flush()
+
+ link = ScholarPublication(
+ scholar_profile_id=scholar.id,
+ publication_id=pub.id,
+ first_seen_run_id=failed_run.id,
+ )
+ db_session.add(link)
+ await db_session.commit()
+
+ # 3. Create a NEW run
+ new_run = CrawlRun(
+ user_id=user_id,
+ status=RunStatus.RUNNING,
+ trigger_type=RunTriggerType.MANUAL,
+ start_dt=datetime.now(UTC),
+ )
+ db_session.add(new_run)
+ await db_session.commit()
+
+ # 4. Mock OpenAlex client to return enrichment data
+ mock_work = OpenAlexWork(
+ openalex_id="W1234567",
+ doi="10.1145/237814.237866",
+ pmid=None,
+ pmcid=None,
+ title="A fast quantum mechanical algorithm for database search",
+ publication_year=1996,
+ cited_by_count=1000,
+ is_oa=True,
+ oa_url="http://example.com/grover.pdf",
+ )
+
+ runner = EnrichmentRunner()
+
+ # We patch the client at its source, and also mock arXiv to avoid real HTTP calls
+ with (
+ patch("app.services.openalex.client.OpenAlexClient") as MockClient,
+ patch("app.services.arxiv.application.discover_arxiv_id_for_publication", new=AsyncMock(return_value=None)),
+ ):
+ mock_instance = MockClient.return_value
+ mock_instance.get_works_by_filter = AsyncMock(return_value=[mock_work])
+
+ # 5. Execute the enrichment pass for the NEW run
+ await runner.enrich_pending_publications(db_session, run_id=new_run.id)
+ await db_session.commit()
+
+ # 6. Verification: The publication from the FAILED run should now be enriched
+ await db_session.refresh(pub)
+ assert pub.openalex_enriched is True
+ assert pub.citation_count == 1000
+ assert pub.pdf_url == "http://example.com/grover.pdf"
+
+ # Double check it was indeed processed
+ stmt = select(Publication).where(Publication.id == pub.id)
+ result = await db_session.execute(stmt)
+ enriched_pub = result.scalar_one()
+ assert enriched_pub.openalex_enriched is True
diff --git a/tests/integration/test_fixture_probe_runs.py b/tests/integration/test_fixture_probe_runs.py
index eb3762a..feeb602 100644
--- a/tests/integration/test_fixture_probe_runs.py
+++ b/tests/integration/test_fixture_probe_runs.py
@@ -8,8 +8,8 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import RunStatus, RunTriggerType
-from app.services.domains.ingestion.application import ScholarIngestionService
-from app.services.domains.scholar.source import FetchResult
+from app.services.ingestion.application import ScholarIngestionService
+from app.services.scholar.source import FetchResult
from tests.integration.helpers import insert_user
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
@@ -34,8 +34,7 @@ def _blocked_fetch(*, scholar_id: str, body: str) -> FetchResult:
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
status_code=200,
final_url=(
- "https://accounts.google.com/v3/signin/identifier"
- "?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
+ "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
),
body=body,
error=None,
@@ -96,7 +95,7 @@ async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
"retry": "RSTUVWX12345",
}
- for label in ("ok", "blocked", "retry"):
+ for label in ("ok", "retry", "blocked"):
await db_session.execute(
text(
"""
@@ -144,7 +143,9 @@ async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
request_delay_seconds=0,
network_error_retries=1,
retry_backoff_seconds=0.0,
- max_pages_per_scholar=1,
+ rate_limit_retries=0,
+ rate_limit_backoff_seconds=0.0,
+ max_pages_per_scholar=10,
page_size=100,
auto_queue_continuations=False,
alert_blocked_failure_threshold=1,
diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py
index 7b4295a..0bb3440 100644
--- a/tests/integration/test_migrations.py
+++ b/tests/integration/test_migrations.py
@@ -13,13 +13,15 @@ EXPECTED_TABLES = {
"ingestion_queue_items",
"author_search_runtime_state",
"author_search_cache_entries",
+ "arxiv_runtime_state",
+ "arxiv_query_cache_entries",
"data_repair_jobs",
"publication_pdf_jobs",
"publication_pdf_job_events",
}
EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
-EXPECTED_REVISION = "20260221_0015"
+EXPECTED_REVISION = "20260226_0024"
@pytest.mark.integration
@@ -27,9 +29,7 @@ EXPECTED_REVISION = "20260221_0015"
@pytest.mark.migrations
@pytest.mark.asyncio
async def test_migration_creates_expected_tables(db_session: AsyncSession) -> None:
- result = await db_session.execute(
- text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
- )
+ result = await db_session.execute(text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'"))
table_names = {row[0] for row in result}
assert EXPECTED_TABLES.issubset(table_names)
@@ -137,6 +137,28 @@ async def test_crawl_runs_has_manual_idempotency_unique_index(db_session: AsyncS
assert "WHERE" in indexdef
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.migrations
+@pytest.mark.asyncio
+async def test_crawl_runs_has_single_active_run_unique_index(db_session: AsyncSession) -> None:
+ result = await db_session.execute(
+ text(
+ """
+ SELECT indexdef
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND tablename = 'crawl_runs'
+ AND indexname = 'uq_crawl_runs_user_active'
+ """
+ )
+ )
+ indexdef = result.scalar_one()
+ assert "UNIQUE INDEX" in indexdef
+ assert "(user_id)" in indexdef
+ assert "WHERE" in indexdef
+
+
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.migrations
diff --git a/tests/integration/test_run_lifecycle_consistency.py b/tests/integration/test_run_lifecycle_consistency.py
new file mode 100644
index 0000000..6009cf3
--- /dev/null
+++ b/tests/integration/test_run_lifecycle_consistency.py
@@ -0,0 +1,536 @@
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from app.api.runtime_deps import get_ingestion_service
+from app.db.models import CrawlRun, Publication, RunStatus, RunTriggerType
+from app.main import app
+from app.services.ingestion.application import ScholarIngestionService
+from app.services.ingestion.run_enrichment import background_enrich
+from app.services.scholar.source import FetchResult
+from tests.integration.helpers import insert_user, login_user
+
+
+class _PassthroughSource:
+ """Minimal source that passes preflight without making real requests."""
+
+ async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
+ return FetchResult(
+ requested_url=f"https://scholar.google.com/citations?user={scholar_id}",
+ status_code=200,
+ final_url=f"https://scholar.google.com/citations?user={scholar_id}",
+ body="",
+ error=None,
+ )
+
+ async def fetch_profile_page_html(
+ self, scholar_id: str, *, cstart: int, pagesize: int
+ ) -> FetchResult:
+ return await self.fetch_profile_html(scholar_id)
+
+
+def _csrf_headers(client: TestClient) -> dict[str, str]:
+ response = client.get("/api/v1/auth/me")
+ assert response.status_code == 200
+ csrf_token = response.json()["data"]["csrf_token"]
+ assert isinstance(csrf_token, str) and csrf_token
+ return {"X-CSRF-Token": csrf_token}
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_crawl_runs_enforce_single_active_run_per_user(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-single-active@example.com",
+ password="api-password",
+ )
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
+ VALUES (:user_id, 'manual', 'running', 1, 0)
+ """
+ ),
+ {"user_id": user_id},
+ )
+ await db_session.commit()
+
+ with pytest.raises(IntegrityError):
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
+ VALUES (:user_id, 'scheduled', 'resolving', 1, 0)
+ """
+ ),
+ {"user_id": user_id},
+ )
+ await db_session.commit()
+ await db_session.rollback()
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_manual_run_conflicts_when_an_active_run_exists(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-run-conflict@example.com",
+ password="api-password",
+ )
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, :scholar_id, :display_name, true)
+ """
+ ),
+ {"user_id": user_id, "scholar_id": "runConflict001", "display_name": "Run Conflict"},
+ )
+ await db_session.commit()
+
+ service = ScholarIngestionService(source=_PassthroughSource())
+
+ async def _stall_execute_run(**_kwargs: Any) -> None:
+ await asyncio.sleep(0.4)
+
+ monkeypatch.setattr(service, "execute_run", _stall_execute_run)
+ app.dependency_overrides[get_ingestion_service] = lambda: service
+ try:
+ client = TestClient(app)
+ login_user(client, email="api-run-conflict@example.com", password="api-password")
+ headers = _csrf_headers(client)
+
+ first_response = client.post("/api/v1/runs/manual", headers=headers)
+ assert first_response.status_code == 200
+ assert first_response.json()["data"]["status"] == "running"
+
+ second_response = client.post("/api/v1/runs/manual", headers=headers)
+ assert second_response.status_code == 409
+ payload = second_response.json()
+ assert payload["error"]["code"] == "run_in_progress"
+ await asyncio.sleep(0.45)
+ finally:
+ app.dependency_overrides.pop(get_ingestion_service, None)
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_api_cancel_allows_resolving_and_rejects_terminal_run(db_session: AsyncSession) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-cancel-resolving@example.com",
+ password="api-password",
+ )
+ run_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO crawl_runs (user_id, trigger_type, status, scholar_count, new_pub_count)
+ VALUES (:user_id, 'manual', 'resolving', 1, 2)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ run_id = int(run_result.scalar_one())
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-cancel-resolving@example.com", password="api-password")
+ headers = _csrf_headers(client)
+
+ cancel_response = client.post(f"/api/v1/runs/{run_id}/cancel", headers=headers)
+ assert cancel_response.status_code == 200
+ assert cancel_response.json()["data"]["run"]["status"] == "canceled"
+
+ cancel_again_response = client.post(f"/api/v1/runs/{run_id}/cancel", headers=headers)
+ assert cancel_again_response.status_code == 409
+ assert cancel_again_response.json()["error"]["code"] == "run_not_cancelable"
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_background_enrichment_preserves_canceled_status(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-cancel-enrichment@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, 'cancelResolve001', 'Cancel Resolve', true)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ run = CrawlRun(
+ user_id=user_id,
+ trigger_type=RunTriggerType.MANUAL,
+ status=RunStatus.RESOLVING,
+ scholar_count=1,
+ )
+ publication = Publication(
+ fingerprint_sha256=f"{(user_id + 9000):064x}",
+ title_raw="Cancel During Resolving",
+ title_normalized="cancel during resolving",
+ citation_count=0,
+ openalex_enriched=False,
+ )
+ db_session.add_all([run, publication])
+ await db_session.flush()
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (
+ scholar_profile_id,
+ publication_id,
+ first_seen_run_id,
+ is_read
+ )
+ VALUES (:scholar_profile_id, :publication_id, :run_id, false)
+ """
+ ),
+ {
+ "scholar_profile_id": scholar_profile_id,
+ "publication_id": int(publication.id),
+ "run_id": int(run.id),
+ },
+ )
+ await db_session.commit()
+
+ session_factory = async_sessionmaker(db_session.bind, expire_on_commit=False)
+
+ class _OpenAlexClientStub:
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ _ = (args, kwargs)
+
+ async def get_works_by_filter(self, *_args: Any, **_kwargs: Any) -> list[Any]:
+ async with session_factory() as cancel_session:
+ run_to_cancel = await cancel_session.get(CrawlRun, int(run.id))
+ assert run_to_cancel is not None
+ run_to_cancel.status = RunStatus.CANCELED
+ await cancel_session.commit()
+ return []
+
+ monkeypatch.setattr(
+ "app.services.openalex.client.OpenAlexClient",
+ _OpenAlexClientStub,
+ )
+
+ from app.services.ingestion.enrichment import EnrichmentRunner
+
+ enrichment_runner = EnrichmentRunner()
+ await background_enrich(
+ session_factory,
+ enrichment_runner,
+ run_id=int(run.id),
+ intended_final_status=RunStatus.SUCCESS,
+ )
+
+ await db_session.refresh(run)
+ await db_session.refresh(publication)
+ assert run.status == RunStatus.CANCELED
+ assert publication.openalex_enriched is False
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_publications_list_endpoint_does_not_raise_name_error(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-publications-nameerror@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, 'pubNameError001', 'Pub NameError', true)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+ publication_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 2222):064x}",
+ "title_raw": "NameError Regression",
+ "title_normalized": "nameerror regression",
+ },
+ )
+ publication_id = int(publication_result.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES (:scholar_profile_id, :publication_id, false, false)
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-publications-nameerror@example.com", password="api-password")
+ response = client.get("/api/v1/publications?mode=all&limit=10&offset=0")
+ assert response.status_code == 200
+ assert len(response.json()["data"]["publications"]) == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_partial_discovery_exception_keeps_new_pub_count_consistent(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-pub-count-consistency@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, 'countConsistency001', 'Count Consistency', true)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ run = CrawlRun(
+ user_id=user_id,
+ trigger_type=RunTriggerType.MANUAL,
+ status=RunStatus.RUNNING,
+ scholar_count=1,
+ new_pub_count=0,
+ )
+ publication = Publication(
+ fingerprint_sha256=f"{(user_id + 777):064x}",
+ title_raw="Persisted Discovery",
+ title_normalized="persisted discovery",
+ citation_count=0,
+ )
+ db_session.add_all([run, publication])
+ await db_session.commit()
+
+ call_count = 0
+
+ async def _resolve_publication_stub(*_args: Any, **_kwargs: Any) -> Publication:
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return publication
+ raise RuntimeError("mid_page_failure")
+
+ from app.services.ingestion import publication_upsert
+
+ monkeypatch.setattr(publication_upsert, "resolve_publication", _resolve_publication_stub)
+
+ from app.db.models import ScholarProfile
+ from app.services.scholar.parser_types import PublicationCandidate
+
+ scholar = await db_session.get(ScholarProfile, scholar_profile_id)
+ assert scholar is not None
+
+ publications = [
+ PublicationCandidate(
+ title="Persisted Discovery",
+ title_url=None,
+ cluster_id=None,
+ year=None,
+ citation_count=0,
+ authors_text=None,
+ venue_text=None,
+ pdf_url=None,
+ ),
+ PublicationCandidate(
+ title="Will Fail",
+ title_url=None,
+ cluster_id=None,
+ year=None,
+ citation_count=0,
+ authors_text=None,
+ venue_text=None,
+ pdf_url=None,
+ ),
+ ]
+
+ with pytest.raises(RuntimeError, match="mid_page_failure"):
+ await publication_upsert.upsert_profile_publications(
+ db_session,
+ run=run,
+ scholar=scholar,
+ publications=publications,
+ )
+
+ refreshed_run = await db_session.get(CrawlRun, int(run.id))
+ assert refreshed_run is not None
+ assert int(refreshed_run.new_pub_count) == 1
+ link_count_result = await db_session.execute(
+ text(
+ """
+ SELECT count(*)
+ FROM scholar_publications
+ WHERE scholar_profile_id = :scholar_profile_id
+ AND first_seen_run_id = :run_id
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "run_id": int(run.id)},
+ )
+ assert int(link_count_result.scalar_one()) == 1
+
+
+@pytest.mark.integration
+@pytest.mark.db
+@pytest.mark.asyncio
+async def test_publications_pagination_snapshot_stays_stable_across_inserts(
+ db_session: AsyncSession,
+) -> None:
+ user_id = await insert_user(
+ db_session,
+ email="api-publications-snapshot@example.com",
+ password="api-password",
+ )
+ scholar_result = await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
+ VALUES (:user_id, 'pagingSnapshot001', 'Paging Snapshot', true)
+ RETURNING id
+ """
+ ),
+ {"user_id": user_id},
+ )
+ scholar_profile_id = int(scholar_result.scalar_one())
+
+ publication_ids: list[int] = []
+ for index in range(4):
+ created = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 1000 + index):064x}",
+ "title_raw": f"Stable Page {index}",
+ "title_normalized": f"stable page {index}",
+ },
+ )
+ publication_ids.append(int(created.scalar_one()))
+
+ for publication_id in publication_ids:
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES (:scholar_profile_id, :publication_id, false, false)
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
+ )
+ await db_session.commit()
+
+ client = TestClient(app)
+ login_user(client, email="api-publications-snapshot@example.com", password="api-password")
+
+ first_page_response = client.get("/api/v1/publications?mode=all&page=1&page_size=2")
+ assert first_page_response.status_code == 200
+ first_page = first_page_response.json()["data"]
+ first_page_ids = [int(item["publication_id"]) for item in first_page["publications"]]
+ snapshot = first_page["snapshot"]
+ assert isinstance(snapshot, str) and snapshot
+
+ inserted = await db_session.execute(
+ text(
+ """
+ INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
+ VALUES (:fingerprint, :title_raw, :title_normalized, 1)
+ RETURNING id
+ """
+ ),
+ {
+ "fingerprint": f"{(user_id + 5000):064x}",
+ "title_raw": "Inserted After Snapshot",
+ "title_normalized": "inserted after snapshot",
+ },
+ )
+ inserted_publication_id = int(inserted.scalar_one())
+ await db_session.execute(
+ text(
+ """
+ INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read, is_favorite)
+ VALUES (:scholar_profile_id, :publication_id, false, false)
+ """
+ ),
+ {"scholar_profile_id": scholar_profile_id, "publication_id": inserted_publication_id},
+ )
+ await db_session.commit()
+
+ second_page_response = client.get(
+ "/api/v1/publications",
+ params={
+ "mode": "all",
+ "page": 2,
+ "page_size": 2,
+ "snapshot": snapshot,
+ },
+ )
+ assert second_page_response.status_code == 200
+ second_page = second_page_response.json()["data"]
+ second_page_ids = [int(item["publication_id"]) for item in second_page["publications"]]
+
+ first_page_again_response = client.get(
+ "/api/v1/publications",
+ params={
+ "mode": "all",
+ "page": 1,
+ "page_size": 2,
+ "snapshot": snapshot,
+ },
+ )
+ assert first_page_again_response.status_code == 200
+ first_page_again = first_page_again_response.json()["data"]
+ first_page_again_ids = [int(item["publication_id"]) for item in first_page_again["publications"]]
+
+ assert first_page_again_ids == first_page_ids
+ assert not (set(first_page_ids) & set(second_page_ids))
+ assert inserted_publication_id not in set(first_page_ids + second_page_ids)
diff --git a/tests/smoke/test_db_connectivity.py b/tests/smoke/test_db_connectivity.py
index 848a034..ba9ce76 100644
--- a/tests/smoke/test_db_connectivity.py
+++ b/tests/smoke/test_db_connectivity.py
@@ -20,4 +20,3 @@ async def test_database_connectivity_from_database_url() -> None:
assert result.scalar_one() == 1
finally:
await engine.dispose()
-
diff --git a/tests/smoke/test_migration_schema.py b/tests/smoke/test_migration_schema.py
index c12be25..d107388 100644
--- a/tests/smoke/test_migration_schema.py
+++ b/tests/smoke/test_migration_schema.py
@@ -1,6 +1,6 @@
+import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
-import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
diff --git a/tests/unit/services/domains/arxiv/test_cache.py b/tests/unit/services/domains/arxiv/test_cache.py
new file mode 100644
index 0000000..0ef753d
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_cache.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import asyncio
+import gc
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ArxivQueryCacheEntry
+from app.services.arxiv.cache import (
+ build_query_fingerprint,
+ get_cached_feed,
+ run_with_inflight_dedupe,
+ set_cached_feed,
+)
+from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta
+
+
+def _sample_feed(arxiv_id: str = "1234.5678") -> ArxivFeed:
+ return ArxivFeed(
+ entries=[
+ ArxivEntry(
+ entry_id_url=f"https://arxiv.org/abs/{arxiv_id}",
+ arxiv_id=arxiv_id,
+ title="Sample",
+ summary="Summary",
+ published=None,
+ updated=None,
+ )
+ ],
+ opensearch=ArxivOpenSearchMeta(total_results=1, start_index=0, items_per_page=1),
+ )
+
+
+def test_build_query_fingerprint_normalizes_search_and_id_params() -> None:
+ first = build_query_fingerprint(
+ params={
+ "search_query": ' TI:"Quantum Fields" AND AU:"Doe" ',
+ "start": 0,
+ "max_results": 3,
+ }
+ )
+ second = build_query_fingerprint(
+ params={
+ "search_query": 'ti:"quantum fields" and au:"doe"',
+ "start": 0,
+ "max_results": 3,
+ }
+ )
+ third = build_query_fingerprint(params={"id_list": " 2222.0002,1111.0001 "})
+ fourth = build_query_fingerprint(params={"id_list": "1111.0001, 2222.0002"})
+
+ assert first == second
+ assert third == fourth
+
+
+@pytest.mark.asyncio
+async def test_cache_entry_expires_and_is_deleted(db_session: AsyncSession) -> None:
+ query_fingerprint = build_query_fingerprint(params={"search_query": "ti:test", "start": 0})
+ now_utc = datetime(2026, 2, 26, 12, 0, tzinfo=UTC)
+
+ await set_cached_feed(
+ query_fingerprint=query_fingerprint,
+ feed=_sample_feed(),
+ ttl_seconds=5.0,
+ max_entries=16,
+ now_utc=now_utc,
+ )
+
+ hit = await get_cached_feed(
+ query_fingerprint=query_fingerprint,
+ now_utc=now_utc + timedelta(seconds=2),
+ )
+ miss = await get_cached_feed(
+ query_fingerprint=query_fingerprint,
+ now_utc=now_utc + timedelta(seconds=8),
+ )
+
+ result = await db_session.execute(
+ select(ArxivQueryCacheEntry).where(ArxivQueryCacheEntry.query_fingerprint == query_fingerprint)
+ )
+ assert hit is not None
+ assert miss is None
+ assert result.scalar_one_or_none() is None
+
+
+@pytest.mark.asyncio
+async def test_inflight_dedupe_coalesces_identical_requests() -> None:
+ calls = {"count": 0}
+
+ async def _fetch_feed() -> ArxivFeed:
+ calls["count"] += 1
+ await asyncio.sleep(0.05)
+ return _sample_feed("9999.0001")
+
+ first, second = await asyncio.gather(
+ run_with_inflight_dedupe(query_fingerprint="same-key", fetch_feed=_fetch_feed),
+ run_with_inflight_dedupe(query_fingerprint="same-key", fetch_feed=_fetch_feed),
+ )
+
+ assert calls["count"] == 1
+ assert first.entries[0].arxiv_id == "9999.0001"
+ assert second.entries[0].arxiv_id == "9999.0001"
+
+
+@pytest.mark.asyncio
+async def test_inflight_owner_failure_without_joiner_has_no_unretrieved_exception() -> None:
+ loop = asyncio.get_running_loop()
+ messages: list[str] = []
+ previous_handler = loop.get_exception_handler()
+
+ def _capture_exception(_loop, context) -> None:
+ messages.append(str(context.get("message", "")))
+
+ loop.set_exception_handler(_capture_exception)
+ try:
+
+ async def _failing_fetch() -> ArxivFeed:
+ raise RuntimeError("owner_failed")
+
+ with pytest.raises(RuntimeError, match="owner_failed"):
+ await run_with_inflight_dedupe(
+ query_fingerprint="owner-failure-no-joiner",
+ fetch_feed=_failing_fetch,
+ )
+ gc.collect()
+ await asyncio.sleep(0)
+ finally:
+ loop.set_exception_handler(previous_handler)
+
+ assert "Future exception was never retrieved" not in " | ".join(messages)
diff --git a/tests/unit/services/domains/arxiv/test_client.py b/tests/unit/services/domains/arxiv/test_client.py
new file mode 100644
index 0000000..1b8e6cc
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_client.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime
+
+import httpx
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.services.arxiv import client as arxiv_client_module
+from app.services.arxiv.client import ArxivClient
+from app.services.arxiv.errors import ArxivClientValidationError, ArxivRateLimitError
+from app.services.arxiv.rate_limit import ArxivCooldownStatus
+
+_CLIENT_FEED_XML = """
+
+ 1
+ 0
+ 1
+
+ http://arxiv.org/abs/9999.0001
+ Client Entry
+ Client summary
+
+
+"""
+
+
+@pytest.mark.asyncio
+async def test_client_search_builds_query_and_sort_params() -> None:
+ captured: dict[str, object] = {}
+
+ async def _request_fn(*, params, request_email, timeout_seconds):
+ captured["params"] = params
+ captured["request_email"] = request_email
+ captured["timeout_seconds"] = timeout_seconds
+ return httpx.Response(
+ 200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query")
+ )
+
+ client = ArxivClient(request_fn=_request_fn)
+ feed = await client.search(
+ query='ti:"test"',
+ start=5,
+ max_results=7,
+ sort_by="submittedDate",
+ sort_order="ascending",
+ request_email="user@example.com",
+ timeout_seconds=3.5,
+ )
+
+ assert feed.opensearch.total_results == 1
+ assert captured["params"] == {
+ "search_query": 'ti:"test"',
+ "start": 5,
+ "max_results": 7,
+ "sortBy": "submittedDate",
+ "sortOrder": "ascending",
+ }
+ assert captured["request_email"] == "user@example.com"
+ assert captured["timeout_seconds"] == 3.5
+
+
+@pytest.mark.asyncio
+async def test_client_lookup_ids_builds_id_list_param() -> None:
+ captured: dict[str, object] = {}
+
+ async def _request_fn(*, params, request_email, timeout_seconds):
+ captured["params"] = params
+ return httpx.Response(
+ 200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query")
+ )
+
+ client = ArxivClient(request_fn=_request_fn)
+ await client.lookup_ids(id_list=["1234.5678", " 9999.0001 "], start=0, max_results=2)
+ assert captured["params"] == {"id_list": "1234.5678,9999.0001", "start": 0, "max_results": 2}
+
+
+@pytest.mark.asyncio
+async def test_client_search_rejects_invalid_sort_by() -> None:
+ async def _unused_request_fn(*, params, request_email, timeout_seconds):
+ return httpx.Response(
+ 200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query")
+ )
+
+ client = ArxivClient(request_fn=_unused_request_fn)
+ with pytest.raises(ArxivClientValidationError):
+ await client.search(query="x", sort_by="bad")
+
+
+@pytest.mark.asyncio
+async def test_client_lookup_ids_rejects_empty_list() -> None:
+ async def _unused_request_fn(*, params, request_email, timeout_seconds):
+ return httpx.Response(
+ 200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query")
+ )
+
+ client = ArxivClient(request_fn=_unused_request_fn)
+ with pytest.raises(ArxivClientValidationError):
+ await client.lookup_ids(id_list=[])
+
+
+@pytest.mark.asyncio
+async def test_client_search_rejects_negative_start() -> None:
+ async def _unused_request_fn(*, params, request_email, timeout_seconds):
+ return httpx.Response(
+ 200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query")
+ )
+
+ client = ArxivClient(request_fn=_unused_request_fn)
+ with pytest.raises(ArxivClientValidationError):
+ await client.search(query="ti:test", start=-1)
+
+
+@pytest.mark.asyncio
+async def test_client_propagates_http_status_error() -> None:
+ async def _request_fn(*, params, request_email, timeout_seconds):
+ request = httpx.Request("GET", "https://export.arxiv.org/api/query")
+ return httpx.Response(500, text="error", request=request)
+
+ client = ArxivClient(request_fn=_request_fn)
+ with pytest.raises(httpx.HTTPStatusError):
+ await client.search(query="ti:test")
+
+
+@pytest.mark.asyncio
+async def test_client_coalesces_concurrent_identical_search_requests() -> None:
+ calls = {"count": 0}
+
+ async def _request_fn(*, params, request_email, timeout_seconds):
+ calls["count"] += 1
+ await asyncio.sleep(0.05)
+ request = httpx.Request("GET", "https://export.arxiv.org/api/query")
+ return httpx.Response(200, text=_CLIENT_FEED_XML, request=request)
+
+ client = ArxivClient(request_fn=_request_fn)
+ await asyncio.gather(
+ client.search(query="ti:test"),
+ client.search(query="ti:test"),
+ )
+ assert calls["count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_client_logs_cache_hit_and_miss(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _ = db_session
+ calls = {"count": 0}
+ logged: list[dict[str, object]] = []
+
+ async def _request_fn(*, params, request_email, timeout_seconds):
+ calls["count"] += 1
+ request = httpx.Request("GET", "https://export.arxiv.org/api/query")
+ return httpx.Response(200, text=_CLIENT_FEED_XML, request=request)
+
+ def _capture_log(_msg: str, *args, **kwargs) -> None:
+ logged.append({"event": _msg, **(kwargs.get("extra") or {})})
+
+ monkeypatch.setattr("app.services.arxiv.client.logger.info", _capture_log)
+ client = ArxivClient(request_fn=_request_fn, cache_enabled=True)
+ await client.search(query="ti:test-cache")
+ await client.search(query="ti:test-cache")
+
+ events = [str(entry.get("event", "")) for entry in logged]
+ assert "arxiv.cache_miss" in events
+ assert "arxiv.cache_hit" in events
+ assert calls["count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_request_feed_skips_live_call_when_global_cooldown_is_active(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ logged: list[dict[str, object]] = []
+ called = {"count": 0}
+
+ async def _cooldown_status(*, now_utc=None):
+ _ = now_utc
+ return ArxivCooldownStatus(
+ is_active=True,
+ remaining_seconds=61.0,
+ cooldown_until=datetime(2026, 2, 26, 12, 1, tzinfo=UTC),
+ )
+
+ async def _unexpected_limit_call(*, fetch, source_path): # pragma: no cover - defensive
+ _ = (fetch, source_path)
+ called["count"] += 1
+ return httpx.Response(200, text=_CLIENT_FEED_XML)
+
+ def _capture_warning(_msg: str, *args, **kwargs) -> None:
+ logged.append({"event": _msg, **(kwargs.get("extra") or {})})
+
+ monkeypatch.setattr(arxiv_client_module, "get_arxiv_cooldown_status", _cooldown_status)
+ monkeypatch.setattr(arxiv_client_module, "run_with_global_arxiv_limit", _unexpected_limit_call)
+ monkeypatch.setattr("app.services.arxiv.client.logger.warning", _capture_warning)
+
+ with pytest.raises(ArxivRateLimitError):
+ await arxiv_client_module._request_arxiv_feed(
+ params={"search_query": 'ti:"test"'},
+ request_email="user@example.com",
+ timeout_seconds=2.0,
+ )
+
+ assert called["count"] == 0
+ assert [entry.get("event") for entry in logged] == ["arxiv.request_skipped_cooldown"]
diff --git a/tests/unit/services/domains/arxiv/test_gateway.py b/tests/unit/services/domains/arxiv/test_gateway.py
new file mode 100644
index 0000000..5d73e6d
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_gateway.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app.services.arxiv import application as arxiv_application
+from app.services.arxiv import gateway as arxiv_gateway
+from app.services.arxiv.types import ArxivEntry, ArxivFeed, ArxivOpenSearchMeta
+from app.settings import settings
+
+
+def _item(*, title: str = "A Test Paper", scholar_label: str = "Ada Lovelace") -> SimpleNamespace:
+ return SimpleNamespace(title=title, scholar_label=scholar_label)
+
+
+def test_get_arxiv_gateway_returns_cached_instance() -> None:
+ previous = arxiv_gateway.set_arxiv_gateway(None)
+ try:
+ first = arxiv_gateway.get_arxiv_gateway()
+ second = arxiv_gateway.get_arxiv_gateway()
+ assert first is second
+ finally:
+ arxiv_gateway.set_arxiv_gateway(previous)
+
+
+@pytest.mark.asyncio
+async def test_application_discover_uses_gateway_override() -> None:
+ class FakeGateway:
+ def __init__(self) -> None:
+ self.calls: list[tuple[object, str | None, float | None]] = []
+
+ async def discover_arxiv_id_for_publication(
+ self,
+ *,
+ item,
+ request_email: str | None = None,
+ timeout_seconds: float | None = None,
+ max_results: int | None = None,
+ ) -> str | None:
+ self.calls.append((item, request_email, timeout_seconds))
+ return "1234.5678"
+
+ fake_gateway = FakeGateway()
+ previous = arxiv_gateway.set_arxiv_gateway(fake_gateway)
+ try:
+ result = await arxiv_application.discover_arxiv_id_for_publication(
+ item=_item(),
+ request_email="user@example.com",
+ timeout_seconds=7.0,
+ )
+ assert result == "1234.5678"
+ assert fake_gateway.calls
+ assert fake_gateway.calls[0][1] == "user@example.com"
+ assert fake_gateway.calls[0][2] == 7.0
+ finally:
+ arxiv_gateway.set_arxiv_gateway(previous)
+
+
+@pytest.mark.asyncio
+async def test_http_gateway_returns_none_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
+ class FakeClient:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ async def search(self, **kwargs):
+ self.calls += 1
+ return ArxivFeed()
+
+ previous_enabled = bool(settings.arxiv_enabled)
+ fake_client = FakeClient()
+ object.__setattr__(settings, "arxiv_enabled", False)
+ try:
+ gateway = arxiv_gateway.HttpArxivGateway(client=fake_client)
+ result = await gateway.discover_arxiv_id_for_publication(item=_item())
+ assert result is None
+ assert fake_client.calls == 0
+ finally:
+ object.__setattr__(settings, "arxiv_enabled", previous_enabled)
+
+
+@pytest.mark.asyncio
+async def test_http_gateway_uses_client_search_for_discovery() -> None:
+ class FakeClient:
+ def __init__(self) -> None:
+ self.calls: list[dict] = []
+
+ async def search(self, **kwargs):
+ self.calls.append(kwargs)
+ return ArxivFeed(
+ entries=[
+ ArxivEntry(
+ entry_id_url="https://arxiv.org/abs/1234.5678v1",
+ arxiv_id="1234.5678v1",
+ title="Paper",
+ summary="",
+ published=None,
+ updated=None,
+ )
+ ],
+ opensearch=ArxivOpenSearchMeta(total_results=1, start_index=0, items_per_page=1),
+ )
+
+ fake_client = FakeClient()
+ gateway = arxiv_gateway.HttpArxivGateway(client=fake_client)
+ result = await gateway.discover_arxiv_id_for_publication(
+ item=_item(title="My Paper", scholar_label="Ada Lovelace"),
+ request_email="user@example.com",
+ timeout_seconds=2.0,
+ max_results=4,
+ )
+
+ assert result == "1234.5678v1"
+ assert fake_client.calls
+ first_call = fake_client.calls[0]
+ assert first_call["query"] == 'ti:"My Paper" AND au:"lovelace"'
+ assert first_call["request_email"] == "user@example.com"
+ assert first_call["timeout_seconds"] == 2.0
+ assert first_call["max_results"] == 4
+
+
+def test_build_arxiv_query_normalizes_noisy_mojibake_title() -> None:
+ noisy = " Graph–Neural Networks Survey "
+ clean = "Graph Neural Networks Survey"
+
+ assert arxiv_gateway.build_arxiv_query(noisy, None) == arxiv_gateway.build_arxiv_query(clean, None)
diff --git a/tests/unit/services/domains/arxiv/test_guards.py b/tests/unit/services/domains/arxiv/test_guards.py
new file mode 100644
index 0000000..a869433
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_guards.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from app.services.arxiv.guards import arxiv_skip_reason_for_item
+from app.services.publication_identifiers.types import DisplayIdentifier
+from app.services.publications.types import PublicationListItem
+
+
+def _item(
+ *,
+ title: str,
+ pub_url: str | None = None,
+ pdf_url: str | None = None,
+ display_identifier: DisplayIdentifier | None = None,
+) -> PublicationListItem:
+ return PublicationListItem(
+ publication_id=1,
+ scholar_profile_id=1,
+ scholar_label="Ada Lovelace",
+ title=title,
+ year=2024,
+ citation_count=0,
+ venue_text=None,
+ pub_url=pub_url,
+ pdf_url=pdf_url,
+ is_read=False,
+ first_seen_at=datetime.now(UTC),
+ is_new_in_latest_run=True,
+ display_identifier=display_identifier,
+ )
+
+
+def test_arxiv_skip_reason_for_strong_doi_evidence() -> None:
+ item = _item(
+ title="A Robust and Reproducible Deep Learning Benchmark",
+ display_identifier=DisplayIdentifier(
+ kind="doi",
+ value="10.1000/example",
+ label="DOI: 10.1000/example",
+ url="https://doi.org/10.1000/example",
+ confidence_score=1.0,
+ ),
+ )
+ assert arxiv_skip_reason_for_item(item=item) == "strong_doi_present"
+
+
+def test_arxiv_skip_reason_for_existing_arxiv_link() -> None:
+ item = _item(
+ title="A Robust and Reproducible Deep Learning Benchmark",
+ pub_url="https://arxiv.org/abs/2501.00001",
+ )
+ assert arxiv_skip_reason_for_item(item=item) == "arxiv_identifier_present"
+
+
+def test_arxiv_skip_reason_for_low_quality_title() -> None:
+ item = _item(title="AI 2024")
+ assert arxiv_skip_reason_for_item(item=item) == "title_quality_below_threshold"
+
+
+def test_arxiv_skip_reason_none_for_eligible_item() -> None:
+ item = _item(title="Reliable Graph Neural Network Benchmarking Across Multiple Datasets")
+ assert arxiv_skip_reason_for_item(item=item) is None
diff --git a/tests/unit/services/domains/arxiv/test_parser.py b/tests/unit/services/domains/arxiv/test_parser.py
new file mode 100644
index 0000000..6ab0418
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_parser.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import pytest
+
+from app.services.arxiv.errors import ArxivParseError
+from app.services.arxiv.parser import parse_arxiv_feed
+
+_VALID_FEED_XML = """
+
+ 2
+ 0
+ 2
+
+ http://arxiv.org/abs/1234.5678v2
+ 2020-01-01T00:00:00Z
+ 2019-12-31T00:00:00Z
+ Test Entry
+ Example summary
+ Ada Lovelace
+ Grace Hopper
+
+
+
+
+
+
+"""
+
+
+def test_parse_arxiv_feed_extracts_entries_and_opensearch_meta() -> None:
+ feed = parse_arxiv_feed(_VALID_FEED_XML)
+ assert feed.opensearch.total_results == 2
+ assert feed.opensearch.start_index == 0
+ assert feed.opensearch.items_per_page == 2
+ assert len(feed.entries) == 1
+ entry = feed.entries[0]
+ assert entry.arxiv_id == "1234.5678v2"
+ assert entry.title == "Test Entry"
+ assert entry.summary == "Example summary"
+ assert entry.authors == ["Ada Lovelace", "Grace Hopper"]
+ assert entry.primary_category == "cs.AI"
+ assert entry.categories == ["cs.AI", "stat.ML"]
+
+
+def test_parse_arxiv_feed_raises_on_invalid_xml() -> None:
+ with pytest.raises(ArxivParseError):
+ parse_arxiv_feed(" ")
+
+
+def test_parse_arxiv_feed_raises_on_invalid_opensearch_integer() -> None:
+ payload = _VALID_FEED_XML.replace(
+ "2 ", "x "
+ )
+ with pytest.raises(ArxivParseError):
+ parse_arxiv_feed(payload)
diff --git a/tests/unit/services/domains/arxiv/test_rate_limit.py b/tests/unit/services/domains/arxiv/test_rate_limit.py
new file mode 100644
index 0000000..73183ff
--- /dev/null
+++ b/tests/unit/services/domains/arxiv/test_rate_limit.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime, timedelta
+
+import httpx
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ArxivRuntimeState
+from app.services.arxiv.constants import ARXIV_RUNTIME_STATE_KEY
+from app.services.arxiv.errors import ArxivRateLimitError
+from app.services.arxiv.rate_limit import get_arxiv_cooldown_status, run_with_global_arxiv_limit
+from app.settings import settings
+
+
+@pytest.mark.asyncio
+async def test_arxiv_rate_limit_respects_cooldown(db_session: AsyncSession) -> None:
+ db_session.add(
+ ArxivRuntimeState(
+ state_key=ARXIV_RUNTIME_STATE_KEY,
+ cooldown_until=datetime.now(UTC) + timedelta(seconds=30),
+ )
+ )
+ await db_session.commit()
+
+ called = {"count": 0}
+
+ async def _fetch() -> httpx.Response:
+ called["count"] += 1
+ return httpx.Response(200, text="ok")
+
+ with pytest.raises(ArxivRateLimitError):
+ await run_with_global_arxiv_limit(fetch=_fetch)
+ assert called["count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_arxiv_rate_limit_persists_cooldown_after_429(db_session: AsyncSession) -> None:
+ previous_interval = settings.arxiv_min_interval_seconds
+ previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds
+ object.__setattr__(settings, "arxiv_min_interval_seconds", 0.0)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", 5.0)
+ try:
+
+ async def _fetch() -> httpx.Response:
+ return httpx.Response(429, text="rate limited")
+
+ with pytest.raises(ArxivRateLimitError):
+ await run_with_global_arxiv_limit(fetch=_fetch)
+ finally:
+ object.__setattr__(settings, "arxiv_min_interval_seconds", previous_interval)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", previous_cooldown)
+
+ result = await db_session.execute(
+ select(ArxivRuntimeState).where(ArxivRuntimeState.state_key == ARXIV_RUNTIME_STATE_KEY)
+ )
+ state = result.scalar_one()
+ assert state.cooldown_until is not None
+ assert state.cooldown_until > datetime.now(UTC)
+
+
+@pytest.mark.asyncio
+async def test_arxiv_rate_limit_serializes_concurrent_calls(db_session: AsyncSession) -> None:
+ previous_interval = settings.arxiv_min_interval_seconds
+ previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds
+ object.__setattr__(settings, "arxiv_min_interval_seconds", 0.2)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", 5.0)
+ try:
+ call_times: list[float] = []
+
+ async def _fetch() -> httpx.Response:
+ call_times.append(asyncio.get_running_loop().time())
+ return httpx.Response(200, text="ok")
+
+ await asyncio.gather(
+ run_with_global_arxiv_limit(fetch=_fetch),
+ run_with_global_arxiv_limit(fetch=_fetch),
+ )
+ finally:
+ object.__setattr__(settings, "arxiv_min_interval_seconds", previous_interval)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", previous_cooldown)
+
+ assert len(call_times) == 2
+ assert call_times[1] - call_times[0] >= 0.18
+
+
+@pytest.mark.asyncio
+async def test_arxiv_rate_limit_logs_request_scheduled_and_completed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ logged: list[dict[str, object]] = []
+
+ async def _fetch() -> httpx.Response:
+ return httpx.Response(200, text="ok")
+
+ def _capture_log(_msg: str, *args, **kwargs) -> None:
+ logged.append({"event": _msg, **(kwargs.get("extra") or {})})
+
+ monkeypatch.setattr("app.services.arxiv.rate_limit.logger.info", _capture_log)
+ await run_with_global_arxiv_limit(fetch=_fetch, source_path="search")
+
+ scheduled = [entry for entry in logged if entry.get("event") == "arxiv.request_scheduled"]
+ completed = [entry for entry in logged if entry.get("event") == "arxiv.request_completed"]
+
+ assert scheduled
+ assert completed
+ assert float(scheduled[0]["wait_seconds"]) >= 0.0
+ assert int(completed[0]["status_code"]) == 200
+ assert completed[0]["source_path"] == "search"
+
+
+@pytest.mark.asyncio
+async def test_arxiv_rate_limit_logs_cooldown_activation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ logged_warning: list[dict[str, object]] = []
+ previous_interval = settings.arxiv_min_interval_seconds
+ previous_cooldown = settings.arxiv_rate_limit_cooldown_seconds
+ object.__setattr__(settings, "arxiv_min_interval_seconds", 0.0)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", 5.0)
+ try:
+
+ async def _fetch() -> httpx.Response:
+ return httpx.Response(429, text="rate limited")
+
+ def _capture_warning(_msg: str, *args, **kwargs) -> None:
+ logged_warning.append({"event": _msg, **(kwargs.get("extra") or {})})
+
+ monkeypatch.setattr("app.services.arxiv.rate_limit.logger.warning", _capture_warning)
+ with pytest.raises(ArxivRateLimitError):
+ await run_with_global_arxiv_limit(fetch=_fetch, source_path="lookup_ids")
+ finally:
+ object.__setattr__(settings, "arxiv_min_interval_seconds", previous_interval)
+ object.__setattr__(settings, "arxiv_rate_limit_cooldown_seconds", previous_cooldown)
+
+ cooldown_events = [entry for entry in logged_warning if entry.get("event") == "arxiv.cooldown_activated"]
+ assert cooldown_events
+ assert cooldown_events[0]["source_path"] == "lookup_ids"
+ assert float(cooldown_events[0]["cooldown_remaining_seconds"]) > 0.0
+
+
+@pytest.mark.asyncio
+async def test_get_arxiv_cooldown_status_reads_active_cooldown(db_session: AsyncSession) -> None:
+ now_utc = datetime(2026, 2, 26, 13, 0, tzinfo=UTC)
+ existing = await db_session.get(ArxivRuntimeState, ARXIV_RUNTIME_STATE_KEY)
+ if existing is None:
+ db_session.add(
+ ArxivRuntimeState(
+ state_key=ARXIV_RUNTIME_STATE_KEY,
+ cooldown_until=now_utc + timedelta(seconds=45),
+ )
+ )
+ else:
+ existing.cooldown_until = now_utc + timedelta(seconds=45)
+ await db_session.commit()
+
+ status = await get_arxiv_cooldown_status(now_utc=now_utc)
+
+ assert status.is_active is True
+ assert status.cooldown_until is not None
+ assert int(status.remaining_seconds) == 45
diff --git a/tests/unit/services/domains/openalex/test_client.py b/tests/unit/services/domains/openalex/test_client.py
new file mode 100644
index 0000000..3d8827d
--- /dev/null
+++ b/tests/unit/services/domains/openalex/test_client.py
@@ -0,0 +1,56 @@
+from app.services.openalex.types import OpenAlexWork
+
+
+def test_parse_openalex_work_from_api_dict() -> None:
+ raw_api_response = {
+ "id": "https://openalex.org/W2741809807",
+ "doi": "https://doi.org/10.1038/s41586-020-0315-z",
+ "title": "Machine learning and the physical sciences",
+ "publication_year": 2019,
+ "cited_by_count": 1420,
+ "ids": {
+ "openalex": "https://openalex.org/W2741809807",
+ "doi": "https://doi.org/10.1038/s41586-020-0315-z",
+ "mag": "2741809807",
+ "pmid": "https://pubmed.ncbi.nlm.nih.gov/32040050",
+ "pmcid": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7325852",
+ },
+ "open_access": {"is_oa": True, "oa_url": "https://example.com/pdf"},
+ "authorships": [
+ {
+ "author_position": "first",
+ "author": {"id": "https://openalex.org/A1969205032", "display_name": "Giuseppe Carleo"},
+ },
+ {
+ "author_position": "middle",
+ "author": {"id": "https://openalex.org/A4356881717", "display_name": "Ignacio Cirac"},
+ },
+ ],
+ }
+
+ work = OpenAlexWork.from_api_dict(raw_api_response)
+
+ assert work.openalex_id == "https://openalex.org/W2741809807"
+ assert work.title == "Machine learning and the physical sciences"
+ assert work.doi == "10.1038/s41586-020-0315-z"
+ assert work.pmid == "32040050"
+ assert work.pmcid == "PMC7325852"
+ assert work.publication_year == 2019
+ assert work.cited_by_count == 1420
+ assert work.is_oa is True
+ assert work.oa_url == "https://example.com/pdf"
+ assert len(work.authors) == 2
+ assert work.authors[0].display_name == "Giuseppe Carleo"
+ assert work.authors[1].display_name == "Ignacio Cirac"
+
+
+def test_parse_openalex_work_empty() -> None:
+ work = OpenAlexWork.from_api_dict({"id": "W123"})
+ assert work.openalex_id == "W123"
+ assert work.doi is None
+ assert work.pmid is None
+ assert work.title is None
+ assert work.publication_year is None
+ assert work.cited_by_count == 0
+ assert work.is_oa is False
+ assert len(work.authors) == 0
diff --git a/tests/unit/services/domains/openalex/test_matching.py b/tests/unit/services/domains/openalex/test_matching.py
new file mode 100644
index 0000000..f2da0fd
--- /dev/null
+++ b/tests/unit/services/domains/openalex/test_matching.py
@@ -0,0 +1,63 @@
+from app.services.openalex.matching import find_best_match
+from app.services.openalex.types import OpenAlexWork
+
+
+def test_find_best_match_exact_title():
+ cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Exact Title of the Paper"})
+ cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "Totally Different Paper"})
+
+ match = find_best_match("Exact Title of the Paper", 2020, "Author A", [cand1, cand2])
+ assert match is not None
+ assert match.openalex_id == "W1"
+
+
+def test_find_best_match_fuzzy_title():
+ # Only differences are punctuation or minor phrasing (e.g., matching a preprint title vs published)
+ cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Fuzzier Title: A Study on OpenAlex"})
+ cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "Some completely unrelated work"})
+
+ match = find_best_match("Fuzzier Title A Study on OpenAlex", 2021, "Author B", [cand1, cand2])
+ assert match is not None
+ assert match.openalex_id == "W1"
+
+
+def test_find_best_match_rejects_low_score():
+ cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "Cats in hats"})
+
+ match = find_best_match("Dogs with logs", 2020, "Author A", [cand1])
+ assert match is None
+
+
+def test_find_best_match_year_tiebreaker():
+ # Both titles are very similar, one has exact year.
+ cand1 = OpenAlexWork.from_api_dict({"id": "W1", "title": "The exact same title", "publication_year": 2018})
+ cand2 = OpenAlexWork.from_api_dict({"id": "W2", "title": "The exact same title", "publication_year": 2020})
+
+ match = find_best_match("The exact same title", 2020, "Author A", [cand1, cand2])
+ assert match is not None
+ assert match.openalex_id == "W2"
+
+
+def test_find_best_match_author_tiebreaker():
+ # Titles and years match exactly. Author overlap decides it.
+ cand1 = OpenAlexWork.from_api_dict(
+ {
+ "id": "W1",
+ "title": "A popular title",
+ "publication_year": 2020,
+ "authorships": [{"author": {"display_name": "Smith, J"}}],
+ }
+ )
+ cand2 = OpenAlexWork.from_api_dict(
+ {
+ "id": "W2",
+ "title": "A popular title",
+ "publication_year": 2020,
+ "authorships": [{"author": {"display_name": "Doe, J"}}],
+ }
+ )
+
+ # Target authors contains "Doe"
+ match = find_best_match("A popular title", 2020, "A Einstein, J Doe", [cand1, cand2])
+ assert match is not None
+ assert match.openalex_id == "W2"
diff --git a/tests/unit/services/domains/publications/test_dedup.py b/tests/unit/services/domains/publications/test_dedup.py
new file mode 100644
index 0000000..0820ecb
--- /dev/null
+++ b/tests/unit/services/domains/publications/test_dedup.py
@@ -0,0 +1,286 @@
+"""Unit tests for publication dedup operations."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from app.db.models import ScholarPublication
+from app.services.publications import dedup as dedup_service
+from app.services.publications.dedup import (
+ NearDuplicateCluster,
+ NearDuplicateMember,
+ find_identifier_duplicate_pairs,
+ find_near_duplicate_clusters,
+ merge_duplicate_publication,
+ merge_near_duplicate_cluster,
+ sweep_identifier_duplicates,
+)
+
+
+def _make_result(rows: list) -> MagicMock:
+ mock_result = MagicMock()
+ mock_result.scalars.return_value.all.return_value = rows
+ mock_result.__iter__ = lambda self: iter(rows)
+ mock_result.all.return_value = rows
+ return mock_result
+
+
+def _session_with_execute_sequence(results: list) -> AsyncMock:
+ session = AsyncMock()
+ session.execute = AsyncMock(side_effect=[_make_result(r) for r in results])
+ session.delete = AsyncMock()
+ session.flush = AsyncMock()
+ return session
+
+
+@pytest.mark.asyncio
+async def test_find_identifier_duplicate_pairs_returns_pairs() -> None:
+ session = AsyncMock()
+ session.execute = AsyncMock(return_value=_make_result([(1, 2)]))
+
+ pairs = await find_identifier_duplicate_pairs(session)
+
+ assert pairs == [(1, 2)]
+ session.execute.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_find_identifier_duplicate_pairs_returns_empty_when_no_duplicates() -> None:
+ session = AsyncMock()
+ session.execute = AsyncMock(return_value=_make_result([]))
+
+ pairs = await find_identifier_duplicate_pairs(session)
+
+ assert pairs == []
+
+
+@pytest.mark.asyncio
+async def test_merge_duplicate_publication_migrates_links_and_identifiers() -> None:
+ session = AsyncMock()
+ winner = SimpleNamespace(
+ year=2014,
+ citation_count=10,
+ author_text=None,
+ venue_text=None,
+ pub_url=None,
+ pdf_url=None,
+ cluster_id=None,
+ canonical_title_hash=None,
+ title_raw="winner",
+ title_normalized="winner",
+ )
+ dup = SimpleNamespace(
+ year=2015,
+ citation_count=11,
+ author_text="a",
+ venue_text="v",
+ pub_url="https://example.org",
+ pdf_url="https://example.org/a.pdf",
+ cluster_id="x",
+ canonical_title_hash="hash",
+ title_raw="dup",
+ title_normalized="dup",
+ )
+
+ with (
+ patch(
+ "app.services.publications.dedup._load_publication",
+ new=AsyncMock(side_effect=[winner, dup]),
+ ),
+ patch(
+ "app.services.publications.dedup._migrate_scholar_links",
+ new=AsyncMock(),
+ ) as mock_links,
+ patch(
+ "app.services.publications.dedup._migrate_identifiers",
+ new=AsyncMock(),
+ ) as mock_identifiers,
+ ):
+ await merge_duplicate_publication(session, winner_id=1, dup_id=2)
+
+ assert session.execute.await_count == 1
+ mock_links.assert_awaited_once_with(session, winner_id=1, dup_id=2)
+ mock_identifiers.assert_awaited_once_with(session, winner_id=1, dup_id=2)
+
+
+@pytest.mark.asyncio
+async def test_merge_duplicate_publication_rejects_missing_publications() -> None:
+ session = AsyncMock()
+
+ with (
+ patch(
+ "app.services.publications.dedup._load_publication",
+ new=AsyncMock(side_effect=[None, None]),
+ ),
+ pytest.raises(ValueError),
+ ):
+ await merge_duplicate_publication(session, winner_id=1, dup_id=2)
+
+
+@pytest.mark.asyncio
+async def test_migrate_scholar_links_moves_orphans() -> None:
+ dup_link = MagicMock(spec=ScholarPublication)
+ dup_link.scholar_profile_id = 99
+ dup_link.publication_id = 2
+
+ session = _session_with_execute_sequence(
+ results=[
+ [dup_link],
+ [],
+ ]
+ )
+
+ await dedup_service._migrate_scholar_links(session, winner_id=1, dup_id=2)
+
+ assert dup_link.publication_id == 1
+ session.delete.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_migrate_scholar_links_drops_conflicts() -> None:
+ dup_link = MagicMock(spec=ScholarPublication)
+ dup_link.scholar_profile_id = 88
+ dup_link.publication_id = 2
+
+ session = _session_with_execute_sequence(
+ results=[
+ [dup_link],
+ [(88,)],
+ ]
+ )
+
+ await dedup_service._migrate_scholar_links(session, winner_id=1, dup_id=2)
+
+ session.delete.assert_awaited_once_with(dup_link)
+
+
+@pytest.mark.asyncio
+async def test_sweep_returns_zero_when_no_pairs() -> None:
+ with patch(
+ "app.services.publications.dedup.find_identifier_duplicate_pairs",
+ new=AsyncMock(return_value=[]),
+ ):
+ session = AsyncMock()
+ count = await sweep_identifier_duplicates(session)
+
+ assert count == 0
+ session.flush.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_sweep_returns_merge_count() -> None:
+ with (
+ patch(
+ "app.services.publications.dedup.find_identifier_duplicate_pairs",
+ new=AsyncMock(return_value=[(1, 2), (3, 4)]),
+ ),
+ patch(
+ "app.services.publications.dedup.merge_duplicate_publication",
+ new=AsyncMock(),
+ ) as mock_merge,
+ ):
+ session = AsyncMock()
+ count = await sweep_identifier_duplicates(session)
+
+ assert count == 2
+ assert mock_merge.await_count == 2
+ session.flush.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_sweep_merges_each_dup_only_once() -> None:
+ with (
+ patch(
+ "app.services.publications.dedup.find_identifier_duplicate_pairs",
+ new=AsyncMock(return_value=[(1, 2), (1, 2)]),
+ ),
+ patch(
+ "app.services.publications.dedup.merge_duplicate_publication",
+ new=AsyncMock(),
+ ) as mock_merge,
+ ):
+ session = AsyncMock()
+ count = await sweep_identifier_duplicates(session)
+
+ assert count == 1
+ assert mock_merge.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_find_near_duplicate_clusters_groups_similar_titles() -> None:
+ first = dedup_service._candidate_from_row(
+ publication_id=10,
+ title="Adam: A method for stochastic optimization",
+ year=2014,
+ citation_count=100,
+ )
+ second = dedup_service._candidate_from_row(
+ publication_id=11,
+ title="†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent.",
+ year=2015,
+ citation_count=50,
+ )
+ assert first is not None
+ assert second is not None
+
+ with patch(
+ "app.services.publications.dedup._load_near_duplicate_candidates",
+ new=AsyncMock(return_value=[first, second]),
+ ):
+ clusters = await find_near_duplicate_clusters(AsyncMock())
+
+ assert len(clusters) == 1
+ assert len(clusters[0].members) == 2
+ assert clusters[0].winner_publication_id == 10
+
+
+@pytest.mark.asyncio
+async def test_find_near_duplicate_clusters_skips_unrelated_titles() -> None:
+ first = dedup_service._candidate_from_row(
+ publication_id=21,
+ title="Adam optimizer",
+ year=2014,
+ citation_count=10,
+ )
+ second = dedup_service._candidate_from_row(
+ publication_id=22,
+ title="Diffusion models in vision",
+ year=2022,
+ citation_count=10,
+ )
+ assert first is not None
+ assert second is not None
+
+ with patch(
+ "app.services.publications.dedup._load_near_duplicate_candidates",
+ new=AsyncMock(return_value=[first, second]),
+ ):
+ clusters = await find_near_duplicate_clusters(AsyncMock())
+
+ assert clusters == []
+
+
+@pytest.mark.asyncio
+async def test_merge_near_duplicate_cluster_merges_non_winner_members() -> None:
+ cluster = NearDuplicateCluster(
+ cluster_key="abc",
+ winner_publication_id=5,
+ similarity_score=1.0,
+ members=(
+ NearDuplicateMember(publication_id=5, title="Winner", year=2014, citation_count=10),
+ NearDuplicateMember(publication_id=6, title="Dup", year=2014, citation_count=3),
+ NearDuplicateMember(publication_id=7, title="Dup2", year=2014, citation_count=1),
+ ),
+ )
+
+ with patch(
+ "app.services.publications.dedup.merge_duplicate_publication",
+ new=AsyncMock(),
+ ) as mock_merge:
+ merged = await merge_near_duplicate_cluster(AsyncMock(), cluster=cluster)
+
+ assert merged == 2
+ assert mock_merge.await_count == 2
diff --git a/tests/unit/test_crossref_lookup.py b/tests/unit/test_crossref_lookup.py
index 5d71281..8b883b5 100644
--- a/tests/unit/test_crossref_lookup.py
+++ b/tests/unit/test_crossref_lookup.py
@@ -4,7 +4,7 @@ from types import SimpleNamespace
import pytest
-from app.services.domains.crossref import application as crossref_app
+from app.services.crossref import application as crossref_app
def _item(*, title: str, year: int | None, scholar_label: str = "Shinya Yamanaka"):
@@ -52,7 +52,9 @@ async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest.
@pytest.mark.asyncio
-async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
+async def test_crossref_relaxed_fallback_allows_large_year_mismatch_for_strong_title_match(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
async def _fake_fetch_items(**_kwargs):
return [
{
@@ -72,4 +74,30 @@ async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPa
max_rows=10,
email=None,
)
- assert doi is None
+ assert doi == "10.1000/wrong-year"
+
+
+@pytest.mark.asyncio
+async def test_crossref_relaxed_fallback_allows_author_mismatch_for_strong_title_match(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_fetch_items(**_kwargs):
+ return [
+ {
+ "DOI": "10.1000/author-fallback",
+ "title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
+ "issued": {"date-parts": [[2007]]},
+ "author": [{"family": "SomeoneElse"}],
+ }
+ ]
+
+ monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
+ doi = await crossref_app.discover_doi_for_publication(
+ item=_item(
+ title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
+ year=2007,
+ ),
+ max_rows=10,
+ email=None,
+ )
+ assert doi == "10.1000/author-fallback"
diff --git a/tests/unit/test_doi_normalize.py b/tests/unit/test_doi_normalize.py
index 84b0ad0..0b14294 100644
--- a/tests/unit/test_doi_normalize.py
+++ b/tests/unit/test_doi_normalize.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from app.services.domains.doi.normalize import first_doi_from_texts, normalize_doi
+from app.services.doi.normalize import first_doi_from_texts, normalize_doi
def test_normalize_doi_extracts_and_lowercases() -> None:
diff --git a/tests/unit/test_fingerprints.py b/tests/unit/test_fingerprints.py
new file mode 100644
index 0000000..87c12ba
--- /dev/null
+++ b/tests/unit/test_fingerprints.py
@@ -0,0 +1,279 @@
+from __future__ import annotations
+
+from app.services.ingestion.fingerprints import (
+ _dedupe_publication_candidates,
+ canonical_title_for_dedup,
+ fuzzy_titles_match,
+ normalize_title,
+)
+from app.services.scholar.parser import PublicationCandidate
+
+
+def _candidate(
+ title: str,
+ *,
+ cluster_id: str | None = None,
+ year: int | None = 2024,
+ authors_text: str | None = "Smith, J",
+ venue_text: str | None = "ICML",
+) -> PublicationCandidate:
+ return PublicationCandidate(
+ title=title,
+ title_url=None,
+ cluster_id=cluster_id,
+ year=year,
+ citation_count=None,
+ authors_text=authors_text,
+ venue_text=venue_text,
+ pdf_url=None,
+ )
+
+
+class TestFuzzyTitlesMatch:
+ def test_identical_titles(self) -> None:
+ assert fuzzy_titles_match("Deep Learning for NLP", "deep learning for nlp") is True
+
+ def test_minor_word_difference(self) -> None:
+ assert (
+ fuzzy_titles_match(
+ "A Survey on Deep Learning Methods for NLP",
+ "Survey on Deep Learning Methods for NLP",
+ )
+ is True
+ )
+
+ def test_punctuation_difference(self) -> None:
+ assert (
+ fuzzy_titles_match(
+ "Attention Is All You Need",
+ "Attention Is All You Need.",
+ )
+ is True
+ )
+
+ def test_colon_vs_dash_subtitle(self) -> None:
+ assert (
+ fuzzy_titles_match(
+ "Deep Learning: A Comprehensive Survey",
+ "Deep Learning - A Comprehensive Survey",
+ )
+ is True
+ )
+
+ def test_completely_different_titles(self) -> None:
+ assert (
+ fuzzy_titles_match(
+ "Deep Learning for NLP",
+ "Climate Change Impact on Agriculture",
+ )
+ is False
+ )
+
+ def test_short_title_no_false_positive(self) -> None:
+ assert fuzzy_titles_match("On Trees", "On Forests") is False
+
+ def test_empty_title(self) -> None:
+ assert fuzzy_titles_match("", "Deep Learning") is False
+
+ def test_custom_threshold(self) -> None:
+ # Lower threshold catches more distant matches
+ assert (
+ fuzzy_titles_match(
+ "A Survey on Deep Learning",
+ "Survey on Machine Learning Approaches",
+ threshold=0.3,
+ )
+ is True
+ )
+ # Default threshold rejects them
+ assert (
+ fuzzy_titles_match(
+ "A Survey on Deep Learning",
+ "Survey on Machine Learning Approaches",
+ )
+ is False
+ )
+
+
+class TestDedupePublicationCandidates:
+ def test_exact_duplicates_by_cluster_id(self) -> None:
+ pubs = [
+ _candidate("Title A", cluster_id="c1"),
+ _candidate("Title A Copy", cluster_id="c1"),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 1
+ assert result[0].title == "Title A"
+
+ def test_fuzzy_duplicates_without_cluster_id(self) -> None:
+ pubs = [
+ _candidate("Attention Is All You Need"),
+ _candidate("Attention Is All You Need."),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 1
+
+ def test_distinct_titles_preserved(self) -> None:
+ pubs = [
+ _candidate("Deep Learning for NLP"),
+ _candidate("Reinforcement Learning for Robotics"),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 2
+
+ def test_fallback_aligned_with_db_fingerprint(self) -> None:
+ """Same title/year/first_author/first_venue should deduplicate even with
+ different full authors_text or venue_text."""
+ pubs = [
+ _candidate(
+ "My Paper",
+ authors_text="Smith, J; Jones, A",
+ venue_text="International Conference on ML",
+ ),
+ _candidate(
+ "My Paper",
+ authors_text="Smith, J; Baker, B",
+ venue_text="International Conference for ML",
+ ),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ # Both share first_author_last_name="smith" and first_venue_word="international"
+ assert len(result) == 1
+
+ def test_mixed_cluster_and_fuzzy(self) -> None:
+ pubs = [
+ _candidate("A Comprehensive Survey on Deep Learning Methods", cluster_id="c1"),
+ _candidate("Comprehensive Survey on Deep Learning Methods"), # fuzzy match (subtitle stripped)
+ _candidate("Completely Different Study"),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 2
+ titles = [p.title for p in result]
+ assert "A Comprehensive Survey on Deep Learning Methods" in titles
+ assert "Completely Different Study" in titles
+
+ def test_scholar_noise_variants_collapse_to_one(self) -> None:
+ """The motivating case: three Scholar display variants of the Adam paper."""
+ pubs = [
+ _candidate(
+ "Adam: A method for stochastic optimization, preprint (2014)",
+ year=2014,
+ venue_text="",
+ ),
+ _candidate(
+ "Adam: A Method for Stochastic Optimization. arXiv, Jan 29, 2017. doi: 10.48550/arxiv.1412.6980",
+ year=2017,
+ venue_text="arXiv",
+ ),
+ _candidate(
+ "Adam a method for stochastic optimization. Comput. Sci",
+ year=2015,
+ venue_text="Comput. Sci",
+ ),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 1
+ assert result[0].title == pubs[0].title
+
+ def test_distinct_papers_not_merged(self) -> None:
+ """Papers with different core titles must not be collapsed."""
+ pubs = [
+ _candidate("Adam: A Method for Stochastic Optimization"),
+ _candidate("SGD: Stochastic Gradient Descent Revisited"),
+ _candidate("Attention Is All You Need"),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 3
+
+ def test_cross_page_dedup_via_seen_canonical(self) -> None:
+ """seen_canonical threads state across two separate calls (simulating two pages)."""
+ seen: set[str] = set()
+ page1 = [_candidate("Adam: A Method for Stochastic Optimization")]
+ page2 = [
+ _candidate(
+ "Adam: A method for stochastic optimization, preprint (2014)",
+ year=2014,
+ ),
+ _candidate("An Entirely Different Paper"),
+ ]
+
+ result1 = _dedupe_publication_candidates(page1, seen_canonical=seen)
+ result2 = _dedupe_publication_candidates(page2, seen_canonical=seen)
+
+ assert len(result1) == 1
+ # Noisy Adam variant from page 2 is suppressed; distinct paper survives
+ assert len(result2) == 1
+ assert result2[0].title == "An Entirely Different Paper"
+
+ def test_first_seen_wins_in_noise_collapse(self) -> None:
+ """First occurrence in page order is the kept candidate."""
+ pubs = [
+ _candidate("Adam: A Method for Stochastic Optimization", year=2015),
+ _candidate("Adam: A method for stochastic optimization, preprint (2014)", year=2014),
+ ]
+ result = _dedupe_publication_candidates(pubs)
+ assert len(result) == 1
+ assert result[0].year == 2015 # first wins
+
+
+class TestCanonicalTitleForDedup:
+ def test_strips_doi_suffix(self) -> None:
+ title = "Adam: A Method for Stochastic Optimization. doi: 10.48550/arxiv.1412.6980"
+ assert canonical_title_for_dedup(title) == normalize_title("Adam: A Method for Stochastic Optimization")
+
+ def test_strips_arxiv_metadata_suffix(self) -> None:
+ title = "Adam: A Method for Stochastic Optimization. arXiv, Jan 29, 2017"
+ assert canonical_title_for_dedup(title) == normalize_title("Adam: A Method for Stochastic Optimization")
+
+ def test_strips_preprint_parenthetical(self) -> None:
+ title = "Adam: A method for stochastic optimization, preprint (2014)"
+ assert canonical_title_for_dedup(title) == normalize_title("Adam: A method for stochastic optimization")
+
+ def test_strips_venue_sentence_suffix(self) -> None:
+ title = "Adam a method for stochastic optimization. Comput. Sci"
+ assert canonical_title_for_dedup(title) == normalize_title("Adam a method for stochastic optimization")
+
+ def test_strips_trailing_year_in_parens(self) -> None:
+ assert canonical_title_for_dedup("Deep Learning (2018)") == normalize_title("Deep Learning")
+
+ def test_preserves_clean_title(self) -> None:
+ title = "Attention Is All You Need"
+ assert canonical_title_for_dedup(title) == normalize_title(title)
+
+ def test_adam_variants_produce_identical_canonical(self) -> None:
+ variants = [
+ "Adam: A method for stochastic optimization, preprint (2014)",
+ "Adam: A Method for Stochastic Optimization. arXiv, Jan 29, 2017. doi: 10.48550/arxiv.1412.6980",
+ "Adam a method for stochastic optimization. Comput. Sci",
+ ]
+ canonicals = [canonical_title_for_dedup(v) for v in variants]
+ assert len(set(canonicals)) == 1, f"Expected one canonical, got: {canonicals}"
+
+ def test_strips_mojibake_conference_suffix(self) -> None:
+ noisy = "†œAdam: A method for stochastic optimization, †3rd Int. Conf. Learn. Represent. ICLR 2015-Conf"
+ clean = "Adam: A method for stochastic optimization"
+ assert canonical_title_for_dedup(noisy) == normalize_title(clean)
+
+ def test_preserves_clean_subtitle_not_venue_metadata(self) -> None:
+ title = "Vision-Language Models - A Survey"
+ assert canonical_title_for_dedup(title) == normalize_title(title)
+
+ def test_strips_leading_author_fragment_before_core_title(self) -> None:
+ noisy = "and Ba.J.:Adam: a method for stochastic optimization"
+ clean = "Adam: a method for stochastic optimization"
+ assert canonical_title_for_dedup(noisy) == normalize_title(clean)
+
+ def test_strips_leading_date_prefix(self) -> None:
+ noisy = "January 7-9). Adam: A method for stochastic optimization"
+ clean = "Adam: A method for stochastic optimization"
+ assert canonical_title_for_dedup(noisy) == normalize_title(clean)
+
+ def test_strips_trailing_publication_type(self) -> None:
+ noisy = "Adam: A method for stochastic optimization. conference paper"
+ clean = "Adam: A method for stochastic optimization"
+ assert canonical_title_for_dedup(noisy) == normalize_title(clean)
+
+ def test_strips_trailing_month_year_parenthetical(self) -> None:
+ noisy = "Adam: A method for stochastic optimization (Jan 2017)"
+ clean = "Adam: A method for stochastic optimization"
+ assert canonical_title_for_dedup(noisy) == normalize_title(clean)
diff --git a/tests/unit/test_ingestion_arxiv_rate_limit.py b/tests/unit/test_ingestion_arxiv_rate_limit.py
new file mode 100644
index 0000000..5d38779
--- /dev/null
+++ b/tests/unit/test_ingestion_arxiv_rate_limit.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app.services.arxiv.errors import ArxivRateLimitError
+from app.services.ingestion.enrichment import EnrichmentRunner
+from app.services.publication_identifiers import application as identifier_service
+
+
+@pytest.mark.asyncio
+async def test_discover_identifiers_for_enrichment_disables_arxiv_on_rate_limit(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ runner = EnrichmentRunner()
+ publication = SimpleNamespace(id=11, author_text="Ada Lovelace")
+ calls = {"sync": 0}
+
+ async def _raise_rate_limit(db_session, *, publication, scholar_label):
+ _ = (db_session, publication, scholar_label)
+ raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch")
+
+ async def _sync_fields(db_session, *, publication):
+ _ = (db_session, publication)
+ calls["sync"] += 1
+
+ monkeypatch.setattr(
+ identifier_service,
+ "discover_and_sync_identifiers_for_publication",
+ _raise_rate_limit,
+ )
+ monkeypatch.setattr(identifier_service, "sync_identifiers_for_publication_fields", _sync_fields)
+
+ async def _publish_noop(*args, **kwargs) -> None:
+ _ = (args, kwargs)
+
+ monkeypatch.setattr(runner, "_publish_identifier_update_event", _publish_noop)
+
+ result = await runner._discover_identifiers_for_enrichment(
+ object(),
+ publication=publication,
+ run_id=321,
+ allow_arxiv_lookup=True,
+ )
+
+ assert result is False
+ assert calls["sync"] == 1
diff --git a/tests/unit/test_ingestion_progress_reporting.py b/tests/unit/test_ingestion_progress_reporting.py
new file mode 100644
index 0000000..bdae206
--- /dev/null
+++ b/tests/unit/test_ingestion_progress_reporting.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import pytest
+
+from app.services.ingestion.run_completion import apply_outcome_to_progress
+from app.services.ingestion.types import RunProgress, ScholarProcessingOutcome
+
+
+def _outcome(*, scholar_profile_id: int, outcome_label: str) -> ScholarProcessingOutcome:
+ counters = {
+ "success": (1, 0, 0),
+ "partial": (1, 0, 1),
+ "failed": (0, 1, 0),
+ }
+ succeeded, failed, partial = counters[outcome_label]
+ return ScholarProcessingOutcome(
+ result_entry={
+ "scholar_profile_id": scholar_profile_id,
+ "outcome": outcome_label,
+ "state": "ok",
+ "state_reason": "publications_extracted",
+ "publication_count": 1,
+ },
+ succeeded_count_delta=succeeded,
+ failed_count_delta=failed,
+ partial_count_delta=partial,
+ discovered_publication_count=1,
+ )
+
+
+def test_apply_outcome_to_progress_replaces_previous_scholar_outcome() -> None:
+ progress = RunProgress()
+
+ apply_outcome_to_progress(
+ progress=progress,
+ outcome=_outcome(scholar_profile_id=42, outcome_label="partial"),
+ )
+ apply_outcome_to_progress(
+ progress=progress,
+ outcome=_outcome(scholar_profile_id=42, outcome_label="success"),
+ )
+
+ assert len(progress.scholar_results) == 1
+ assert progress.scholar_results[0]["outcome"] == "success"
+ assert progress.succeeded_count == 1
+ assert progress.failed_count == 0
+ assert progress.partial_count == 0
+
+
+def test_apply_outcome_to_progress_rejects_invalid_scholar_id() -> None:
+ progress = RunProgress()
+ invalid = ScholarProcessingOutcome(
+ result_entry={"scholar_profile_id": 0, "outcome": "success"},
+ succeeded_count_delta=1,
+ failed_count_delta=0,
+ partial_count_delta=0,
+ discovered_publication_count=0,
+ )
+
+ with pytest.raises(RuntimeError, match="missing valid scholar_profile_id"):
+ apply_outcome_to_progress(progress=progress, outcome=invalid)
diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py
index 84cf882..a4d8d99 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -7,9 +7,10 @@ from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
-from app.logging_config import JsonLogFormatter, parse_redact_fields
-from app.main import app
from app.http.middleware import REQUEST_ID_HEADER, parse_skip_paths
+from app.logging_config import ConsoleLogFormatter, JsonLogFormatter, parse_redact_fields
+from app.logging_utils import structured_log
+from app.main import app
def test_json_log_formatter_redacts_sensitive_fields() -> None:
@@ -53,3 +54,67 @@ def test_parse_skip_paths_trims_and_discards_empty_segments() -> None:
"/healthz",
"/api/v1/metrics",
)
+
+
+# --- structured_log tests ---
+
+
+def _capture_structured_log(caplog, level, event, **fields):
+ """Helper: call structured_log and return the captured LogRecord."""
+ logger = logging.getLogger("tests.structured")
+ with caplog.at_level(logging.DEBUG, logger="tests.structured"):
+ structured_log(logger, level, event, **fields)
+ return caplog.records[-1]
+
+
+def test_structured_log_json_formatter_uses_event_as_message(caplog) -> None:
+ record = _capture_structured_log(caplog, "info", "ingestion.run_started", user_id=42)
+ formatter = JsonLogFormatter(redact_fields=set())
+ payload = json.loads(formatter.format(record))
+
+ assert payload["event"] == "ingestion.run_started"
+ assert payload["user_id"] == 42
+
+
+def test_structured_log_console_formatter(caplog) -> None:
+ record = _capture_structured_log(caplog, "warning", "export.failed", scholar_id=7)
+ formatter = ConsoleLogFormatter(redact_fields=set())
+ output = formatter.format(record)
+
+ assert "export.failed" in output
+ assert "scholar=7" in output
+
+
+def test_structured_log_strips_metric_fields(caplog) -> None:
+ record = _capture_structured_log(
+ caplog,
+ "info",
+ "scrape.complete",
+ metric_name="articles_scraped",
+ metric_value=15,
+ scholar_id=3,
+ )
+ formatter = JsonLogFormatter(redact_fields=set())
+ payload = json.loads(formatter.format(record))
+
+ assert "metric_name" not in payload
+ assert "metric_value" not in payload
+ assert payload["scholar_id"] == 3
+
+
+def test_structured_log_extra_fields_in_output(caplog) -> None:
+ record = _capture_structured_log(
+ caplog,
+ "info",
+ "scholar.created",
+ user_id=1,
+ scholar_id=99,
+ scholar_name="Ada Lovelace",
+ )
+ formatter = JsonLogFormatter(redact_fields=set())
+ payload = json.loads(formatter.format(record))
+
+ assert payload["event"] == "scholar.created"
+ assert payload["user_id"] == 1
+ assert payload["scholar_id"] == 99
+ assert payload["scholar_name"] == "Ada Lovelace"
diff --git a/tests/unit/test_logging_policy.py b/tests/unit/test_logging_policy.py
new file mode 100644
index 0000000..c0fec71
--- /dev/null
+++ b/tests/unit/test_logging_policy.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+RAW_LOG_METHODS = {
+ "debug",
+ "info",
+ "warning",
+ "error",
+ "exception",
+ "critical",
+}
+APP_DIR = Path(__file__).resolve().parents[2] / "app"
+
+
+def _raw_logger_calls(path: Path) -> list[tuple[int, str]]:
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ violations: list[tuple[int, str]] = []
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ if not isinstance(func, ast.Attribute):
+ continue
+ if not isinstance(func.value, ast.Name) or func.value.id != "logger":
+ continue
+ if func.attr not in RAW_LOG_METHODS:
+ continue
+ violations.append((int(node.lineno), func.attr))
+ return violations
+
+
+def test_app_runtime_uses_structured_log_only() -> None:
+ violations: list[str] = []
+ for path in sorted(APP_DIR.rglob("*.py")):
+ for line_number, method in _raw_logger_calls(path):
+ relative_path = path.relative_to(APP_DIR.parent)
+ violations.append(f"{relative_path}:{line_number} logger.{method}()")
+ assert not violations, "raw logger calls found:\n" + "\n".join(violations)
diff --git a/tests/unit/test_portability_import.py b/tests/unit/test_portability_import.py
new file mode 100644
index 0000000..6ffd3e4
--- /dev/null
+++ b/tests/unit/test_portability_import.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from app.services.portability.publication_import import (
+ _build_imported_publication_input,
+ _initialize_import_counters,
+ _update_link_counters,
+)
+
+
+def _mock_profile(scholar_id: str = "ABC123DEF456") -> MagicMock:
+ profile = MagicMock()
+ profile.id = 1
+ profile.scholar_id = scholar_id
+ return profile
+
+
+def _scholar_map(scholar_id: str = "ABC123DEF456") -> dict[str, MagicMock]:
+ return {scholar_id: _mock_profile(scholar_id)}
+
+
+class TestBuildImportedPublicationInput:
+ def test_returns_parsed_input_for_valid_item(self) -> None:
+ item = {
+ "scholar_id": "ABC123DEF456",
+ "title": "Deep Learning",
+ "year": 2024,
+ "author_text": "Smith, J",
+ "venue_text": "ICML",
+ "citation_count": 10,
+ }
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.title == "Deep Learning"
+ assert result.year == 2024
+ assert result.citation_count == 10
+ assert result.author_text == "Smith, J"
+
+ def test_returns_none_when_title_missing(self) -> None:
+ item = {"scholar_id": "ABC123DEF456"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is None
+
+ def test_returns_none_when_scholar_id_missing(self) -> None:
+ item = {"title": "Test Title"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is None
+
+ def test_returns_none_when_scholar_not_in_map(self) -> None:
+ item = {"scholar_id": "UNKNOWN12345", "title": "Test Title"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is None
+
+ def test_defaults_is_read_to_false(self) -> None:
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.is_read is False
+
+ def test_respects_is_read_true(self) -> None:
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "is_read": True}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.is_read is True
+
+ def test_normalizes_year(self) -> None:
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "year": "invalid"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.year is None
+
+ def test_normalizes_citation_count(self) -> None:
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "citation_count": "not_a_number"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.citation_count == 0
+
+ def test_computes_fingerprint_when_not_provided(self) -> None:
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title"}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert len(result.fingerprint) == 64
+
+ def test_uses_provided_valid_fingerprint(self) -> None:
+ valid_sha = "b" * 64
+ item = {"scholar_id": "ABC123DEF456", "title": "Test Title", "fingerprint_sha256": valid_sha}
+ result = _build_imported_publication_input(item=item, scholar_map=_scholar_map())
+ assert result is not None
+ assert result.fingerprint == valid_sha
+
+
+class TestInitializeImportCounters:
+ def test_adds_publication_and_link_keys(self) -> None:
+ counters: dict[str, int] = {"existing_key": 5}
+ _initialize_import_counters(counters)
+ assert counters["publications_created"] == 0
+ assert counters["publications_updated"] == 0
+ assert counters["links_created"] == 0
+ assert counters["links_updated"] == 0
+ assert counters["existing_key"] == 5
+
+
+class TestUpdateLinkCounters:
+ def test_increments_created(self) -> None:
+ counters = {"links_created": 0, "links_updated": 0}
+ _update_link_counters(counters=counters, link_created=True, link_updated=False)
+ assert counters["links_created"] == 1
+ assert counters["links_updated"] == 0
+
+ def test_increments_updated(self) -> None:
+ counters = {"links_created": 0, "links_updated": 0}
+ _update_link_counters(counters=counters, link_created=False, link_updated=True)
+ assert counters["links_created"] == 0
+ assert counters["links_updated"] == 1
+
+ def test_no_change_when_both_false(self) -> None:
+ counters = {"links_created": 0, "links_updated": 0}
+ _update_link_counters(counters=counters, link_created=False, link_updated=False)
+ assert counters["links_created"] == 0
+ assert counters["links_updated"] == 0
diff --git a/tests/unit/test_portability_normalize.py b/tests/unit/test_portability_normalize.py
new file mode 100644
index 0000000..7d7e439
--- /dev/null
+++ b/tests/unit/test_portability_normalize.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+import pytest
+
+from app.services.portability.constants import MAX_IMPORT_PUBLICATIONS, MAX_IMPORT_SCHOLARS
+from app.services.portability.normalize import (
+ _build_fingerprint,
+ _first_author_last_name,
+ _first_venue_word,
+ _normalize_citation_count,
+ _normalize_optional_text,
+ _normalize_optional_year,
+ _resolve_fingerprint,
+ _validate_import_sizes,
+)
+from app.services.portability.types import ImportExportError
+
+
+class TestNormalizeOptionalText:
+ def test_returns_stripped_string(self) -> None:
+ assert _normalize_optional_text(" hello ") == "hello"
+
+ def test_returns_none_for_none(self) -> None:
+ assert _normalize_optional_text(None) is None
+
+ def test_returns_none_for_empty_string(self) -> None:
+ assert _normalize_optional_text("") is None
+
+ def test_returns_none_for_whitespace_only(self) -> None:
+ assert _normalize_optional_text(" ") is None
+
+ def test_coerces_non_string_to_string(self) -> None:
+ assert _normalize_optional_text(42) == "42"
+
+
+class TestNormalizeOptionalYear:
+ def test_valid_year(self) -> None:
+ assert _normalize_optional_year(2024) == 2024
+
+ def test_string_year(self) -> None:
+ assert _normalize_optional_year("1999") == 1999
+
+ def test_returns_none_for_none(self) -> None:
+ assert _normalize_optional_year(None) is None
+
+ def test_returns_none_for_non_numeric(self) -> None:
+ assert _normalize_optional_year("abc") is None
+
+ def test_returns_none_for_year_below_1500(self) -> None:
+ assert _normalize_optional_year(1499) is None
+
+ def test_returns_none_for_year_above_3000(self) -> None:
+ assert _normalize_optional_year(3001) is None
+
+ def test_boundary_1500_accepted(self) -> None:
+ assert _normalize_optional_year(1500) == 1500
+
+ def test_boundary_3000_accepted(self) -> None:
+ assert _normalize_optional_year(3000) == 3000
+
+
+class TestNormalizeCitationCount:
+ def test_valid_positive(self) -> None:
+ assert _normalize_citation_count(10) == 10
+
+ def test_zero(self) -> None:
+ assert _normalize_citation_count(0) == 0
+
+ def test_negative_clamped_to_zero(self) -> None:
+ assert _normalize_citation_count(-5) == 0
+
+ def test_string_number(self) -> None:
+ assert _normalize_citation_count("42") == 42
+
+ def test_none_returns_zero(self) -> None:
+ assert _normalize_citation_count(None) == 0
+
+ def test_invalid_string_returns_zero(self) -> None:
+ assert _normalize_citation_count("abc") == 0
+
+
+class TestFirstAuthorLastName:
+ def test_single_author(self) -> None:
+ assert _first_author_last_name("John Smith") == "smith"
+
+ def test_multiple_authors(self) -> None:
+ assert _first_author_last_name("Jane Doe, John Smith") == "doe"
+
+ def test_empty_string(self) -> None:
+ assert _first_author_last_name("") == ""
+
+ def test_none(self) -> None:
+ assert _first_author_last_name(None) == ""
+
+
+class TestFirstVenueWord:
+ def test_returns_first_word(self) -> None:
+ assert _first_venue_word("Nature Communications") == "nature"
+
+ def test_empty_string(self) -> None:
+ assert _first_venue_word("") == ""
+
+ def test_none(self) -> None:
+ assert _first_venue_word(None) == ""
+
+
+class TestBuildFingerprint:
+ def test_produces_64_hex_digest(self) -> None:
+ fp = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
+ assert len(fp) == 64
+ assert all(c in "0123456789abcdef" for c in fp)
+
+ def test_deterministic(self) -> None:
+ kwargs = {"title": "Test Title", "year": 2024, "author_text": "Smith", "venue_text": "ICML"}
+ assert _build_fingerprint(**kwargs) == _build_fingerprint(**kwargs)
+
+ def test_different_titles_produce_different_fingerprints(self) -> None:
+ fp1 = _build_fingerprint(title="Title A", year=2024, author_text=None, venue_text=None)
+ fp2 = _build_fingerprint(title="Title B", year=2024, author_text=None, venue_text=None)
+ assert fp1 != fp2
+
+ def test_none_year_handled(self) -> None:
+ fp = _build_fingerprint(title="Test", year=None, author_text=None, venue_text=None)
+ assert len(fp) == 64
+
+
+class TestResolveFingerprint:
+ def test_uses_provided_valid_sha256(self) -> None:
+ valid_sha = "a" * 64
+ result = _resolve_fingerprint(
+ title="Test",
+ year=2024,
+ author_text=None,
+ venue_text=None,
+ provided_fingerprint=valid_sha,
+ )
+ assert result == valid_sha
+
+ def test_computes_fingerprint_when_provided_is_none(self) -> None:
+ result = _resolve_fingerprint(
+ title="Test",
+ year=2024,
+ author_text=None,
+ venue_text=None,
+ provided_fingerprint=None,
+ )
+ assert len(result) == 64
+
+ def test_computes_fingerprint_when_provided_is_invalid(self) -> None:
+ result = _resolve_fingerprint(
+ title="Test",
+ year=2024,
+ author_text=None,
+ venue_text=None,
+ provided_fingerprint="not-a-sha",
+ )
+ assert len(result) == 64
+
+ def test_normalizes_provided_to_lowercase(self) -> None:
+ valid_sha = "A" * 64
+ result = _resolve_fingerprint(
+ title="Test",
+ year=2024,
+ author_text=None,
+ venue_text=None,
+ provided_fingerprint=valid_sha,
+ )
+ assert result == "a" * 64
+
+
+class TestValidateImportSizes:
+ def test_accepts_within_limits(self) -> None:
+ _validate_import_sizes(scholars=[{}], publications=[{}])
+
+ def test_rejects_too_many_scholars(self) -> None:
+ with pytest.raises(ImportExportError, match="max scholars"):
+ _validate_import_sizes(
+ scholars=[{}] * (MAX_IMPORT_SCHOLARS + 1),
+ publications=[],
+ )
+
+ def test_rejects_too_many_publications(self) -> None:
+ with pytest.raises(ImportExportError, match="max publications"):
+ _validate_import_sizes(
+ scholars=[],
+ publications=[{}] * (MAX_IMPORT_PUBLICATIONS + 1),
+ )
+
+ def test_accepts_exact_limit(self) -> None:
+ _validate_import_sizes(
+ scholars=[{}] * MAX_IMPORT_SCHOLARS,
+ publications=[{}] * MAX_IMPORT_PUBLICATIONS,
+ )
diff --git a/tests/unit/test_publication_identifiers.py b/tests/unit/test_publication_identifiers.py
new file mode 100644
index 0000000..1ec44ce
--- /dev/null
+++ b/tests/unit/test_publication_identifiers.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Publication, PublicationIdentifier
+from app.services.arxiv import application as arxiv_service
+from app.services.publication_identifiers import application as identifier_service
+from app.services.publication_identifiers.types import IdentifierKind
+from app.services.publications.types import UnreadPublicationItem
+
+
+def test_derive_display_identifier_prefers_doi_over_arxiv() -> None:
+ display = identifier_service.derive_display_identifier_from_values(
+ doi="10.1000/example",
+ pub_url="https://arxiv.org/abs/1504.08025",
+ pdf_url=None,
+ )
+ assert display is not None
+ assert display.kind == "doi"
+ assert display.value == "10.1000/example"
+ assert display.url == "https://doi.org/10.1000/example"
+
+
+def test_derive_display_identifier_uses_arxiv_when_doi_missing() -> None:
+ display = identifier_service.derive_display_identifier_from_values(
+ doi=None,
+ pub_url="https://arxiv.org/pdf/1504.08025v2",
+ pdf_url=None,
+ )
+ assert display is not None
+ assert display.kind == "arxiv"
+ assert display.value == "1504.08025v2"
+ assert display.label == "arXiv: 1504.08025v2"
+
+
+def test_derive_display_identifier_uses_pmcid_when_present() -> None:
+ display = identifier_service.derive_display_identifier_from_values(
+ doi=None,
+ pub_url=None,
+ pdf_url="https://pmc.ncbi.nlm.nih.gov/articles/PMC2175868/pdf/file.pdf",
+ )
+ assert display is not None
+ assert display.kind == "pmcid"
+ assert display.value == "PMC2175868"
+
+
+def test_normalize_arxiv_id_handles_urls() -> None:
+ from app.services.publication_identifiers.normalize import normalize_arxiv_id
+
+ assert normalize_arxiv_id("https://arxiv.org/abs/1504.08025") == "1504.08025"
+ assert normalize_arxiv_id("http://arxiv.org/pdf/1504.08025v2.pdf") == "1504.08025v2"
+ assert normalize_arxiv_id("https://arxiv.org/html/1504.08025v2") == "1504.08025v2"
+ # Modern arxiv format
+ assert normalize_arxiv_id("https://arxiv.org/abs/2012.00001") == "2012.00001"
+ # Old arxiv format
+ assert normalize_arxiv_id("https://arxiv.org/abs/math/9901123") == "math/9901123"
+
+
+def test_normalize_arxiv_id_handles_raw_text() -> None:
+ from app.services.publication_identifiers.normalize import normalize_arxiv_id
+
+ assert normalize_arxiv_id("1504.08025v1") == "1504.08025v1"
+ assert normalize_arxiv_id("arXiv:1504.08025") == "1504.08025"
+ assert normalize_arxiv_id("arxiv: 1504.08025v1") == "1504.08025v1"
+ assert normalize_arxiv_id("Preprint at arXiv:math/9901123v2") == "math/9901123v2"
+ assert normalize_arxiv_id("Not an arxiv: 123") is None
+
+
+def test_normalize_pmcid_handles_urls_and_text() -> None:
+ from app.services.publication_identifiers.normalize import normalize_pmcid
+
+ assert normalize_pmcid("https://pmc.ncbi.nlm.nih.gov/articles/PMC2175868/") == "PMC2175868"
+ assert normalize_pmcid("http://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567") == "PMC1234567"
+ assert normalize_pmcid("PMCID: PMC1234567") == "PMC1234567"
+ assert normalize_pmcid("pmc1234567") == "PMC1234567"
+ assert normalize_pmcid("Not a PMCID 1234567") is None
+
+
+def test_normalize_pmid_handles_urls() -> None:
+ from app.services.publication_identifiers.normalize import normalize_pmid
+
+ assert normalize_pmid("https://pubmed.ncbi.nlm.nih.gov/12345678/") == "12345678"
+ assert normalize_pmid("http://pubmed.ncbi.nlm.nih.gov/12345678") == "12345678"
+ assert normalize_pmid("https://pubmed.ncbi.nlm.nih.gov/not-a-pmid/") is None
+
+
+def _publication(*, title: str, pub_url: str | None = None) -> Publication:
+ return Publication(
+ cluster_id=None,
+ fingerprint_sha256="f" * 64,
+ title_raw=title,
+ title_normalized=title.lower(),
+ year=2024,
+ citation_count=0,
+ author_text=None,
+ venue_text=None,
+ pub_url=pub_url,
+ pdf_url=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_discover_and_sync_skips_arxiv_when_crossref_finds_doi(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publication = _publication(title="Reliable Paper Title for DOI discovery")
+ db_session.add(publication)
+ await db_session.flush()
+
+ async def _fake_crossref(*, item):
+ return "10.1000/strong-doi"
+
+ async def _fail_arxiv(*, item, request_email=None, timeout_seconds=None):
+ raise AssertionError("arXiv should be skipped after strong DOI evidence.")
+
+ monkeypatch.setattr(
+ "app.services.crossref.application.discover_doi_for_publication",
+ _fake_crossref,
+ )
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fail_arxiv,
+ )
+
+ await identifier_service.discover_and_sync_identifiers_for_publication(
+ db_session,
+ publication=publication,
+ scholar_label="Ada Lovelace",
+ )
+
+ result = await db_session.execute(
+ select(PublicationIdentifier).where(
+ PublicationIdentifier.publication_id == int(publication.id),
+ PublicationIdentifier.kind == IdentifierKind.DOI.value,
+ )
+ )
+ assert result.scalar_one_or_none() is not None
+
+
+@pytest.mark.asyncio
+async def test_discover_and_sync_skips_arxiv_for_low_quality_title(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publication = _publication(title="AI 2024")
+ db_session.add(publication)
+ await db_session.flush()
+ calls = {"count": 0}
+
+ async def _fake_crossref(*, item):
+ return None
+
+ async def _fake_arxiv(*, item, request_email=None, timeout_seconds=None):
+ calls["count"] += 1
+ return "1234.5678"
+
+ monkeypatch.setattr(
+ "app.services.crossref.application.discover_doi_for_publication",
+ _fake_crossref,
+ )
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fake_arxiv,
+ )
+
+ await identifier_service.discover_and_sync_identifiers_for_publication(
+ db_session,
+ publication=publication,
+ scholar_label="Alan Turing",
+ )
+ assert calls["count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_discover_and_sync_calls_arxiv_when_item_is_eligible(
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ publication = _publication(title="Neural Representation Learning for Graph Signals")
+ db_session.add(publication)
+ await db_session.flush()
+
+ async def _fake_crossref(*, item):
+ return None
+
+ async def _fake_arxiv(*, item, request_email=None, timeout_seconds=None):
+ return "2501.00001v2"
+
+ monkeypatch.setattr(
+ "app.services.crossref.application.discover_doi_for_publication",
+ _fake_crossref,
+ )
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fake_arxiv,
+ )
+
+ await identifier_service.discover_and_sync_identifiers_for_publication(
+ db_session,
+ publication=publication,
+ scholar_label="Grace Hopper",
+ )
+
+ result = await db_session.execute(
+ select(PublicationIdentifier).where(
+ PublicationIdentifier.publication_id == int(publication.id),
+ PublicationIdentifier.kind == IdentifierKind.ARXIV.value,
+ )
+ )
+ identifier = result.scalar_one_or_none()
+ assert identifier is not None
+ assert identifier.value_normalized == "2501.00001v2"
+
+
+@pytest.mark.asyncio
+async def test_discover_arxiv_id_returns_none_if_no_title() -> None:
+ item = UnreadPublicationItem(
+ publication_id=1,
+ scholar_profile_id=1,
+ scholar_label="First Last",
+ title="",
+ year=2023,
+ citation_count=0,
+ venue_text=None,
+ pub_url=None,
+ pdf_url=None,
+ )
+ result = await arxiv_service.discover_arxiv_id_for_publication(item=item)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_build_arxiv_query() -> None:
+ query = arxiv_service._build_arxiv_query("Super AI Model", "Smith")
+ assert query == 'ti:"Super AI Model" AND au:"Smith"'
+
+ query2 = arxiv_service._build_arxiv_query("Only Title Here", None)
+ assert query2 == 'ti:"Only Title Here"'
diff --git a/tests/unit/test_publication_pdf_queue_policy.py b/tests/unit/test_publication_pdf_queue_policy.py
index 3d4fe7f..ca909a6 100644
--- a/tests/unit/test_publication_pdf_queue_policy.py
+++ b/tests/unit/test_publication_pdf_queue_policy.py
@@ -1,11 +1,14 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
import pytest
from app.db.models import PublicationPdfJob
-from app.services.domains.publications import pdf_queue
+from app.services.publications import pdf_queue, pdf_queue_resolution
+from app.services.publications.pdf_resolution_pipeline import PipelineOutcome
+from app.services.unpaywall.application import OaResolutionOutcome
def _job(
@@ -22,9 +25,30 @@ def _job(
)
+def _row(
+ *, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz"
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ publication_id=1,
+ scholar_profile_id=1,
+ scholar_label="Ada Lovelace",
+ title="A paper",
+ year=2024,
+ citation_count=0,
+ venue_text=None,
+ pub_url=pub_url,
+ doi=None,
+ pdf_url=None,
+ is_read=False,
+ is_favorite=False,
+ first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=UTC),
+ is_new_in_latest_run=True,
+ )
+
+
def test_pdf_queue_auto_enqueue_blocks_recent_attempt(monkeypatch: pytest.MonkeyPatch) -> None:
- now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
- monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=UTC)
+ monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
monkeypatch.setattr(pdf_queue, "_auto_retry_first_interval_seconds", lambda: 3_600)
monkeypatch.setattr(pdf_queue, "_auto_retry_interval_seconds", lambda: 86_400)
monkeypatch.setattr(pdf_queue, "_auto_retry_max_attempts", lambda: 3)
@@ -37,8 +61,8 @@ def test_pdf_queue_auto_enqueue_blocks_recent_attempt(monkeypatch: pytest.Monkey
def test_pdf_queue_auto_enqueue_blocks_recent_first_retry(monkeypatch: pytest.MonkeyPatch) -> None:
- now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
- monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=UTC)
+ monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
monkeypatch.setattr(pdf_queue, "_auto_retry_first_interval_seconds", lambda: 3_600)
monkeypatch.setattr(pdf_queue, "_auto_retry_interval_seconds", lambda: 86_400)
monkeypatch.setattr(pdf_queue, "_auto_retry_max_attempts", lambda: 3)
@@ -51,8 +75,8 @@ def test_pdf_queue_auto_enqueue_blocks_recent_first_retry(monkeypatch: pytest.Mo
def test_pdf_queue_auto_enqueue_blocks_after_max_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
- now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
- monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=UTC)
+ monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
monkeypatch.setattr(pdf_queue, "_auto_retry_first_interval_seconds", lambda: 3_600)
monkeypatch.setattr(pdf_queue, "_auto_retry_interval_seconds", lambda: 86_400)
monkeypatch.setattr(pdf_queue, "_auto_retry_max_attempts", lambda: 3)
@@ -65,8 +89,8 @@ def test_pdf_queue_auto_enqueue_blocks_after_max_attempts(monkeypatch: pytest.Mo
def test_pdf_queue_auto_enqueue_blocks_second_retry_within_day(monkeypatch: pytest.MonkeyPatch) -> None:
- now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
- monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=UTC)
+ monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
monkeypatch.setattr(pdf_queue, "_auto_retry_first_interval_seconds", lambda: 3_600)
monkeypatch.setattr(pdf_queue, "_auto_retry_interval_seconds", lambda: 86_400)
monkeypatch.setattr(pdf_queue, "_auto_retry_max_attempts", lambda: 3)
@@ -81,8 +105,8 @@ def test_pdf_queue_auto_enqueue_blocks_second_retry_within_day(monkeypatch: pyte
def test_pdf_queue_manual_requeue_bypasses_cooldown_and_max_attempts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
- monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=UTC)
+ monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
monkeypatch.setattr(pdf_queue, "_auto_retry_first_interval_seconds", lambda: 3_600)
monkeypatch.setattr(pdf_queue, "_auto_retry_interval_seconds", lambda: 86_400)
monkeypatch.setattr(pdf_queue, "_auto_retry_max_attempts", lambda: 3)
@@ -107,3 +131,134 @@ def test_pdf_queue_manual_requeue_still_blocks_when_inflight() -> None:
)
assert pdf_queue._can_enqueue_job(running, force_retry=True) is False
assert pdf_queue._can_enqueue_job(queued, force_retry=True) is False
+
+
+@pytest.mark.asyncio
+async def test_fetch_outcome_for_row_uses_pipeline_outcome(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake_pipeline(*, row, request_email=None, openalex_api_key=None, allow_arxiv_lookup=True):
+ assert request_email == "user@example.com"
+ assert allow_arxiv_lookup is True
+ return PipelineOutcome(
+ outcome=OaResolutionOutcome(
+ publication_id=row.publication_id,
+ doi=None,
+ pdf_url="https://arxiv.org/pdf/1703.06103",
+ failure_reason=None,
+ source="openalex",
+ used_crossref=False,
+ ),
+ scholar_candidates=None,
+ )
+
+ monkeypatch.setattr(pdf_queue_resolution, "resolve_publication_pdf_outcome_for_row", _fake_pipeline)
+
+ outcome, arxiv_rate_limited = await pdf_queue_resolution._fetch_outcome_for_row(
+ row=_row(),
+ request_email="user@example.com",
+ )
+
+ assert outcome.pdf_url == "https://arxiv.org/pdf/1703.06103"
+ assert outcome.source == "openalex"
+ assert outcome.used_crossref is False
+ assert arxiv_rate_limited is False
+
+
+@pytest.mark.asyncio
+async def test_fetch_outcome_for_row_returns_failed_outcome_when_pipeline_returns_none(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_pipeline(*, row, request_email=None, openalex_api_key=None, allow_arxiv_lookup=True):
+ assert request_email == "user@example.com"
+ assert allow_arxiv_lookup is True
+ return PipelineOutcome(outcome=None, scholar_candidates=None, arxiv_rate_limited=True)
+
+ monkeypatch.setattr(pdf_queue_resolution, "resolve_publication_pdf_outcome_for_row", _fake_pipeline)
+
+ outcome, arxiv_rate_limited = await pdf_queue_resolution._fetch_outcome_for_row(
+ row=_row(),
+ request_email="user@example.com",
+ )
+
+ assert outcome.pdf_url is None
+ assert outcome.failure_reason == pdf_queue_resolution.FAILURE_RESOLUTION_EXCEPTION
+ assert arxiv_rate_limited is True
+
+
+@pytest.mark.asyncio
+async def test_resolve_publication_row_persists_outcome_and_returns_rate_limit_flag(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: list[tuple[int, int, OaResolutionOutcome]] = []
+
+ async def _noop_mark_attempt_started(*, publication_id: int, user_id: int) -> None:
+ return None
+
+ async def _fake_fetch(*, row, request_email=None, openalex_api_key=None, allow_arxiv_lookup=True):
+ assert allow_arxiv_lookup is True
+ return (
+ OaResolutionOutcome(
+ publication_id=row.publication_id,
+ doi=None,
+ pdf_url="https://fallback.example/test.pdf",
+ failure_reason=None,
+ source="unpaywall",
+ used_crossref=False,
+ ),
+ True,
+ )
+
+ async def _capture_persist_outcome(*, publication_id: int, user_id: int, outcome: OaResolutionOutcome) -> None:
+ captured.append((publication_id, user_id, outcome))
+
+ monkeypatch.setattr(pdf_queue_resolution, "_mark_attempt_started", _noop_mark_attempt_started)
+ monkeypatch.setattr(pdf_queue_resolution, "_fetch_outcome_for_row", _fake_fetch)
+ monkeypatch.setattr(pdf_queue_resolution, "_persist_outcome", _capture_persist_outcome)
+
+ rate_limited = await pdf_queue_resolution._resolve_publication_row(
+ user_id=42,
+ request_email="user@example.com",
+ row=_row(),
+ openalex_api_key="key",
+ )
+
+ assert len(captured) == 1
+ publication_id, user_id, outcome = captured[0]
+ assert publication_id == 1
+ assert user_id == 42
+ assert outcome.pdf_url == "https://fallback.example/test.pdf"
+ assert rate_limited is True
+
+
+@pytest.mark.asyncio
+async def test_run_resolution_task_disables_arxiv_for_remaining_batch(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[tuple[int, bool]] = []
+ first = _row()
+ second = SimpleNamespace(**{**first.__dict__, "publication_id": 2})
+
+ def _raise_session_factory_error():
+ raise RuntimeError("skip user settings lookup in test")
+
+ async def _fake_resolve_publication_row(
+ *,
+ user_id: int,
+ request_email: str | None,
+ row,
+ openalex_api_key=None,
+ allow_arxiv_lookup=True,
+ ):
+ _ = (user_id, request_email, openalex_api_key)
+ calls.append((int(row.publication_id), bool(allow_arxiv_lookup)))
+ return row.publication_id == 1
+
+ monkeypatch.setattr(pdf_queue_resolution, "get_session_factory", _raise_session_factory_error)
+ monkeypatch.setattr(pdf_queue_resolution, "_resolve_publication_row", _fake_resolve_publication_row)
+
+ await pdf_queue_resolution._run_resolution_task(
+ user_id=42,
+ request_email="user@example.com",
+ rows=[first, second],
+ )
+
+ assert calls == [(1, True), (2, False)]
diff --git a/tests/unit/test_publication_pdf_resolution_pipeline.py b/tests/unit/test_publication_pdf_resolution_pipeline.py
new file mode 100644
index 0000000..284be8c
--- /dev/null
+++ b/tests/unit/test_publication_pdf_resolution_pipeline.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from types import SimpleNamespace
+
+import pytest
+
+from app.services.arxiv.errors import ArxivRateLimitError
+from app.services.publication_identifiers.types import DisplayIdentifier
+from app.services.publications import pdf_resolution_pipeline as pipeline
+from app.services.unpaywall.application import OaResolutionOutcome
+
+
+def _row(*, display_identifier: DisplayIdentifier | None = None) -> SimpleNamespace:
+ return SimpleNamespace(
+ publication_id=1,
+ scholar_profile_id=1,
+ scholar_label="Ada Lovelace",
+ title="A paper",
+ year=2024,
+ citation_count=0,
+ venue_text=None,
+ pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz",
+ display_identifier=display_identifier,
+ pdf_url=None,
+ is_read=False,
+ is_favorite=False,
+ first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=UTC),
+ is_new_in_latest_run=True,
+ )
+
+
+def _api_outcome(*, pdf_url: str | None, source: str = "unpaywall") -> OaResolutionOutcome | None:
+ if not pdf_url:
+ return None
+ return OaResolutionOutcome(
+ publication_id=1,
+ doi="10.1000/example",
+ pdf_url=pdf_url,
+ failure_reason=None if pdf_url else "no_pdf_found",
+ source=source,
+ used_crossref=False,
+ )
+
+
+def _oa_fallback_outcome(*, pdf_url: str | None, source: str = "unpaywall") -> OaResolutionOutcome:
+ return OaResolutionOutcome(
+ publication_id=1,
+ doi="10.1000/example",
+ pdf_url=pdf_url,
+ failure_reason=None if pdf_url else "no_pdf_found",
+ source=source,
+ used_crossref=False,
+ )
+
+
+@pytest.mark.asyncio
+async def test_pipeline_prefers_openalex_before_arxiv(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
+ return _api_outcome(pdf_url="https://oa.example.org/found.pdf", source="openalex")
+
+ async def _fail_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
+ _ = (row, request_email, allow_lookup)
+ raise AssertionError("arXiv should not run when OpenAlex candidate exists.")
+
+ monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
+ monkeypatch.setattr(pipeline, "_arxiv_outcome", _fail_arxiv)
+
+ result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
+
+ assert result.outcome is not None
+ assert result.outcome.pdf_url == "https://oa.example.org/found.pdf"
+ assert result.outcome.source == "openalex"
+
+
+@pytest.mark.asyncio
+async def test_pipeline_uses_arxiv_after_openalex_failure(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
+ return None
+
+ async def _fake_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
+ _ = allow_lookup
+ return _api_outcome(pdf_url="https://arxiv.org/pdf/1234.5678.pdf", source="arxiv")
+
+ async def _fail_oa(*, row, request_email):
+ raise AssertionError("Unpaywall should not run when arXiv returns PDF.")
+
+ monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
+ monkeypatch.setattr(pipeline, "_arxiv_outcome", _fake_arxiv)
+ monkeypatch.setattr(pipeline, "_oa_outcome", _fail_oa)
+
+ result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
+
+ assert result.outcome is not None
+ assert result.outcome.pdf_url == "https://arxiv.org/pdf/1234.5678.pdf"
+ assert result.outcome.source == "arxiv"
+
+
+@pytest.mark.asyncio
+async def test_pipeline_uses_unpaywall_after_arxiv_failure(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
+ return None
+
+ async def _fake_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
+ _ = (request_email, allow_lookup)
+ return None
+
+ async def _fake_oa(*, row, request_email):
+ assert request_email == "user@example.com"
+ return _oa_fallback_outcome(pdf_url="https://example.org/fallback.pdf", source="unpaywall")
+
+ monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
+ monkeypatch.setattr(pipeline, "_arxiv_outcome", _fake_arxiv)
+ monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa)
+
+ result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
+
+ assert result.outcome is not None
+ assert result.outcome.pdf_url == "https://example.org/fallback.pdf"
+ assert result.outcome.source == "unpaywall"
+
+
+@pytest.mark.asyncio
+async def test_pipeline_falls_back_to_unpaywall_when_arxiv_is_rate_limited(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
+ _ = (row, request_email, openalex_api_key)
+ return None
+
+ async def _raise_rate_limit(row, *, request_email: str | None = None, allow_lookup: bool = True):
+ _ = (row, request_email, allow_lookup)
+ raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch")
+
+ async def _fake_oa(*, row, request_email):
+ _ = (row, request_email)
+ return _oa_fallback_outcome(pdf_url="https://example.org/fallback.pdf", source="unpaywall")
+
+ monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
+ monkeypatch.setattr(pipeline, "_arxiv_outcome", _raise_rate_limit)
+ monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa)
+
+ result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
+
+ assert result.outcome is not None
+ assert result.outcome.source == "unpaywall"
+ assert result.arxiv_rate_limited is True
+
+
+@pytest.mark.asyncio
+async def test_arxiv_outcome_skips_when_strong_doi_identifier(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ row = _row(
+ display_identifier=DisplayIdentifier(
+ kind="doi",
+ value="10.1000/example",
+ label="DOI",
+ url="https://doi.org/10.1000/example",
+ confidence_score=1.0,
+ )
+ )
+
+ async def _fail_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
+ raise AssertionError("arXiv lookup should be skipped when DOI evidence is strong.")
+
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fail_discover,
+ )
+ outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
+ assert outcome is None
+
+
+@pytest.mark.asyncio
+async def test_arxiv_outcome_skips_when_title_quality_is_low(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ row = _row()
+ row.title = "AI 2024"
+
+ async def _fail_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
+ raise AssertionError("arXiv lookup should be skipped for low-quality titles.")
+
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fail_discover,
+ )
+ outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
+ assert outcome is None
+
+
+@pytest.mark.asyncio
+async def test_arxiv_outcome_calls_arxiv_when_eligible(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
+ return "1234.5678"
+
+ monkeypatch.setattr(
+ "app.services.arxiv.application.discover_arxiv_id_for_publication",
+ _fake_discover,
+ )
+ row = _row()
+ row.title = "Reliable Graph Neural Network Benchmark across Multiple Datasets"
+ outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
+
+ assert outcome is not None
+ assert outcome.source == "arxiv"
+ assert outcome.pdf_url == "https://arxiv.org/pdf/1234.5678.pdf"
diff --git a/tests/unit/test_request_delay_policy.py b/tests/unit/test_request_delay_policy.py
index f8eb767..411ae3c 100644
--- a/tests/unit/test_request_delay_policy.py
+++ b/tests/unit/test_request_delay_policy.py
@@ -1,9 +1,10 @@
from __future__ import annotations
-from datetime import datetime, timezone
+from datetime import UTC, datetime
-from app.services.domains.ingestion.application import ScholarIngestionService
-from app.services.domains.ingestion import scheduler as scheduler_module
+from app.services.ingestion import scheduler as scheduler_module
+from app.services.ingestion.application import ScholarIngestionService
+from app.services.ingestion.queue_runner import effective_request_delay_seconds
from app.settings import settings
@@ -25,9 +26,9 @@ def test_ingestion_effective_request_delay_respects_policy_minimum() -> None:
def test_scheduler_effective_request_delay_respects_policy_minimum() -> None:
previous = _set_policy_minimum(9)
try:
- assert scheduler_module._effective_request_delay_seconds(None) == 9
- assert scheduler_module._effective_request_delay_seconds(6) == 9
- assert scheduler_module._effective_request_delay_seconds(11) == 11
+ assert effective_request_delay_seconds(None, floor=9) == 9
+ assert effective_request_delay_seconds(6, floor=9) == 9
+ assert effective_request_delay_seconds(11, floor=9) == 11
finally:
object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous)
@@ -37,7 +38,7 @@ def test_scheduler_candidate_row_clamps_request_delay() -> None:
try:
candidate = scheduler_module.SchedulerService._candidate_from_row(
(1, 15, 1, None, None),
- now_utc=datetime(2026, 2, 21, tzinfo=timezone.utc),
+ now_utc=datetime(2026, 2, 21, tzinfo=UTC),
)
assert candidate is not None
assert candidate.request_delay_seconds == 6
diff --git a/tests/unit/test_run_safety.py b/tests/unit/test_run_safety.py
index 60cd87c..ac0c297 100644
--- a/tests/unit/test_run_safety.py
+++ b/tests/unit/test_run_safety.py
@@ -1,14 +1,14 @@
from __future__ import annotations
-from datetime import datetime, timedelta, timezone
+from datetime import UTC, datetime, timedelta
from app.db.models import UserSetting
-from app.services.domains.ingestion import safety as run_safety
+from app.services.ingestion import safety as run_safety
def test_apply_run_safety_outcome_triggers_blocked_cooldown() -> None:
settings = UserSetting(user_id=1, scrape_safety_state={})
- now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=UTC)
safety_state, reason = run_safety.apply_run_safety_outcome(
settings,
@@ -32,7 +32,7 @@ def test_apply_run_safety_outcome_triggers_blocked_cooldown() -> None:
def test_clear_expired_cooldown() -> None:
- now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=UTC)
settings = UserSetting(
user_id=1,
scrape_safety_state={"blocked_start_count": 3},
@@ -51,7 +51,7 @@ def test_clear_expired_cooldown() -> None:
def test_apply_run_safety_outcome_triggers_network_cooldown() -> None:
settings = UserSetting(user_id=1, scrape_safety_state={})
- now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=UTC)
safety_state, reason = run_safety.apply_run_safety_outcome(
settings,
@@ -75,7 +75,7 @@ def test_apply_run_safety_outcome_triggers_network_cooldown() -> None:
def test_register_cooldown_blocked_start_increments_counter() -> None:
- now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=UTC)
settings = UserSetting(
user_id=1,
scrape_safety_state={"blocked_start_count": 1},
@@ -91,7 +91,7 @@ def test_register_cooldown_blocked_start_increments_counter() -> None:
def test_get_safety_event_context_contains_structured_fields() -> None:
- now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=UTC)
settings = UserSetting(
user_id=1,
scrape_safety_state={"cooldown_entry_count": 4},
diff --git a/tests/unit/test_runs_summary.py b/tests/unit/test_runs_summary.py
index 14ea44d..2c224d6 100644
--- a/tests/unit/test_runs_summary.py
+++ b/tests/unit/test_runs_summary.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from app.services.domains.runs.application import extract_run_summary
+from app.services.runs.application import extract_run_summary
def test_extract_run_summary_includes_extended_metrics() -> None:
diff --git a/tests/unit/test_scholar_parser.py b/tests/unit/test_scholar_parser.py
index 7c70dd3..90bc2da 100644
--- a/tests/unit/test_scholar_parser.py
+++ b/tests/unit/test_scholar_parser.py
@@ -4,13 +4,13 @@ from pathlib import Path
import pytest
-from app.services.domains.scholar.parser import (
+from app.services.scholar.parser import (
ParseState,
ScholarDomInvariantError,
parse_author_search_page,
parse_profile_page,
)
-from app.services.domains.scholar.source import FetchResult
+from app.services.scholar.source import FetchResult
def _fixture(name: str) -> str:
@@ -433,3 +433,18 @@ def test_parse_author_search_page_classifies_http_429_as_blocked() -> None:
assert parsed.state == ParseState.BLOCKED_OR_CAPTCHA
assert parsed.state_reason == "blocked_http_429_rate_limited"
+
+
+def test_parse_author_search_page_prefers_sorry_challenge_reason_over_generic_429() -> None:
+ fetch_result = FetchResult(
+ requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
+ status_code=429,
+ final_url="https://www.google.com/sorry/index?continue=scholar",
+ body="Our systems have detected unusual traffic.",
+ error=None,
+ )
+
+ parsed = parse_author_search_page(fetch_result)
+
+ assert parsed.state == ParseState.BLOCKED_OR_CAPTCHA
+ assert parsed.state_reason == "blocked_google_sorry_challenge"
diff --git a/tests/unit/test_scholar_search_safety.py b/tests/unit/test_scholar_search_safety.py
index b3c35da..2bc5be8 100644
--- a/tests/unit/test_scholar_search_safety.py
+++ b/tests/unit/test_scholar_search_safety.py
@@ -3,9 +3,9 @@ from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
-from app.services.domains.scholars import application as scholar_service
-from app.services.domains.scholar.parser import ParseState
-from app.services.domains.scholar.source import FetchResult
+from app.services.scholar.parser import ParseState
+from app.services.scholar.source import FetchResult
+from app.services.scholars import application as scholar_service
pytestmark = [pytest.mark.integration, pytest.mark.db]
@@ -51,8 +51,7 @@ def _blocked_author_search_fetch() -> FetchResult:
requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
status_code=200,
final_url=(
- "https://accounts.google.com/v3/signin/identifier"
- "?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
+ "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
),
body="Sign in",
error=None,
diff --git a/tests/unit/test_scholar_source.py b/tests/unit/test_scholar_source.py
index c11ed1e..0d98f4c 100644
--- a/tests/unit/test_scholar_source.py
+++ b/tests/unit/test_scholar_source.py
@@ -1,4 +1,13 @@
-from app.services.domains.scholar.source import _build_profile_url
+import pytest
+
+from app.services.scholar import rate_limit as scholar_rate_limit
+from app.services.scholar.source import FetchResult, LiveScholarSource, _build_profile_url
+from app.settings import settings
+
+
+def _request_header(request, name: str) -> str | None:
+ headers = {key.lower(): value for key, value in request.header_items()}
+ return headers.get(name.lower())
def test_build_profile_url_includes_pagesize_for_initial_page() -> None:
@@ -11,3 +20,106 @@ def test_build_profile_url_includes_pagesize_for_initial_page() -> None:
assert "user=abcDEF123456" in url
assert "pagesize=100" in url
assert "cstart=" not in url
+
+
+@pytest.mark.asyncio
+async def test_live_scholar_source_applies_global_throttle(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured_interval: list[float] = []
+
+ async def _fake_wait_for_scholar_slot(*, min_interval_seconds: float) -> None:
+ captured_interval.append(min_interval_seconds)
+
+ monkeypatch.setattr(
+ scholar_rate_limit,
+ "wait_for_scholar_slot",
+ _fake_wait_for_scholar_slot,
+ )
+
+ expected_result = FetchResult(
+ requested_url="https://example.test/scholar",
+ status_code=200,
+ final_url="https://example.test/scholar",
+ body="ok",
+ error=None,
+ )
+
+ source = LiveScholarSource(min_interval_seconds=7.0)
+ monkeypatch.setattr(source, "_fetch_sync", lambda _url: expected_result)
+
+ result = await source.fetch_profile_page_html(
+ "abcDEF123456",
+ cstart=0,
+ pagesize=100,
+ )
+
+ assert result == expected_result
+ assert captured_interval == [7.0]
+
+
+def test_http_error_reason_prefers_sorry_challenge_marker() -> None:
+ reason = LiveScholarSource._http_error_reason(
+ status_code=429,
+ final_url="https://www.google.com/sorry/index?continue=example",
+ body="",
+ )
+ assert reason == "blocked_google_sorry_challenge"
+
+
+def test_http_error_reason_falls_back_to_http_429_rate_limit() -> None:
+ reason = LiveScholarSource._http_error_reason(
+ status_code=429,
+ final_url="https://scholar.google.com/citations?hl=en&user=abc",
+ body="Too Many Requests",
+ )
+ assert reason == "blocked_http_429_rate_limited"
+
+
+def test_build_request_uses_stable_user_agent_by_default() -> None:
+ previous_user_agent = settings.scholar_http_user_agent
+ previous_rotate = settings.scholar_http_rotate_user_agent
+ previous_cookie = settings.scholar_http_cookie
+ previous_accept_language = settings.scholar_http_accept_language
+ object.__setattr__(settings, "scholar_http_user_agent", "")
+ object.__setattr__(settings, "scholar_http_rotate_user_agent", False)
+ object.__setattr__(settings, "scholar_http_cookie", "")
+ object.__setattr__(settings, "scholar_http_accept_language", "en-US,en;q=0.9")
+ try:
+ source = LiveScholarSource(user_agents=["UA-A", "UA-B"])
+ request_one = source._build_request("https://example.test/one")
+ request_two = source._build_request("https://example.test/two")
+ assert _request_header(request_one, "User-Agent") == _request_header(request_two, "User-Agent")
+ assert _request_header(request_one, "Connection") is None
+ finally:
+ object.__setattr__(settings, "scholar_http_user_agent", previous_user_agent)
+ object.__setattr__(settings, "scholar_http_rotate_user_agent", previous_rotate)
+ object.__setattr__(settings, "scholar_http_cookie", previous_cookie)
+ object.__setattr__(settings, "scholar_http_accept_language", previous_accept_language)
+
+
+def test_build_request_rotates_user_agent_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
+ previous_user_agent = settings.scholar_http_user_agent
+ previous_rotate = settings.scholar_http_rotate_user_agent
+ object.__setattr__(settings, "scholar_http_user_agent", "")
+ object.__setattr__(settings, "scholar_http_rotate_user_agent", True)
+ choices = iter(["UA-STABLE", "UA-1", "UA-2"])
+ monkeypatch.setattr("app.services.scholar.source.random.choice", lambda _values: next(choices))
+ try:
+ source = LiveScholarSource(user_agents=["UA-A", "UA-B"])
+ request_one = source._build_request("https://example.test/one")
+ request_two = source._build_request("https://example.test/two")
+ assert _request_header(request_one, "User-Agent") == "UA-1"
+ assert _request_header(request_two, "User-Agent") == "UA-2"
+ finally:
+ object.__setattr__(settings, "scholar_http_user_agent", previous_user_agent)
+ object.__setattr__(settings, "scholar_http_rotate_user_agent", previous_rotate)
+
+
+def test_build_request_includes_cookie_when_configured() -> None:
+ previous_cookie = settings.scholar_http_cookie
+ object.__setattr__(settings, "scholar_http_cookie", "SID=abc123")
+ try:
+ source = LiveScholarSource(user_agents=["UA-A"])
+ request = source._build_request("https://example.test/one")
+ assert _request_header(request, "Cookie") == "SID=abc123"
+ finally:
+ object.__setattr__(settings, "scholar_http_cookie", previous_cookie)
diff --git a/tests/unit/test_scholar_validators.py b/tests/unit/test_scholar_validators.py
new file mode 100644
index 0000000..460b315
--- /dev/null
+++ b/tests/unit/test_scholar_validators.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import os
+import tempfile
+
+import pytest
+
+from app.services.scholars.constants import MAX_IMAGE_URL_LENGTH
+from app.services.scholars.exceptions import ScholarServiceError
+from app.services.scholars.uploads import (
+ _ensure_upload_root,
+ _resolve_upload_path,
+ _safe_remove_upload,
+ resolve_upload_file_path,
+)
+from app.services.scholars.validators import (
+ normalize_display_name,
+ normalize_profile_image_url,
+ validate_scholar_id,
+)
+
+
+class TestValidateScholarId:
+ def test_valid_12_char_id(self) -> None:
+ assert validate_scholar_id("ABCDEF123456") == "ABCDEF123456"
+
+ def test_valid_with_hyphens_and_underscores(self) -> None:
+ assert validate_scholar_id("AB-CD_EF1234") == "AB-CD_EF1234"
+
+ def test_strips_whitespace(self) -> None:
+ assert validate_scholar_id(" ABCDEF123456 ") == "ABCDEF123456"
+
+ def test_rejects_too_short(self) -> None:
+ with pytest.raises(ScholarServiceError, match="12"):
+ validate_scholar_id("ABC123")
+
+ def test_rejects_too_long(self) -> None:
+ with pytest.raises(ScholarServiceError, match="12"):
+ validate_scholar_id("ABCDEF1234567")
+
+ def test_rejects_special_characters(self) -> None:
+ with pytest.raises(ScholarServiceError, match="12"):
+ validate_scholar_id("ABCDEF12345!")
+
+
+class TestNormalizeDisplayName:
+ def test_returns_stripped_name(self) -> None:
+ assert normalize_display_name(" John Doe ") == "John Doe"
+
+ def test_returns_none_for_empty(self) -> None:
+ assert normalize_display_name("") is None
+
+ def test_returns_none_for_whitespace_only(self) -> None:
+ assert normalize_display_name(" ") is None
+
+ def test_preserves_non_empty(self) -> None:
+ assert normalize_display_name("A") == "A"
+
+
+class TestNormalizeProfileImageUrl:
+ def test_valid_https_url(self) -> None:
+ assert normalize_profile_image_url("https://example.com/img.png") == "https://example.com/img.png"
+
+ def test_valid_http_url(self) -> None:
+ assert normalize_profile_image_url("http://example.com/img.png") == "http://example.com/img.png"
+
+ def test_returns_none_for_none(self) -> None:
+ assert normalize_profile_image_url(None) is None
+
+ def test_returns_none_for_empty(self) -> None:
+ assert normalize_profile_image_url("") is None
+
+ def test_returns_none_for_whitespace(self) -> None:
+ assert normalize_profile_image_url(" ") is None
+
+ def test_rejects_ftp_scheme(self) -> None:
+ with pytest.raises(ScholarServiceError, match="http"):
+ normalize_profile_image_url("ftp://example.com/img.png")
+
+ def test_rejects_no_scheme(self) -> None:
+ with pytest.raises(ScholarServiceError, match="http"):
+ normalize_profile_image_url("example.com/img.png")
+
+ def test_rejects_url_too_long(self) -> None:
+ long_url = "https://example.com/" + "a" * MAX_IMAGE_URL_LENGTH
+ with pytest.raises(ScholarServiceError, match="characters or fewer"):
+ normalize_profile_image_url(long_url)
+
+ def test_accepts_url_at_max_length(self) -> None:
+ url = "https://example.com/" + "a" * (MAX_IMAGE_URL_LENGTH - len("https://example.com/"))
+ assert len(url) == MAX_IMAGE_URL_LENGTH
+ assert normalize_profile_image_url(url) == url
+
+
+class TestUploadPathSafety:
+ def test_resolve_upload_path_rejects_traversal(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ with pytest.raises(ScholarServiceError, match="Invalid"):
+ _resolve_upload_path(root, "../../../etc/passwd")
+
+ def test_resolve_upload_path_accepts_valid_relative(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ result = _resolve_upload_path(root, "scholar_img.png")
+ assert result.name == "scholar_img.png"
+ assert root in result.parents or root == result.parent
+
+ def test_ensure_upload_root_creates_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ new_dir = os.path.join(tmpdir, "uploads", "images")
+ root = _ensure_upload_root(new_dir, create=True)
+ assert root.exists()
+
+ def test_safe_remove_upload_removes_existing_file(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ test_file = root / "test.png"
+ test_file.write_bytes(b"fake image")
+ assert test_file.exists()
+ _safe_remove_upload(root, "test.png")
+ assert not test_file.exists()
+
+ def test_safe_remove_upload_ignores_missing_file(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ _safe_remove_upload(root, "nonexistent.png")
+
+ def test_safe_remove_upload_ignores_none(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ _safe_remove_upload(root, None)
+
+ def test_safe_remove_upload_ignores_traversal_path(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = _ensure_upload_root(tmpdir, create=False)
+ _safe_remove_upload(root, "../../../etc/passwd")
+
+ def test_resolve_upload_file_path(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ result = resolve_upload_file_path(upload_dir=tmpdir, relative_path="img.png")
+ assert result.name == "img.png"
diff --git a/tests/unit/test_scholars_create_hydration.py b/tests/unit/test_scholars_create_hydration.py
new file mode 100644
index 0000000..0694b32
--- /dev/null
+++ b/tests/unit/test_scholars_create_hydration.py
@@ -0,0 +1,144 @@
+from types import SimpleNamespace
+
+import pytest
+
+from app.api.routers import scholar_helpers
+
+
+class _FakeSession:
+ def __init__(self) -> None:
+ self.commits = 0
+ self.rollbacks = 0
+
+ async def commit(self) -> None:
+ self.commits += 1
+
+ async def rollback(self) -> None:
+ self.rollbacks += 1
+
+
+@pytest.mark.asyncio
+async def test_create_hydration_skips_when_global_throttle_active(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ profile = SimpleNamespace(
+ id=42,
+ profile_image_url=None,
+ display_name="",
+ )
+
+ async def _fail_hydration(*_args, **_kwargs):
+ raise AssertionError("hydrate_profile_metadata should not run when throttle is active")
+
+ monkeypatch.setattr(
+ scholar_helpers.scholar_rate_limit,
+ "remaining_scholar_slot_seconds",
+ lambda **_kwargs: 3.5,
+ )
+ monkeypatch.setattr(
+ scholar_helpers.scholar_service,
+ "hydrate_profile_metadata",
+ _fail_hydration,
+ )
+
+ result = await scholar_helpers.hydrate_scholar_metadata_if_needed(
+ db_session=None,
+ profile=profile,
+ source=object(),
+ user_id=7,
+ )
+
+ assert result is profile
+
+
+@pytest.mark.asyncio
+async def test_create_hydration_runs_when_throttle_is_clear(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ profile = SimpleNamespace(
+ id=84,
+ profile_image_url=None,
+ display_name="",
+ )
+
+ async def _hydrate_profile_metadata(*_args, **_kwargs):
+ profile.display_name = "Ada Lovelace"
+ return profile
+
+ monkeypatch.setattr(
+ scholar_helpers.scholar_rate_limit,
+ "remaining_scholar_slot_seconds",
+ lambda **_kwargs: 0.0,
+ )
+ monkeypatch.setattr(
+ scholar_helpers.scholar_service,
+ "hydrate_profile_metadata",
+ _hydrate_profile_metadata,
+ )
+
+ result = await scholar_helpers.hydrate_scholar_metadata_if_needed(
+ db_session=None,
+ profile=profile,
+ source=object(),
+ user_id=8,
+ )
+
+ assert result.display_name == "Ada Lovelace"
+
+
+@pytest.mark.asyncio
+async def test_initial_scrape_job_enqueued_on_create(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ profile = SimpleNamespace(id=101)
+ session = _FakeSession()
+ upsert_calls: list[dict[str, object]] = []
+
+ async def _fake_upsert_job(*_args, **kwargs):
+ upsert_calls.append(kwargs)
+
+ monkeypatch.setattr(
+ scholar_helpers,
+ "auto_enqueue_new_scholar_enabled",
+ lambda: True,
+ )
+ monkeypatch.setattr(
+ scholar_helpers.ingestion_queue_service,
+ "upsert_job",
+ _fake_upsert_job,
+ )
+
+ queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar(
+ session,
+ profile=profile,
+ user_id=9,
+ )
+
+ assert queued is True
+ assert session.commits == 1
+ assert session.rollbacks == 0
+ assert upsert_calls[0]["reason"] == scholar_helpers.INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON
+
+
+@pytest.mark.asyncio
+async def test_initial_scrape_job_not_enqueued_when_disabled(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ profile = SimpleNamespace(id=202)
+ session = _FakeSession()
+
+ monkeypatch.setattr(
+ scholar_helpers,
+ "auto_enqueue_new_scholar_enabled",
+ lambda: False,
+ )
+
+ queued = await scholar_helpers.enqueue_initial_scrape_job_for_scholar(
+ session,
+ profile=profile,
+ user_id=11,
+ )
+
+ assert queued is False
+ assert session.commits == 0
+ assert session.rollbacks == 0
diff --git a/tests/unit/test_unpaywall_pdf_discovery.py b/tests/unit/test_unpaywall_pdf_discovery.py
index 16e1c27..2695fb8 100644
--- a/tests/unit/test_unpaywall_pdf_discovery.py
+++ b/tests/unit/test_unpaywall_pdf_discovery.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
-from app.services.domains.unpaywall import pdf_discovery
+from app.services.unpaywall import pdf_discovery
def test_looks_like_pdf_url_detects_path_and_query_variants() -> None:
@@ -61,7 +61,7 @@ class _FakeClient:
def __init__(self, pages: dict[str, _FakeResponse]) -> None:
self._pages = pages
- async def get(self, url: str, follow_redirects: bool = True): # noqa: ARG002
+ async def get(self, url: str, follow_redirects: bool = True):
return self._pages[url]
@@ -69,7 +69,7 @@ class _FakeClient:
async def test_resolve_pdf_from_landing_page_follows_one_hop_html_candidate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- async def _skip_wait(*args, **kwargs): # noqa: ARG001, ANN002
+ async def _skip_wait(*args, **kwargs):
return None
monkeypatch.setattr(pdf_discovery, "wait_for_unpaywall_slot", _skip_wait)
@@ -81,7 +81,7 @@ async def test_resolve_pdf_from_landing_page_follows_one_hop_html_candidate(
landing_url: _FakeResponse(
status_code=200,
content_type="text/html",
- text=f"View article ",
+ text=f'View article ',
),
hop_url: _FakeResponse(
status_code=200,
diff --git a/tests/unit/test_unpaywall_resolution.py b/tests/unit/test_unpaywall_resolution.py
index 5cc34ce..37b4764 100644
--- a/tests/unit/test_unpaywall_resolution.py
+++ b/tests/unit/test_unpaywall_resolution.py
@@ -1,11 +1,13 @@
from __future__ import annotations
-from datetime import datetime, timezone
+from dataclasses import replace
+from datetime import UTC, datetime
import pytest
-from app.services.domains.publications.types import PublicationListItem
-from app.services.domains.unpaywall import application as unpaywall_app
+from app.services.publication_identifiers.types import DisplayIdentifier
+from app.services.publications.types import PublicationListItem
+from app.services.unpaywall import application as unpaywall_app
class _DummyAsyncClient:
@@ -30,14 +32,46 @@ def _item(publication_id: int) -> PublicationListItem:
citation_count=1000,
venue_text="Cell",
pub_url="https://doi.org/10.1016/j.cell.2007.11.019",
- doi=None,
+ display_identifier=None,
pdf_url=None,
is_read=False,
- first_seen_at=datetime.now(timezone.utc),
+ first_seen_at=datetime.now(UTC),
is_new_in_latest_run=True,
)
+def test_publication_doi_uses_stored_value_when_metadata_has_no_doi() -> None:
+ item = replace(
+ _item(99),
+ pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:123",
+ venue_text="Cell 130 (5), 2007",
+ display_identifier=DisplayIdentifier(
+ kind="doi",
+ value="10.1016/j.cell.2007.11.019",
+ label="DOI",
+ url=None,
+ confidence_score=1.0,
+ ),
+ )
+ assert unpaywall_app._publication_doi(item) == "10.1016/j.cell.2007.11.019"
+
+
+def test_publication_doi_prefers_explicit_metadata_doi_over_stored_value() -> None:
+ item = replace(
+ _item(100),
+ pub_url="https://doi.org/10.2000/fresh-value",
+ venue_text="Cell",
+ display_identifier=DisplayIdentifier(
+ kind="doi",
+ value="10.1000/stale-value",
+ label="DOI",
+ url=None,
+ confidence_score=1.0,
+ ),
+ )
+ assert unpaywall_app._publication_doi(item) == "10.2000/fresh-value"
+
+
@pytest.mark.asyncio
async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkeypatch: pytest.MonkeyPatch) -> None:
payload = {
@@ -49,7 +83,7 @@ async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkey
}
async def _fake_resolve_item_payload(**_kwargs):
- return payload, False
+ return payload, False, "10.1016/j.cell.2007.11.019"
async def _fail_crawl(_client, *, page_url: str):
raise AssertionError(f"unexpected landing crawl: {page_url}")
@@ -75,7 +109,7 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct(
crawled_pages: list[str] = []
async def _fake_resolve_item_payload(**_kwargs):
- return payload, False
+ return payload, False, "10.1016/j.cell.2007.11.019"
async def _fake_crawl(_client, *, page_url: str):
crawled_pages.append(page_url)
@@ -87,3 +121,22 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct(
resolved = await unpaywall_app.resolve_publication_oa_metadata([_item(2)], request_email="user@example.com")
assert resolved == {2: ("10.1016/j.cell.2007.11.019", "https://oa.example.org/files/paper-42.pdf")}
assert "https://oa.example.org/landing/42" in crawled_pages
+
+
+@pytest.mark.asyncio
+async def test_unpaywall_preserves_crossref_doi_when_unpaywall_has_no_record(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_resolve_item_payload(**_kwargs):
+ return None, True, "10.2000/crossref-only"
+
+ monkeypatch.setattr(unpaywall_app, "_resolve_item_payload", _fake_resolve_item_payload)
+ monkeypatch.setattr("httpx.AsyncClient", _DummyAsyncClient)
+
+ outcomes = await unpaywall_app.resolve_publication_oa_outcomes([_item(3)], request_email="user@example.com")
+
+ outcome = outcomes[3]
+ assert outcome.doi == "10.2000/crossref-only"
+ assert outcome.pdf_url is None
+ assert outcome.failure_reason == unpaywall_app.FAILURE_NO_RECORD
+ assert outcome.used_crossref is True
diff --git a/tests/unit/test_user_settings.py b/tests/unit/test_user_settings.py
index 83b1bc0..05d8607 100644
--- a/tests/unit/test_user_settings.py
+++ b/tests/unit/test_user_settings.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
-from app.services.domains.settings.application import (
+from app.services.settings.application import (
DEFAULT_NAV_VISIBLE_PAGES,
HARD_MIN_REQUEST_DELAY_SECONDS,
HARD_MIN_RUN_INTERVAL_MINUTES,
@@ -22,7 +22,7 @@ def test_parse_run_interval_minutes_accepts_valid_value() -> None:
def test_parse_run_interval_minutes_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Check interval must be at least 15 minutes.",
+ match=r"Check interval must be at least 15 minutes.",
):
parse_run_interval_minutes("14")
@@ -34,7 +34,7 @@ def test_parse_request_delay_seconds_accepts_valid_value() -> None:
def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Request delay must be at least 2 seconds.",
+ match=r"Request delay must be at least 2 seconds.",
):
parse_request_delay_seconds("1")
@@ -42,7 +42,7 @@ def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
def test_parse_run_interval_minutes_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Check interval must be at least 30 minutes.",
+ match=r"Check interval must be at least 30 minutes.",
):
parse_run_interval_minutes("29", minimum=30)
@@ -50,7 +50,7 @@ def test_parse_run_interval_minutes_rejects_below_configured_minimum() -> None:
def test_parse_request_delay_seconds_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Request delay must be at least 8 seconds.",
+ match=r"Request delay must be at least 8 seconds.",
):
parse_request_delay_seconds("7", minimum=8)
@@ -82,7 +82,7 @@ def test_parse_nav_visible_pages_accepts_valid_pages() -> None:
def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Dashboard, Scholars, and Settings must remain visible.",
+ match=r"Dashboard, Scholars, and Settings must remain visible.",
):
parse_nav_visible_pages(["dashboard", "publications"])
@@ -90,6 +90,6 @@ def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
def test_parse_nav_visible_pages_rejects_unknown_page() -> None:
with pytest.raises(
UserSettingsServiceError,
- match="Unsupported navigation page id: reports",
+ match=r"Unsupported navigation page id: reports",
):
- parse_nav_visible_pages(DEFAULT_NAV_VISIBLE_PAGES + ["reports"])
+ parse_nav_visible_pages([*DEFAULT_NAV_VISIBLE_PAGES, "reports"])
diff --git a/tests/unit/test_user_validation.py b/tests/unit/test_user_validation.py
new file mode 100644
index 0000000..dddc7f4
--- /dev/null
+++ b/tests/unit/test_user_validation.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import pytest
+
+from app.services.users.application import (
+ UserServiceError,
+ normalize_email,
+ validate_email,
+ validate_password,
+)
+
+
+class TestNormalizeEmail:
+ def test_lowercases_and_strips(self) -> None:
+ assert normalize_email(" Alice@Example.COM ") == "alice@example.com"
+
+ def test_already_normalized(self) -> None:
+ assert normalize_email("user@example.com") == "user@example.com"
+
+
+class TestValidateEmail:
+ def test_valid_email(self) -> None:
+ assert validate_email("user@example.com") == "user@example.com"
+
+ def test_strips_and_lowercases(self) -> None:
+ assert validate_email(" User@Example.COM ") == "user@example.com"
+
+ def test_rejects_missing_at(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email("userexample.com")
+
+ def test_rejects_missing_domain(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email("user@")
+
+ def test_rejects_missing_tld(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email("user@example")
+
+ def test_rejects_empty_string(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email("")
+
+ def test_rejects_whitespace_only(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email(" ")
+
+ def test_rejects_spaces_in_local_part(self) -> None:
+ with pytest.raises(UserServiceError, match="valid email"):
+ validate_email("us er@example.com")
+
+ def test_accepts_plus_addressing(self) -> None:
+ assert validate_email("user+tag@example.com") == "user+tag@example.com"
+
+ def test_accepts_dots_in_local(self) -> None:
+ assert validate_email("first.last@example.com") == "first.last@example.com"
+
+
+class TestValidatePassword:
+ def test_valid_password(self) -> None:
+ assert validate_password("securepass") == "securepass"
+
+ def test_exactly_8_characters(self) -> None:
+ assert validate_password("12345678") == "12345678"
+
+ def test_rejects_7_characters(self) -> None:
+ with pytest.raises(UserServiceError, match="at least 8"):
+ validate_password("1234567")
+
+ def test_rejects_empty(self) -> None:
+ with pytest.raises(UserServiceError, match="at least 8"):
+ validate_password("")
+
+ def test_strips_whitespace(self) -> None:
+ assert validate_password(" securepass ") == "securepass"
+
+ def test_whitespace_only_too_short(self) -> None:
+ with pytest.raises(UserServiceError, match="at least 8"):
+ validate_password(" ")
diff --git a/uv.lock b/uv.lock
index ac9d550..cc622b5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -253,6 +253,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
]
+[[package]]
+name = "click-option-group"
+version = "0.5.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553 },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -285,6 +297,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
]
+[[package]]
+name = "deprecated"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298 },
+]
+
+[[package]]
+name = "dotty-dict"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014 },
+]
+
[[package]]
name = "executing"
version = "2.2.1"
@@ -308,6 +341,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670 },
]
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.46"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620 },
+]
+
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -426,6 +483,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
]
+[[package]]
+name = "importlib-resources"
+version = "6.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461 },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -476,6 +542,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 },
]
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
+]
+
+[[package]]
+name = "librt"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 },
+ { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 },
+ { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 },
+ { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 },
+ { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 },
+ { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 },
+ { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 },
+ { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 },
+ { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 },
+ { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 },
+ { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 },
+ { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 },
+ { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 },
+ { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 },
+ { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 },
+ { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 },
+ { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 },
+ { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 },
+ { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 },
+ { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 },
+ { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 },
+ { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 },
+ { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 },
+ { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 },
+ { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 },
+ { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 },
+ { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 },
+ { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 },
+ { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 },
+ { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 },
+ { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 },
+ { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 },
+ { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 },
+ { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 },
+ { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 },
+ { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 },
+ { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 },
+ { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 },
+ { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 },
+ { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 },
+ { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 },
+ { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 },
+ { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 },
+ { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 },
+ { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 },
+ { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 },
+ { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 },
+ { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 },
+ { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 },
+ { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 },
+ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 },
+]
+
[[package]]
name = "mako"
version = "1.3.10"
@@ -488,6 +626,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
]
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -563,6 +713,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 },
]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
+]
+
+[[package]]
+name = "mypy"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053 },
+ { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134 },
+ { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616 },
+ { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847 },
+ { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976 },
+ { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104 },
+ { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927 },
+ { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730 },
+ { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581 },
+ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252 },
+ { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848 },
+ { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510 },
+ { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744 },
+ { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815 },
+ { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047 },
+ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998 },
+ { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476 },
+ { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872 },
+ { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239 },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
+]
+
[[package]]
name = "packaging"
version = "26.0"
@@ -581,6 +782,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894 },
]
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 },
+]
+
[[package]]
name = "pexpect"
version = "4.9.0"
@@ -769,6 +979,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
]
+[[package]]
+name = "python-gitlab"
+version = "5.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d6/08/d35d0f28549c43611e942f39b6321a7b35c02189d5badceefe601c0207ce/python_gitlab-5.6.0.tar.gz", hash = "sha256:bc531e8ba3e5641b60409445d4919ace68a2c18cb0ec6d48fbced6616b954166", size = 396148 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/5e/2e1ed7145835afaad87664b2f675a1d7b6e1211ad6ecfe57e200ae45f0bd/python_gitlab-5.6.0-py3-none-any.whl", hash = "sha256:68980cd70929fc7f8f06d8a7b09bd046a6b79e1995c19d61249f046005099100", size = 148836 },
+]
+
[[package]]
name = "python-multipart"
version = "0.0.22"
@@ -778,6 +1001,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 },
]
+[[package]]
+name = "python-semantic-release"
+version = "9.21.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "click-option-group" },
+ { name = "deprecated" },
+ { name = "dotty-dict" },
+ { name = "gitpython" },
+ { name = "importlib-resources" },
+ { name = "jinja2" },
+ { name = "pydantic" },
+ { name = "python-gitlab" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f7/ae/3ac172d9cb71dcf25be15c727ce84b3b16f31df98e8015a0a73fe3e29e1c/python_semantic_release-9.21.1.tar.gz", hash = "sha256:b5c509a573899e88e8f29504d2f83e9ddab9a66af861ec1baf39f2b86bbf3517", size = 308463 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/af/b7c6e77142586c4809cb93b636f62cecda73fa543ec4bd00a89eb3e1d4f3/python_semantic_release-9.21.1-py3-none-any.whl", hash = "sha256:e69afe5100106390eec9e800132c947ed774bdcf9aa8f0df29589ea9ef375a21", size = 132726 },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -824,6 +1071,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
+[[package]]
+name = "rapidfuzz"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306 },
+ { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788 },
+ { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580 },
+ { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947 },
+ { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872 },
+ { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512 },
+ { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398 },
+ { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416 },
+ { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527 },
+ { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989 },
+ { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161 },
+ { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646 },
+ { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512 },
+ { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571 },
+ { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759 },
+ { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067 },
+ { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775 },
+ { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123 },
+ { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874 },
+ { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972 },
+ { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011 },
+ { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744 },
+ { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702 },
+ { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702 },
+ { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337 },
+ { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563 },
+ { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727 },
+ { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349 },
+ { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596 },
+ { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595 },
+ { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773 },
+ { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797 },
+ { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940 },
+ { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086 },
+ { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993 },
+ { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126 },
+ { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304 },
+ { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207 },
+ { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245 },
+ { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308 },
+ { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011 },
+ { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245 },
+ { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856 },
+ { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490 },
+ { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658 },
+ { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742 },
+ { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810 },
+ { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349 },
+ { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994 },
+ { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919 },
+ { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346 },
+ { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105 },
+ { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465 },
+ { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491 },
+ { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487 },
+]
+
[[package]]
name = "requests"
version = "2.32.5"
@@ -839,6 +1149,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
]
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 },
+]
+
+[[package]]
+name = "rich"
+version = "14.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333 },
+ { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356 },
+ { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434 },
+ { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456 },
+ { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772 },
+ { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051 },
+ { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494 },
+ { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221 },
+ { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459 },
+ { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366 },
+ { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887 },
+ { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939 },
+ { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471 },
+ { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382 },
+ { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664 },
+ { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048 },
+ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776 },
+]
+
[[package]]
name = "scholarr"
version = "0.1.0"
@@ -852,14 +1212,19 @@ dependencies = [
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "python-multipart" },
+ { name = "rapidfuzz" },
{ name = "sqlalchemy" },
+ { name = "tenacity" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
+ { name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
+ { name = "python-semantic-release" },
+ { name = "ruff" },
]
[package.metadata]
@@ -871,14 +1236,37 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.116,<0.117" },
{ name = "httpx", specifier = ">=0.28,<0.29" },
{ name = "itsdangerous", specifier = ">=2.2,<3.0" },
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<9.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25,<0.26" },
{ name = "python-multipart", specifier = ">=0.0.9,<0.1" },
+ { name = "python-semantic-release", marker = "extra == 'dev'", specifier = ">=9.0,<10.0" },
+ { name = "rapidfuzz", specifier = ">=3.14.3" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" },
{ name = "sqlalchemy", specifier = ">=2.0,<2.1" },
+ { name = "tenacity", specifier = ">=9.1.4" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<0.35" },
]
provides-extras = ["dev"]
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.46"
@@ -948,6 +1336,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736 },
]
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 },
+]
+
[[package]]
name = "traitlets"
version = "5.14.3"
@@ -1166,3 +1572,57 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 },
]
+
+[[package]]
+name = "wrapt"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/cb/4d5255d19bbd12be7f8ee2c1fb4269dddec9cef777ef17174d357468efaa/wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02", size = 61143 },
+ { url = "https://files.pythonhosted.org/packages/6f/07/7ed02daa35542023464e3c8b7cb937fa61f6c61c0361ecf8f5fecf8ad8da/wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f", size = 61740 },
+ { url = "https://files.pythonhosted.org/packages/c4/60/a237a4e4a36f6d966061ccc9b017627d448161b19e0a3ab80a7c7c97f859/wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7", size = 121327 },
+ { url = "https://files.pythonhosted.org/packages/ae/fe/9139058a3daa8818fc67e6460a2340e8bbcf3aef8b15d0301338bbe181ca/wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64", size = 122903 },
+ { url = "https://files.pythonhosted.org/packages/91/10/b8479202b4164649675846a531763531f0a6608339558b5a0a718fc49a8d/wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36", size = 121333 },
+ { url = "https://files.pythonhosted.org/packages/5f/75/75fc793b791d79444aca2c03ccde64e8b99eda321b003f267d570b7b0985/wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825", size = 120458 },
+ { url = "https://files.pythonhosted.org/packages/d7/8f/c3f30d511082ca6d947c405f9d8f6c8eaf83cfde527c439ec2c9a30eb5ea/wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833", size = 58086 },
+ { url = "https://files.pythonhosted.org/packages/0a/c8/37625b643eea2849f10c3b90f69c7462faa4134448d4443234adaf122ae5/wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd", size = 60328 },
+ { url = "https://files.pythonhosted.org/packages/ce/79/56242f07572d5682ba8065a9d4d9c2218313f576e3c3471873c2a5355ffd/wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352", size = 58722 },
+ { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150 },
+ { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743 },
+ { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341 },
+ { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947 },
+ { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370 },
+ { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465 },
+ { url = "https://files.pythonhosted.org/packages/55/90/65be41e40845d951f714b5a77e84f377a3787b1e8eee6555a680da6d0db5/wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06", size = 58090 },
+ { url = "https://files.pythonhosted.org/packages/5f/66/6a09e0294c4fc8c26028a03a15191721c9271672467cc33e6617ee0d91d2/wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1", size = 60341 },
+ { url = "https://files.pythonhosted.org/packages/7a/f0/20ceb8b701e9a71555c87a5ddecbed76ec16742cf1e4b87bbaf26735f998/wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0", size = 58731 },
+ { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969 },
+ { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606 },
+ { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536 },
+ { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697 },
+ { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563 },
+ { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161 },
+ { url = "https://files.pythonhosted.org/packages/38/ec/67c90a7082f452964b4621e4890e9a490f1add23cdeb7483cc1706743291/wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b", size = 59783 },
+ { url = "https://files.pythonhosted.org/packages/ec/08/466afe4855847d8febdfa2c57c87e991fc5820afbdef01a273683dfd15a0/wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83", size = 63082 },
+ { url = "https://files.pythonhosted.org/packages/9a/62/60b629463c28b15b1eeadb3a0691e17568622b12aa5bfa7ebe9b514bfbeb/wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c", size = 60251 },
+ { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311 },
+ { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805 },
+ { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308 },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688 },
+ { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115 },
+ { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484 },
+ { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514 },
+ { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763 },
+ { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051 },
+ { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059 },
+ { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618 },
+ { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544 },
+ { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700 },
+ { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561 },
+ { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188 },
+ { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425 },
+ { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855 },
+ { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443 },
+ { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886 },
+]