| 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 ---
-
- let previousRunStatusKey: string | null = null;
-
- watch(
- () => runStatus.latestRun,
- async (nextRun) => {
- const nextStatus = nextRun ? `${nextRun.id}:${nextRun.status}` : null;
- if (nextStatus === previousRunStatusKey) return;
- previousRunStatusKey = nextStatus;
- const isActive = nextRun && (nextRun.status === "running" || nextRun.status === "resolving");
- if (!isActive) 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 a689ba9..2eddfbb 100644
--- a/frontend/src/features/publications/index.ts
+++ b/frontend/src/features/publications/index.ts
@@ -1,21 +1,6 @@
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;
@@ -26,7 +11,7 @@ export interface PublicationItem {
citation_count: number;
venue_text: string | null;
pub_url: string | null;
- display_identifier: DisplayIdentifier | null;
+ doi: string | null;
pdf_url: string | null;
pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed";
pdf_attempt_count: number;
@@ -50,7 +35,6 @@ export interface PublicationsResult {
total_count: number;
page: number;
page_size: number;
- snapshot: string;
has_next: boolean;
has_prev: boolean;
publications: PublicationItem[];
@@ -60,12 +44,8 @@ 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 {
@@ -85,24 +65,12 @@ 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 735776d..97d49c6 100644
--- a/frontend/src/features/runs/index.ts
+++ b/frontend/src/features/runs/index.ts
@@ -145,13 +145,6 @@ 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
deleted file mode 100644
index 7b25632..0000000
--- a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-// @vitest-environment happy-dom
-import { describe, expect, it } from "vitest";
-import { mount } from "@vue/test-utils";
-import ScholarBatchAdd from "./ScholarBatchAdd.vue";
-import {
- parseScholarIds,
- parseScholarTokens,
- extractScholarIdFromUrl,
- validateTokenAsId,
-} from "./scholar-batch-parsing";
-
-const defaultProps = { saving: false, loading: false };
-
-describe("parseScholarIds (unit)", () => {
- it("parses a single bare ID", () => {
- expect(parseScholarIds("A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("parses multiple IDs separated by commas", () => {
- expect(parseScholarIds("A-UbBTPM15wL, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
- });
-
- it("deduplicates IDs", () => {
- expect(parseScholarIds("A-UbBTPM15wL\nA-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("extracts ID from standard Google Scholar URL", () => {
- expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("extracts ID from URL with trailing slash", () => {
- expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL/")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("extracts ID from URL with fragment", () => {
- expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL#section")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("extracts ID from URL with extra query params", () => {
- expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&view_op=list_works&sortby=pubdate")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("handles URL-encoded characters in non-ID parts", () => {
- expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&label=%E4%B8%AD%E6%96%87")).toEqual(["A-UbBTPM15wL"]);
- });
-
- it("rejects malformed IDs (too short)", () => {
- expect(parseScholarIds("ABC123")).toEqual([]);
- });
-
- it("rejects malformed IDs (too long)", () => {
- expect(parseScholarIds("ABCDEF1234567")).toEqual([]);
- });
-
- it("rejects IDs with special characters", () => {
- expect(parseScholarIds("ABCDEF12345!")).toEqual([]);
- });
-
- it("rejects IDs with embedded whitespace", () => {
- expect(parseScholarIds("ABC DEF12345")).toEqual([]);
- });
-
- it("handles mixed valid and invalid tokens", () => {
- expect(parseScholarIds("A-UbBTPM15wL, invalid, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
- });
-
- it("returns empty array for empty string", () => {
- expect(parseScholarIds("")).toEqual([]);
- });
-
- it("returns empty array for whitespace only", () => {
- expect(parseScholarIds(" ")).toEqual([]);
- });
-});
-
-describe("extractScholarIdFromUrl", () => {
- it("returns null for non-URL strings", () => {
- expect(extractScholarIdFromUrl("not-a-url")).toBeNull();
- });
-
- it("returns null for URL without user param", () => {
- expect(extractScholarIdFromUrl("https://scholar.google.com/citations?hl=en")).toBeNull();
- });
-
- it("extracts from URL with trailing slashes", () => {
- expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL///")).toBe("A-UbBTPM15wL");
- });
-
- it("strips fragment before parsing", () => {
- expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL#foo")).toBe("A-UbBTPM15wL");
- });
-
- it("returns null when user param is invalid length", () => {
- expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=short")).toBeNull();
- });
-});
-
-describe("validateTokenAsId", () => {
- it("returns null for valid 12-char ID", () => {
- expect(validateTokenAsId("ABCDEF123456")).toBeNull();
- });
-
- it("detects wrong length", () => {
- expect(validateTokenAsId("ABC")).toContain("12 characters");
- });
-
- it("detects invalid characters", () => {
- expect(validateTokenAsId("ABCDEF12345!")).toContain("invalid characters");
- });
-
- it("detects whitespace", () => {
- expect(validateTokenAsId("ABC DEF12345")).toContain("whitespace");
- });
-
- it("detects empty input", () => {
- expect(validateTokenAsId("")).toContain("empty");
- });
-});
-
-describe("parseScholarTokens (per-token errors)", () => {
- it("marks invalid tokens with error messages", () => {
- const tokens = parseScholarTokens("A-UbBTPM15wL, short, B-UbBTPM15wL");
- expect(tokens).toHaveLength(3);
- expect(tokens[0].id).toBe("A-UbBTPM15wL");
- expect(tokens[0].error).toBeNull();
- expect(tokens[1].id).toBeNull();
- expect(tokens[1].error).toContain("12 characters");
- expect(tokens[2].id).toBe("B-UbBTPM15wL");
- });
-
- it("marks duplicate tokens", () => {
- const tokens = parseScholarTokens("A-UbBTPM15wL, A-UbBTPM15wL");
- expect(tokens[1].error).toBe("duplicate");
- });
-
- it("marks bad URL tokens", () => {
- const tokens = parseScholarTokens("https://scholar.google.com/citations?hl=en");
- expect(tokens[0].error).toContain("could not extract");
- });
-});
-
-describe("ScholarBatchAdd (component)", () => {
- 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...");
- });
-
- it("shows validation errors for invalid tokens", async () => {
- const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
- await wrapper.find("textarea").setValue("A-UbBTPM15wL, bad!");
- const errors = wrapper.find("[data-testid='validation-errors']");
- expect(errors.exists()).toBe(true);
- });
-
- it("shows skipped count alongside valid count", async () => {
- const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
- await wrapper.find("textarea").setValue("A-UbBTPM15wL, tooshort");
- expect(wrapper.text()).toContain("1 valid");
- expect(wrapper.text()).toContain("1 skipped");
- });
-});
diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.vue b/frontend/src/features/scholars/components/ScholarBatchAdd.vue
deleted file mode 100644
index 60f89b9..0000000
--- a/frontend/src/features/scholars/components/ScholarBatchAdd.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
-
-
-
-
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
deleted file mode 100644
index 56873f1..0000000
--- a/frontend/src/features/scholars/components/ScholarNameSearch.test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-// @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
deleted file mode 100644
index 4a82af2..0000000
--- a/frontend/src/features/scholars/components/ScholarNameSearch.vue
+++ /dev/null
@@ -1,167 +0,0 @@
-
-
-
-
-
-
-
-
- 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
deleted file mode 100644
index 6a3830b..0000000
--- a/frontend/src/features/scholars/components/ScholarSettingsModal.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-// @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
deleted file mode 100644
index 77af0ac..0000000
--- a/frontend/src/features/scholars/components/ScholarSettingsModal.vue
+++ /dev/null
@@ -1,120 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ scholarLabel(scholar) }}
- Pending
-
-
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/components/ScholarStatusBadges.vue b/frontend/src/features/scholars/components/ScholarStatusBadges.vue
deleted file mode 100644
index 86b2cd5..0000000
--- a/frontend/src/features/scholars/components/ScholarStatusBadges.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
- Pending
- Failed
- Partial
- OK
-
diff --git a/frontend/src/features/scholars/components/scholar-batch-parsing.ts b/frontend/src/features/scholars/components/scholar-batch-parsing.ts
deleted file mode 100644
index 45baba7..0000000
--- a/frontend/src/features/scholars/components/scholar-batch-parsing.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
-
-export interface ParsedToken {
- index: number;
- raw: string;
- id: string | null;
- error: string | null;
-}
-
-export function extractScholarIdFromUrl(token: string): string | null {
- const cleaned = token.replace(/\/+$/, "").replace(/#.*$/, "");
- try {
- const parsed = new URL(cleaned);
- const userParam = parsed.searchParams.get("user");
- if (!userParam) return null;
- const decoded = decodeURIComponent(userParam).trim();
- if (SCHOLAR_ID_PATTERN.test(decoded)) return decoded;
- return null;
- } catch {
- return null;
- }
-}
-
-export function validateTokenAsId(token: string): string | null {
- const trimmed = token.trim();
- if (!trimmed) return "empty input";
- if (/\s/.test(trimmed)) return "contains whitespace";
- if (trimmed.length !== 12) return `must be 12 characters (got ${trimmed.length})`;
- if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
- return "contains invalid characters (only a-z, A-Z, 0-9, _ and - allowed)";
- }
- return null;
-}
-
-export function parseScholarTokens(raw: string): ParsedToken[] {
- const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
- const results: ParsedToken[] = [];
- const seen = new Set();
-
- for (let i = 0; i < tokens.length; i++) {
- const token = tokens[i];
-
- if (SCHOLAR_ID_PATTERN.test(token)) {
- if (seen.has(token)) {
- results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
- continue;
- }
- seen.add(token);
- results.push({ index: i + 1, raw: token, id: token, error: null });
- continue;
- }
-
- if (token.includes("scholar.google") || token.startsWith("http")) {
- const extracted = extractScholarIdFromUrl(token);
- if (extracted) {
- if (seen.has(extracted)) {
- results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
- continue;
- }
- seen.add(extracted);
- results.push({ index: i + 1, raw: token, id: extracted, error: null });
- continue;
- }
- results.push({ index: i + 1, raw: token, id: null, error: "could not extract scholar ID from URL" });
- continue;
- }
-
- const reason = validateTokenAsId(token);
- results.push({ index: i + 1, raw: token, id: null, error: reason ?? "invalid scholar ID" });
- }
- return results;
-}
-
-export function parseScholarIds(raw: string): string[] {
- return parseScholarTokens(raw).filter((t) => t.id !== null).map((t) => t.id!);
-}
diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts
deleted file mode 100644
index 5a8dff9..0000000
--- a/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-// @vitest-environment happy-dom
-import { describe, expect, it, vi } from "vitest";
-import { nextTick, ref } from "vue";
-import { useScholarBulkActions, type ScholarBulkAction } from "./useScholarBulkActions";
-import type { ScholarProfile } from "@/features/scholars";
-
-vi.mock("@/features/scholars", () => ({
- bulkDeleteScholars: vi.fn(),
- bulkToggleScholars: vi.fn(),
- exportScholarData: vi.fn(),
-}));
-
-function makeProfile(id: number, name: string): ScholarProfile {
- return {
- id,
- scholar_id: `scholar_${id}`,
- display_name: name,
- profile_image_url: null,
- profile_image_source: "none",
- is_enabled: true,
- baseline_completed: false,
- last_run_dt: null,
- last_run_status: null,
- };
-}
-
-function setup(profiles: ScholarProfile[] = []) {
- const visibleScholars = ref(profiles);
- const callbacks = {
- clearMessages: vi.fn(),
- assignError: vi.fn(),
- setSuccess: vi.fn(),
- reloadScholars: vi.fn(async () => {}),
- };
- const bulk = useScholarBulkActions(visibleScholars, callbacks);
- return { visibleScholars, callbacks, bulk };
-}
-
-describe("useScholarBulkActions", () => {
- it("starts with empty selection", () => {
- const { bulk } = setup([makeProfile(1, "Alice")]);
- expect(bulk.selectedIds.value.size).toBe(0);
- expect(bulk.hasSelection.value).toBe(false);
- });
-
- it("toggles individual row selection", () => {
- const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
- bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
- expect(bulk.selectedIds.value.has(1)).toBe(true);
- expect(bulk.selectedCount.value).toBe(1);
-
- bulk.onToggleRow(1, { target: { checked: false } } as unknown as Event);
- expect(bulk.selectedIds.value.has(1)).toBe(false);
- expect(bulk.selectedCount.value).toBe(0);
- });
-
- it("toggles all visible scholars", () => {
- const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
- bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
- expect(bulk.selectedIds.value.size).toBe(2);
- expect(bulk.allVisibleSelected.value).toBe(true);
-
- bulk.onToggleAll({ target: { checked: false } } as unknown as Event);
- expect(bulk.selectedIds.value.size).toBe(0);
- });
-
- it("prunes stale selections when list changes", async () => {
- const { visibleScholars, bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
- bulk.onToggleAll({ target: { checked: true } } as unknown as Event);
- expect(bulk.selectedIds.value.size).toBe(2);
-
- // Remove Bob from the visible list
- visibleScholars.value = [makeProfile(1, "Alice")];
- await nextTick();
-
- expect(bulk.selectedIds.value.size).toBe(1);
- expect(bulk.selectedIds.value.has(1)).toBe(true);
- expect(bulk.selectedIds.value.has(2)).toBe(false);
- });
-
- it("shows correct bulk action options with and without selection", () => {
- const { bulk } = setup([makeProfile(1, "Alice")]);
- // Without selection
- expect(bulk.bulkActionOptions.value.length).toBe(1);
- expect(bulk.bulkActionOptions.value[0].value).toBe("select_all");
-
- // With selection
- bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
- expect(bulk.bulkActionOptions.value.length).toBe(5);
- const values = bulk.bulkActionOptions.value.map((o) => o.value);
- expect(values).toContain("delete_selected");
- expect(values).toContain("enable_selected");
- expect(values).toContain("disable_selected");
- expect(values).toContain("export_selected");
- expect(values).toContain("clear_selection");
- });
-
- it("select all action selects all visible", async () => {
- const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]);
- bulk.bulkAction.value = "select_all" as ScholarBulkAction;
- await bulk.onApplyBulkAction();
- expect(bulk.selectedIds.value.size).toBe(2);
- });
-
- it("clear selection action clears all", async () => {
- const { bulk } = setup([makeProfile(1, "Alice")]);
- bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
- bulk.bulkAction.value = "clear_selection" as ScholarBulkAction;
- await bulk.onApplyBulkAction();
- expect(bulk.selectedIds.value.size).toBe(0);
- });
-
- it("bulk delete calls assignError on network failure", async () => {
- const { bulkDeleteScholars } = await import("@/features/scholars");
- vi.mocked(bulkDeleteScholars).mockRejectedValueOnce(new Error("Network error"));
-
- const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
- bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
- bulk.bulkAction.value = "delete_selected" as ScholarBulkAction;
- await bulk.onApplyBulkAction();
-
- // Trigger the confirm callback
- expect(bulk.confirmState.value.open).toBe(true);
- await bulk.confirmState.value.onConfirm();
-
- expect(callbacks.assignError).toHaveBeenCalledWith(
- expect.any(Error),
- "Unable to bulk delete scholars.",
- );
- expect(bulk.bulkBusy.value).toBe(false);
- });
-
- it("bulk toggle calls assignError on network failure", async () => {
- const { bulkToggleScholars } = await import("@/features/scholars");
- vi.mocked(bulkToggleScholars).mockRejectedValueOnce(new Error("Network error"));
-
- const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
- bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
- bulk.bulkAction.value = "enable_selected" as ScholarBulkAction;
- await bulk.onApplyBulkAction();
-
- expect(callbacks.assignError).toHaveBeenCalledWith(
- expect.any(Error),
- "Unable to bulk enable scholars.",
- );
- expect(bulk.bulkBusy.value).toBe(false);
- });
-});
diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.ts
deleted file mode 100644
index 51cace7..0000000
--- a/frontend/src/features/scholars/composables/useScholarBulkActions.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { computed, ref, watch, type Ref } from "vue";
-
-import {
- bulkDeleteScholars,
- bulkToggleScholars,
- exportScholarData,
- type ScholarProfile,
-} from "@/features/scholars";
-
-export type ScholarBulkAction =
- | "delete_selected"
- | "enable_selected"
- | "disable_selected"
- | "export_selected"
- | "clear_selection"
- | "select_all";
-
-export interface ScholarBulkActionOption {
- value: ScholarBulkAction;
- label: string;
-}
-
-export interface ConfirmState {
- open: boolean;
- title: string;
- message: string;
- variant: "danger" | "default";
- onConfirm: () => void;
-}
-
-export interface BulkActionCallbacks {
- clearMessages: () => void;
- assignError: (error: unknown, fallback: string) => void;
- setSuccess: (msg: string) => void;
- reloadScholars: () => Promise;
-}
-
-export function useScholarBulkActions(
- visibleScholars: Ref,
- callbacks: BulkActionCallbacks,
-) {
- const selectedIds = ref>(new Set());
- const bulkAction = ref("select_all");
- const bulkBusy = ref(false);
- const confirmState = ref({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
-
- const selectedCount = computed(() => selectedIds.value.size);
- const hasSelection = computed(() => selectedCount.value > 0);
-
- const allVisibleSelected = computed(() => {
- if (visibleScholars.value.length === 0) return false;
- for (const item of visibleScholars.value) {
- if (!selectedIds.value.has(item.id)) return false;
- }
- return true;
- });
-
- const bulkActionOptions = computed(() => {
- if (!hasSelection.value) return [{ value: "select_all", label: "Select all" }];
- const n = selectedCount.value;
- return [
- { value: "delete_selected", label: `Delete selected (${n})` },
- { value: "enable_selected", label: `Enable selected (${n})` },
- { value: "disable_selected", label: `Disable selected (${n})` },
- { value: "export_selected", label: `Export selected (${n})` },
- { value: "clear_selection", label: "Clear selection" },
- ];
- });
-
- const bulkApplyLabel = computed(() => {
- if (bulkBusy.value) return "Applying...";
- if (bulkAction.value === "select_all") return "Select";
- if (bulkAction.value === "clear_selection") return "Clear";
- return "Apply";
- });
-
- const bulkApplyDisabled = computed(() => {
- if (bulkBusy.value) return true;
- if (bulkAction.value === "select_all") return visibleScholars.value.length === 0;
- return selectedCount.value === 0;
- });
-
- // Prune stale selections when visible list changes
- watch(visibleScholars, (items) => {
- const validIds = new Set(items.map((item) => item.id));
- const next = new Set();
- for (const id of selectedIds.value) {
- if (validIds.has(id)) next.add(id);
- }
- if (next.size !== selectedIds.value.size) selectedIds.value = next;
- });
-
- // Reset bulk action dropdown when selection state changes
- watch(hasSelection, (has) => {
- const validValues = new Set(bulkActionOptions.value.map((o) => o.value));
- if (validValues.has(bulkAction.value)) return;
- bulkAction.value = has ? "delete_selected" : "select_all";
- });
-
- function onToggleAll(event: Event): void {
- const checked = (event.target as HTMLInputElement).checked;
- const next = new Set(selectedIds.value);
- for (const item of visibleScholars.value) {
- if (checked) { next.add(item.id); } else { next.delete(item.id); }
- }
- selectedIds.value = next;
- }
-
- function onToggleRow(id: number, event: Event): void {
- const checked = (event.target as HTMLInputElement).checked;
- const next = new Set(selectedIds.value);
- if (checked) { next.add(id); } else { next.delete(id); }
- selectedIds.value = next;
- }
-
- function downloadJsonFile(filename: string, payload: unknown): void {
- const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = filename;
- anchor.click();
- URL.revokeObjectURL(url);
- }
-
- async function onApplyBulkAction(): Promise {
- if (bulkApplyDisabled.value) return;
- if (bulkAction.value === "select_all") {
- selectedIds.value = new Set(visibleScholars.value.map((item) => item.id));
- return;
- }
- if (bulkAction.value === "clear_selection") { selectedIds.value = new Set(); return; }
- if (bulkAction.value === "delete_selected") { await onBulkDelete(); return; }
- if (bulkAction.value === "enable_selected") { await onBulkToggle(true); return; }
- if (bulkAction.value === "disable_selected") { await onBulkToggle(false); return; }
- if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
- }
-
- function dismissConfirm(): void {
- confirmState.value = { ...confirmState.value, open: false };
- }
-
- function requestConfirm(title: string, message: string, variant: "danger" | "default", onConfirm: () => void): void {
- confirmState.value = { open: true, title, message, variant, onConfirm };
- }
-
- function onBulkDelete(): void {
- const ids = [...selectedIds.value];
- requestConfirm(
- `Delete ${ids.length} scholar(s)?`,
- "This removes all linked publications and queue data. This action cannot be undone.",
- "danger",
- async () => {
- dismissConfirm();
- bulkBusy.value = true;
- callbacks.clearMessages();
- try {
- const result = await bulkDeleteScholars(ids);
- callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`);
- selectedIds.value = new Set();
- await callbacks.reloadScholars();
- } catch (error) {
- callbacks.assignError(error, "Unable to bulk delete scholars.");
- } finally {
- bulkBusy.value = false;
- }
- },
- );
- }
-
- async function onBulkToggle(isEnabled: boolean): Promise {
- bulkBusy.value = true;
- callbacks.clearMessages();
- try {
- const ids = [...selectedIds.value];
- const result = await bulkToggleScholars(ids, isEnabled);
- const verb = isEnabled ? "enabled" : "disabled";
- callbacks.setSuccess(`${result.updated_count} scholar(s) ${verb}.`);
- selectedIds.value = new Set();
- await callbacks.reloadScholars();
- } catch (error) {
- callbacks.assignError(error, `Unable to bulk ${isEnabled ? "enable" : "disable"} scholars.`);
- } finally {
- bulkBusy.value = false;
- }
- }
-
- async function onBulkExport(): Promise {
- bulkBusy.value = true;
- callbacks.clearMessages();
- try {
- const ids = [...selectedIds.value];
- const payload = await exportScholarData(ids);
- const dateSlug = payload.exported_at.slice(0, 10) || "unknown-date";
- downloadJsonFile(`scholarr-export-${dateSlug}.json`, payload);
- callbacks.setSuccess("Export complete.");
- } catch (error) {
- callbacks.assignError(error, "Unable to export selected scholars.");
- } finally {
- bulkBusy.value = false;
- }
- }
-
- return {
- selectedIds,
- selectedCount,
- hasSelection,
- allVisibleSelected,
- bulkAction,
- bulkBusy,
- bulkActionOptions,
- bulkApplyLabel,
- bulkApplyDisabled,
- confirmState,
- dismissConfirm,
- requestConfirm,
- onToggleAll,
- onToggleRow,
- onApplyBulkAction,
- };
-}
diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts
index f6ad9d3..db19e76 100644
--- a/frontend/src/features/scholars/index.ts
+++ b/frontend/src/features/scholars/index.ts
@@ -54,6 +54,7 @@ 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;
}
@@ -85,7 +86,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" });
@@ -160,30 +161,8 @@ export async function clearScholarImage(
return response.data;
}
-export interface BulkCountResult {
- deleted_count: number;
- updated_count: number;
-}
-
-export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise {
- const response = await apiRequest("/scholars/bulk-delete", {
- method: "POST",
- body: { scholar_profile_ids: scholarProfileIds },
- });
- return response.data;
-}
-
-export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise {
- const response = await apiRequest("/scholars/bulk-toggle", {
- method: "POST",
- body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled },
- });
- return response.data;
-}
-
-export async function exportScholarData(ids?: number[]): Promise {
- const path = ids && ids.length > 0 ? `/scholars/export?ids=${ids.join(",")}` : "/scholars/export";
- const response = await apiRequest(path, {
+export async function exportScholarData(): Promise {
+ const response = await apiRequest("/scholars/export", {
method: "GET",
});
return response.data;
diff --git a/frontend/src/features/settings/SettingsAdminPanel.test.ts b/frontend/src/features/settings/SettingsAdminPanel.test.ts
deleted file mode 100644
index 47a9e96..0000000
--- a/frontend/src/features/settings/SettingsAdminPanel.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-// @vitest-environment happy-dom
-import { describe, expect, it, vi, beforeEach } from "vitest";
-import { mount, flushPromises } from "@vue/test-utils";
-import { nextTick } from "vue";
-import SettingsAdminPanel from "./SettingsAdminPanel.vue";
-
-vi.mock("@/features/admin_users", () => ({
- listAdminUsers: vi.fn().mockResolvedValue([]),
- createAdminUser: vi.fn(),
- setAdminUserActive: vi.fn(),
- resetAdminUserPassword: vi.fn(),
-}));
-
-vi.mock("@/features/admin_dbops", () => ({
- getAdminDbIntegrityReport: vi.fn().mockResolvedValue({
- status: "ok",
- warnings: [],
- failures: [],
- checked_at: null,
- checks: [],
- }),
- listAdminPdfQueue: vi.fn().mockResolvedValue({
- items: [],
- total_count: 0,
- has_next: false,
- has_prev: false,
- page: 1,
- page_size: 50,
- }),
- requeueAdminPdfLookup: vi.fn(),
- requeueAllAdminPdfLookups: vi.fn(),
-}));
-
-vi.mock("@/stores/auth", () => ({
- useAuthStore: () => ({ isAdmin: true }),
-}));
-
-vi.mock("@/features/admin_repairs", () => ({
- listAdminRepairTasks: vi.fn().mockResolvedValue([]),
- runAdminRepairTask: vi.fn(),
-}));
-
-import { listAdminUsers } from "@/features/admin_users";
-import { getAdminDbIntegrityReport, listAdminPdfQueue } from "@/features/admin_dbops";
-
-const mockedListUsers = vi.mocked(listAdminUsers);
-const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
-const mockedListPdfQueue = vi.mocked(listAdminPdfQueue);
-
-describe("SettingsAdminPanel", () => {
- beforeEach(() => {
- mockedListUsers.mockClear();
- mockedGetReport.mockClear();
- mockedListPdfQueue.mockClear();
- });
-
- it("calls load on users section after nextTick when section=users", async () => {
- mount(SettingsAdminPanel, { props: { section: "users" } });
- await nextTick();
- await nextTick();
- await flushPromises();
- expect(mockedListUsers).toHaveBeenCalled();
- });
-
- it("calls load on integrity section after nextTick when section=integrity", async () => {
- mount(SettingsAdminPanel, { props: { section: "integrity" } });
- await nextTick();
- await nextTick();
- await flushPromises();
- expect(mockedGetReport).toHaveBeenCalled();
- });
-
- it("calls load on new section when section prop changes", async () => {
- const wrapper = mount(SettingsAdminPanel, { props: { section: "users" } });
- await nextTick();
- await nextTick();
- await flushPromises();
- mockedListUsers.mockClear();
- mockedGetReport.mockClear();
-
- await wrapper.setProps({ section: "integrity" });
- await nextTick();
- await flushPromises();
- expect(mockedGetReport).toHaveBeenCalled();
- });
-
- it("calls load on pdf section when section=pdf", async () => {
- mount(SettingsAdminPanel, { props: { section: "pdf" } });
- await nextTick();
- await nextTick();
- await flushPromises();
- expect(mockedListPdfQueue).toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue
index d7ee685..7f5788e 100644
--- a/frontend/src/features/settings/SettingsAdminPanel.vue
+++ b/frontend/src/features/settings/SettingsAdminPanel.vue
@@ -1,57 +1,464 @@
@@ -65,9 +472,355 @@ watch(() => props.section, loadSection, { flush: "post" });
@dismiss-success="successMessage = null"
/>
-
-
-
-
+
+
+
+
+
+
+ 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
deleted file mode 100644
index c69ab73..0000000
--- a/frontend/src/features/settings/components/AdminIntegritySection.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// @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
deleted file mode 100644
index 2c536d5..0000000
--- a/frontend/src/features/settings/components/AdminIntegritySection.vue
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index c312922..0000000
--- a/frontend/src/features/settings/components/AdminPdfQueueSection.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-// @vitest-environment happy-dom
-import { describe, expect, it, vi, beforeEach } from "vitest";
-import { mount, flushPromises } from "@vue/test-utils";
-import { setActivePinia, createPinia } from "pinia";
-import { useAuthStore } from "@/stores/auth";
-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(() => {
- setActivePinia(createPinia());
- const auth = useAuthStore();
- auth.$patch({ state: "authenticated", user: { id: 1, email: "admin@test.com", is_admin: true, is_active: true } });
- 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
deleted file mode 100644
index 91ec2a9..0000000
--- a/frontend/src/features/settings/components/AdminPdfQueueSection.vue
+++ /dev/null
@@ -1,217 +0,0 @@
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index c7c4685..0000000
--- a/frontend/src/features/settings/components/AdminRepairsSection.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-// @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
deleted file mode 100644
index 05eab7a..0000000
--- a/frontend/src/features/settings/components/AdminRepairsSection.vue
+++ /dev/null
@@ -1,382 +0,0 @@
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 4926574..0000000
--- a/frontend/src/features/settings/components/AdminUsersSection.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-// @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
deleted file mode 100644
index 18f6408..0000000
--- a/frontend/src/features/settings/components/AdminUsersSection.vue
+++ /dev/null
@@ -1,211 +0,0 @@
-
-
-
-
-
-
-
-
- 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 a1edce7..e8ef325 100644
--- a/frontend/src/features/settings/index.ts
+++ b/frontend/src/features/settings/index.ts
@@ -19,9 +19,6 @@ 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 {
@@ -29,16 +26,6 @@ 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 {
@@ -67,20 +54,3 @@ 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 3f7df35..e61df8d 100644
--- a/frontend/src/pages/DashboardPage.vue
+++ b/frontend/src/pages/DashboardPage.vue
@@ -1,8 +1,7 @@
-
-
-
-
+
+
+
+
+
+
+ All records
+ Unread
+ New (latest run)
+
+
+
+
+
+ Scholar
+
+
+
+ All scholars
+
+ {{ scholarLabel(scholar) }}
+
+
+
+
+
+
+ Search
+
+
+
+
+
+
+
+ {{ favoriteOnly ? "★" : "☆" }}
+
+
+ {{ startRunButtonLabel }}
+
+
+
+
-
Scope: {{ scopeLabel }} | Showing {{ pub.selectedScholarName.value }}
+
Scope: {{ scopeLabel }} | Showing {{ selectedScholarName }}
Page size
@@ -405,7 +919,9 @@ onMounted(() => {
100 / page
200 / page
-
+
Bulk task
{
-
+
@@ -445,80 +961,157 @@ onMounted(() => {
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="pub.visibleUnreadKeys.value.size === 0"
+ :disabled="visibleUnreadKeys.size === 0"
aria-label="Select all visible unread publications"
@change="onToggleAllVisible"
/>
- ★
+
+
+ ★ {{ sortMarker('favorite') }}
+
+
-
- Title {{ pub.sortMarker('title') }}
+
+ Title {{ sortMarker('title') }}
-
- Scholar {{ pub.sortMarker('scholar') }}
-
-
-
-
- PDF {{ pub.sortMarker('pdf_status') }}
+
+ Scholar {{ sortMarker('scholar') }}
+ PDF
-
- Year {{ pub.sortMarker('year') }}
+
+ Year {{ sortMarker('year') }}
-
- Citations {{ pub.sortMarker('citations') }}
+
+ Citations {{ sortMarker('citations') }}
+
+
+
+
+ Read status {{ sortMarker('status') }}
- Read status
-
- First seen {{ pub.sortMarker('first_seen') }}
+
+ First seen {{ 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) }}
+
- total {{ pub.totalCount.value }} · visible {{ pub.visibleCount.value }} · unread {{ pub.visibleUnreadCount.value }} · favorites {{ pub.visibleFavoriteCount.value }}
+ visible {{ visibleCount }} · unread {{ visibleUnreadCount }} · favorites {{ visibleFavoriteCount }}
· selected {{ selectedCount }}
-
page {{ pub.currentPage.value }} / {{ pub.totalPages.value }}
-
+ page {{ currentPage }} / {{ totalPages }}
+
Prev
-
+
Next
-
@@ -530,4 +1123,44 @@ onMounted(() => {
.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 49894b7..711158c 100644
--- a/frontend/src/pages/RunsPage.vue
+++ b/frontend/src/pages/RunsPage.vue
@@ -22,9 +22,7 @@ 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";
@@ -35,10 +33,8 @@ 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) {
@@ -66,7 +62,7 @@ function queueHealth() {
}
const queueCounts = computed(() => queueHealth());
-const activeRunId = computed(() => runStatus.isRunActive && runStatus.latestRun ? runStatus.latestRun.id : null);
+const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
@@ -90,10 +86,6 @@ 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) {
@@ -159,28 +151,6 @@ 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;
@@ -238,22 +208,6 @@ onMounted(() => {
:manual-run-allowed="userSettings.manualRunAllowed"
/>
-
-
-
-
-
-
- Cancel check
-
-
{{ runButtonLabel }}
diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue
index 3899888..4c8fb57 100644
--- a/frontend/src/pages/ScholarsPage.vue
+++ b/frontend/src/pages/ScholarsPage.vue
@@ -1,15 +1,17 @@
@@ -423,12 +628,163 @@ watch(
-
-
+
+
+
+
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.
+
+
+
+
@@ -436,7 +792,9 @@ watch(
Review tracked profiles, filter quickly, and open per-scholar settings when needed.
@@ -444,23 +802,43 @@ watch(
Tracking status
-
+
{{ trackedCountLabel }}
{{ exportingData ? "Exporting..." : "Export" }}
-
+
{{ importingData ? "Importing..." : "Import" }}
-
+
-
+
+
Filter tracked scholars
-
+
Sort
@@ -475,109 +853,77 @@ watch(
-
-
- {{ trackedCountLabel }}
- · {{ bulk.selectedCount.value }} selected
-
-
-
Bulk action
-
-
- {{ option.label }}
-
-
-
- {{ bulk.bulkApplyLabel.value }}
-
-
-
-
-
-
+
+
+
-
-
+
+
-
+
-
- {{ scholarLabel(item) }}
-
-
+
{{ scholarLabel(item) }}
-
Manage
+
+
Manage
+
- { if (activeScholarSettings) imageUrlDraftByScholarId[activeScholarSettings.id] = v; }"
- @save-image-url="onSaveImageUrl"
- @upload-image="onUploadImage"
- @reset-image="onResetImage"
- @toggle="onToggleScholar"
- @delete="onDeleteScholar"
- />
+
+
+
+
-
+
+
+ {{ 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
+
+
+
+
-
-
diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue
index 3fb50fc..a7b00b1 100644
--- a/frontend/src/pages/SettingsPage.vue
+++ b/frontend/src/pages/SettingsPage.vue
@@ -14,12 +14,9 @@ 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";
@@ -33,7 +30,6 @@ 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();
@@ -43,20 +39,12 @@ const router = useRouter();
const loading = ref(true);
const saving = ref(false);
-const savingScholarHttp = ref(false);
const updatingPassword = ref(false);
const autoRunEnabled = ref(false);
-const runIntervalHours = ref("1");
+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("");
@@ -77,14 +65,13 @@ const tabItems = computed(() => {
const tabs: AppTabItem[] = [
{ id: TAB_CHECKING, label: "Checking" },
{ id: TAB_ACCOUNT, label: "Account" },
- { id: TAB_ADMIN_PDF, label: "PDF Queue" },
];
if (auth.isAdmin) {
tabs.push(
{ id: TAB_ADMIN_USERS, label: "Users" },
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
- { id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
+ { id: TAB_ADMIN_PDF, label: "PDF Queue" },
);
}
return tabs;
@@ -138,25 +125,14 @@ function hydrateSettings(settings: UserSettings): void {
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
- runIntervalHours.value = String(Number(settings.run_interval_minutes) / 60);
+ runIntervalMinutes.value = String(settings.run_interval_minutes);
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)) {
@@ -168,23 +144,6 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
return parsed;
}
-function formatHours(minutes: number): string {
- const h = minutes / 60;
- return Number.isInteger(h) ? String(h) : h.toFixed(2).replace(/\.?0+$/, "");
-}
-
-function parseHoursToMinutes(value: string, minMinutes: number): number {
- const hours = Number(value);
- if (!Number.isFinite(hours) || hours <= 0) {
- throw new Error("Check interval must be a positive number of hours.");
- }
- const minutes = Math.round(hours * 60);
- if (minutes < minMinutes) {
- throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`);
- }
- return minutes;
-}
-
async function loadSettings(): Promise {
loading.value = true;
errorMessage.value = null;
@@ -192,9 +151,6 @@ 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;
@@ -207,32 +163,6 @@ 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;
@@ -242,8 +172,9 @@ async function onSaveSettings(): Promise {
try {
const payload: UserSettingsUpdate = {
auto_run_enabled: autoRunEnabled.value,
- run_interval_minutes: parseHoursToMinutes(
- runIntervalHours.value,
+ run_interval_minutes: parseBoundedInteger(
+ runIntervalMinutes.value,
+ "Check interval (minutes)",
minCheckIntervalMinutes.value,
),
request_delay_seconds: parseBoundedInteger(
@@ -252,9 +183,6 @@ 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);
@@ -373,11 +301,11 @@ onMounted(async () => {
- Check interval (hours)
+ Check interval (minutes)
-
- Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours
+
+ Minimum: {{ minCheckIntervalMinutes }}
@@ -429,93 +357,6 @@ 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 53dc875..ec04bf4 100644
--- a/frontend/src/stores/run_status.test.ts
+++ b/frontend/src/stores/run_status.test.ts
@@ -7,10 +7,9 @@ import { createDefaultSafetyState } from "@/features/safety";
vi.mock("@/features/runs", () => ({
listRuns: vi.fn(),
triggerManualRun: vi.fn(),
- cancelRun: vi.fn(),
}));
-import { cancelRun, listRuns, triggerManualRun } from "@/features/runs";
+import { listRuns, triggerManualRun } from "@/features/runs";
import {
RUN_STATUS_POLL_INTERVAL_MS,
RUN_STATUS_STARTING_PHASE_MS,
@@ -49,45 +48,14 @@ 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();
});
@@ -255,125 +223,4 @@ 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 952c607..9c12540 100644
--- a/frontend/src/stores/run_status.ts
+++ b/frontend/src/stores/run_status.ts
@@ -1,46 +1,36 @@
import { defineStore } from "pinia";
-import { listRuns, triggerManualRun, cancelRun, type RunListItem } from "@/features/runs";
+import { listRuns, triggerManualRun, 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 = 15000;
+export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
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;
- };
-
-export type CancelCheckResult =
- | { kind: "success" }
- | { kind: "error"; message: string };
+ kind: "error";
+ message: string;
+ requestId: string | null;
+ };
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)) {
@@ -77,72 +67,6 @@ 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,
@@ -167,17 +91,13 @@ export const useRunStatusStore = defineStore("runStatus", {
lastErrorMessage: null as string | null,
lastErrorRequestId: null as string | null,
lastSyncAt: null as number | null,
- livePublications: [] as Array,
- scholarProgress: null as { visited: number; finished: number; total: number } | null,
}),
getters: {
isRunActive(state): boolean {
- const s = state.latestRun?.status;
- return state.isSubmitting || s === "running" || s === "resolving";
+ return state.isSubmitting || state.latestRun?.status === "running";
},
isLikelyRunning(state): boolean {
- const s = state.latestRun?.status;
- return s === "running" || s === "resolving" || state.assumeRunningFromSubmission;
+ return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
},
canStart(): boolean {
return !this.isRunActive && !this.safetyState.cooldown_active;
@@ -205,7 +125,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
- if (isActiveStatus(run?.status)) {
+ if (run?.status === "running") {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
@@ -236,102 +156,9 @@ 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 = [];
- this.scholarProgress = null;
- 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.addEventListener("scholar_progress", (e) => {
- try {
- const data = JSON.parse(e.data);
- const visited = typeof data.visited === "number" ? Math.max(0, Math.trunc(data.visited)) : null;
- const finished = typeof data.finished === "number" ? Math.max(0, Math.trunc(data.finished)) : null;
- const total = typeof data.total === "number" ? Math.max(1, Math.trunc(data.total)) : null;
- if (visited !== null && finished !== null && total !== null) {
- this.scholarProgress = { visited, finished, total };
- }
- } 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) {
@@ -342,13 +169,13 @@ export const useRunStatusStore = defineStore("runStatus", {
syncPromise = (async () => {
try {
const payload = await listRuns({ limit: 1 });
- const latest = reconcileRunCounters(this.latestRun, payload.runs[0] ?? null);
+ const latest = payload.runs[0] ?? null;
this.latestRun = latest;
this.safetyState = normalizeSafetyState(payload.safety_state);
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
- if (isActiveStatus(latest?.status)) {
+ if (latest?.status === "running") {
this.assumeRunningFromSubmission = true;
} else if (!this.isSubmitting) {
this.assumeRunningFromSubmission = false;
@@ -467,36 +294,14 @@ export const useRunStatusStore = defineStore("runStatus", {
} finally {
this.isSubmitting = false;
this.clearSubmittingPhaseTimer();
- if (!isActiveStatus(this.latestRun?.status)) {
+ if (this.latestRun?.status !== "running") {
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;
@@ -505,8 +310,6 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastErrorRequestId = null;
this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState();
- this.livePublications = [];
- this.scholarProgress = null;
},
},
});
diff --git a/frontend/src/stores/user_settings.test.ts b/frontend/src/stores/user_settings.test.ts
index 46b25f5..72cbb81 100644
--- a/frontend/src/stores/user_settings.test.ts
+++ b/frontend/src/stores/user_settings.test.ts
@@ -43,9 +43,6 @@ 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
deleted file mode 100644
index 1ab3010..0000000
--- a/frontend/src/theme/presets/deutan-blue.js
+++ /dev/null
@@ -1,313 +0,0 @@
-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 2a0c15f..e469967 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,9 +17,7 @@ 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",
]
@@ -27,13 +25,10 @@ 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\" --import-mode=importlib"
+addopts = "-q -m \"not integration\""
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
@@ -44,31 +39,6 @@ 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 52e6e97..07ba856 100755
--- a/scripts/check_frontend_api_contract.py
+++ b/scripts/check_frontend_api_contract.py
@@ -103,7 +103,9 @@ 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 bcb2c70..b8d4725 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.dbops import collect_integrity_report
+from app.services.domains.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 ebd00ab..44ef56c 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.dbops import run_publication_link_repair
+from app.services.domains.dbops import run_publication_link_repair
def build_parser() -> argparse.ArgumentParser:
diff --git a/scripts/premerge.sh b/scripts/premerge.sh
deleted file mode 100755
index e612091..0000000
--- a/scripts/premerge.sh
+++ /dev/null
@@ -1,58 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-DC="docker compose -f docker-compose.yml -f docker-compose.dev.yml"
-
-passed=0
-failed=0
-failures=()
-
-step() {
- local label="$1"
- shift
- printf '\n\033[1;34m── %s\033[0m\n' "$label"
- if "$@"; then
- printf '\033[1;32m PASS\033[0m %s\n' "$label"
- ((passed++)) || true
- else
- printf '\033[1;31m FAIL\033[0m %s\n' "$label"
- ((failed++)) || true
- failures+=("$label")
- fi
-}
-
-# ── repo hygiene (host-side, no runtime deps) ────────────────────────────────
-step "No generated artifacts" ./scripts/check_no_generated_artifacts.sh
-step "Env contract parity" python3 scripts/check_env_contract.py
-step "API contract drift" python3 scripts/check_frontend_api_contract.py
-
-# ── backend lint + typecheck ─────────────────────────────────────────────────
-step "Ruff check" $DC run --rm app ruff check .
-step "Ruff format" $DC run --rm app ruff format --check .
-step "Mypy" $DC run --rm app mypy app/ --ignore-missing-imports
-
-# ── backend tests ────────────────────────────────────────────────────────────
-step "Unit tests" $DC run --rm app python -m pytest tests/unit
-step "Integration tests" $DC run --rm app python -m pytest -m integration
-
-# ── frontend ─────────────────────────────────────────────────────────────────
-step "Frontend theme tokens" $DC run --rm frontend npm run check:theme-tokens
-step "Frontend typecheck" $DC run --rm frontend npm run typecheck
-step "Frontend unit tests" $DC run --rm frontend npm run test:run
-step "Frontend build" $DC run --rm frontend npm run build
-
-# ── docs ─────────────────────────────────────────────────────────────────────
-step "Docs build" npm --prefix docs/website run build
-
-# ── summary ──────────────────────────────────────────────────────────────────
-printf '\n\033[1;34m── Summary ─────────────────────────────────────────────\033[0m\n'
-printf ' %s passed, %s failed\n' "$passed" "$failed"
-if ((failed > 0)); then
- printf '\n\033[1;31m Failed steps:\033[0m\n'
- for f in "${failures[@]}"; do
- printf ' - %s\n' "$f"
- done
- exit 1
-else
- printf '\n\033[1;32m All checks passed.\033[0m\n'
-fi
diff --git a/tests/conftest.py b/tests/conftest.py
index 323a396..4fa650c 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 import text
from sqlalchemy.engine import make_url
+from sqlalchemy import text
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,8 +19,6 @@ 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,
@@ -52,7 +50,9 @@ 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,7 +83,9 @@ 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 ce5ed3f..d1f5c68 100644
--- a/tests/integration/helpers.py
+++ b/tests/integration/helpers.py
@@ -1,36 +1,11 @@
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
@@ -78,93 +53,3 @@ 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
deleted file mode 100644
index f80a8e3..0000000
--- a/tests/integration/test_api_admin.py
+++ /dev/null
@@ -1,577 +0,0 @@
-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"
-
- # PDF queue listing is accessible to all authenticated users (not admin-only).
- allowed_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
- assert allowed_pdf_queue.status_code == 200
-
- # But requeue actions still require admin.
- 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
deleted file mode 100644
index 6d4a0a6..0000000
--- a/tests/integration/test_api_auth.py
+++ /dev/null
@@ -1,182 +0,0 @@
-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
deleted file mode 100644
index 96d3fc5..0000000
--- a/tests/integration/test_api_publications.py
+++ /dev/null
@@ -1,573 +0,0 @@
-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
deleted file mode 100644
index ce39cab..0000000
--- a/tests/integration/test_api_publications_advanced.py
+++ /dev/null
@@ -1,337 +0,0 @@
-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
deleted file mode 100644
index e8f2f1a..0000000
--- a/tests/integration/test_api_runs.py
+++ /dev/null
@@ -1,499 +0,0 @@
-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
deleted file mode 100644
index 1fd8ba9..0000000
--- a/tests/integration/test_api_scholars.py
+++ /dev/null
@@ -1,476 +0,0 @@
-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.rate_limit import reset_scholar_rate_limit_state_for_tests
-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_delete_scholar_returns_404_for_nonexistent(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-delete-404@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-delete-404@example.com", password="api-password")
- headers = api_csrf_headers(client)
-
- response = client.delete("/api/v1/scholars/999999", headers=headers)
- assert response.status_code == 404
- assert response.json()["error"]["code"] == "scholar_not_found"
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_delete_scholar_cascades_linked_publications(db_session: AsyncSession) -> None:
- user_id = await insert_user(
- db_session,
- email="api-delete-cascade@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": "delCASCADE01", "display_name": "Cascade Test"},
- )
- scholar_profile_id = int(scholar_result.scalar_one())
- pub_result = await db_session.execute(
- text(
- """
- INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
- VALUES (:fingerprint, :title_raw, :title_normalized, 0)
- RETURNING id
- """
- ),
- {
- "fingerprint": f"{(user_id + 7000):064x}",
- "title_raw": "Cascade Publication",
- "title_normalized": "cascadepublication",
- },
- )
- publication_id = int(pub_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-delete-cascade@example.com", password="api-password")
- headers = api_csrf_headers(client)
-
- 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."
-
- link_result = await db_session.execute(
- text("SELECT count(*) FROM scholar_publications WHERE scholar_profile_id = :id"),
- {"id": scholar_profile_id},
- )
- assert int(link_result.scalar_one()) == 0
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_api_create_scholar_rejects_corrupted_id(db_session: AsyncSession) -> None:
- await insert_user(
- db_session,
- email="api-bad-id@example.com",
- password="api-password",
- )
-
- client = TestClient(app)
- login_user(client, email="api-bad-id@example.com", password="api-password")
- headers = api_csrf_headers(client)
-
- for bad_id in ["short", "ABCDEF12345!", "", " ", "AB CD EF1234"]:
- response = client.post(
- "/api/v1/scholars",
- json={"scholar_id": bad_id},
- headers=headers,
- )
- assert response.status_code == 400, f"Expected 400 for scholar_id={bad_id!r}"
- assert response.json()["error"]["code"] == "invalid_scholar"
-
-
-@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"
-
- reset_scholar_rate_limit_state_for_tests()
- 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_scholars_bulk.py b/tests/integration/test_api_scholars_bulk.py
deleted file mode 100644
index 28be206..0000000
--- a/tests/integration/test_api_scholars_bulk.py
+++ /dev/null
@@ -1,170 +0,0 @@
-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_csrf_headers,
- insert_user,
- login_user,
-)
-
-
-def _create_scholar(client: TestClient, headers: dict, scholar_id: str) -> int:
- resp = client.post("/api/v1/scholars", json={"scholar_id": scholar_id}, headers=headers)
- assert resp.status_code == 201
- return int(resp.json()["data"]["id"])
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_bulk_delete_with_valid_ids(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="bulk@example.com", password="pw123456")
- client = TestClient(app)
- login_user(client, email="bulk@example.com", password="pw123456")
- headers = api_csrf_headers(client)
-
- id1 = _create_scholar(client, headers, "aaaBBB111222")
- id2 = _create_scholar(client, headers, "cccDDD333444")
-
- resp = client.post(
- "/api/v1/scholars/bulk-delete",
- json={"scholar_profile_ids": [id1, id2]},
- headers=headers,
- )
- assert resp.status_code == 200
- assert resp.json()["data"]["deleted_count"] == 2
-
- list_resp = client.get("/api/v1/scholars")
- assert len(list_resp.json()["data"]["scholars"]) == 0
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_bulk_delete_only_deletes_own_scholars(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="user1@example.com", password="pw123456")
- await insert_user(db_session, email="user2@example.com", password="pw123456")
-
- client1 = TestClient(app)
- login_user(client1, email="user1@example.com", password="pw123456")
- headers1 = api_csrf_headers(client1)
-
- client2 = TestClient(app)
- login_user(client2, email="user2@example.com", password="pw123456")
- headers2 = api_csrf_headers(client2)
-
- id_user1 = _create_scholar(client1, headers1, "aaaBBB111222")
- id_user2 = _create_scholar(client2, headers2, "cccDDD333444")
-
- # User1 tries to delete both — should only delete own
- resp = client1.post(
- "/api/v1/scholars/bulk-delete",
- json={"scholar_profile_ids": [id_user1, id_user2]},
- headers=headers1,
- )
- assert resp.status_code == 200
- assert resp.json()["data"]["deleted_count"] == 1
-
- # User2's scholar still exists
- list_resp = client2.get("/api/v1/scholars")
- scholars = list_resp.json()["data"]["scholars"]
- assert len(scholars) == 1
- assert int(scholars[0]["id"]) == id_user2
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_bulk_toggle_enables_and_disables(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="toggle@example.com", password="pw123456")
- client = TestClient(app)
- login_user(client, email="toggle@example.com", password="pw123456")
- headers = api_csrf_headers(client)
-
- id1 = _create_scholar(client, headers, "aaaBBB111222")
- id2 = _create_scholar(client, headers, "cccDDD333444")
-
- # Disable both
- resp = client.post(
- "/api/v1/scholars/bulk-toggle",
- json={"scholar_profile_ids": [id1, id2], "is_enabled": False},
- headers=headers,
- )
- assert resp.status_code == 200
- assert resp.json()["data"]["updated_count"] == 2
-
- scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
- for s in scholars:
- assert s["is_enabled"] is False
-
- # Re-enable both
- resp = client.post(
- "/api/v1/scholars/bulk-toggle",
- json={"scholar_profile_ids": [id1, id2], "is_enabled": True},
- headers=headers,
- )
- assert resp.status_code == 200
- assert resp.json()["data"]["updated_count"] == 2
-
- scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
- for s in scholars:
- assert s["is_enabled"] is True
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="export@example.com", password="pw123456")
- client = TestClient(app)
- login_user(client, email="export@example.com", password="pw123456")
- headers = api_csrf_headers(client)
-
- id1 = _create_scholar(client, headers, "aaaBBB111222")
- _create_scholar(client, headers, "cccDDD333444")
-
- # Export only id1
- resp = client.get(f"/api/v1/scholars/export?ids={id1}")
- assert resp.status_code == 200
- data = resp.json()["data"]
- assert len(data["scholars"]) == 1
- assert data["scholars"][0]["scholar_id"] == "aaaBBB111222"
-
- # Export all (no filter)
- resp_all = client.get("/api/v1/scholars/export")
- assert resp_all.status_code == 200
- assert len(resp_all.json()["data"]["scholars"]) == 2
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_bulk_delete_rejects_without_csrf(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="csrf-bulk-del@example.com", password="pw123456")
- client = TestClient(app)
- login_user(client, email="csrf-bulk-del@example.com", password="pw123456")
- response = client.post(
- "/api/v1/scholars/bulk-delete",
- json={"scholar_profile_ids": [1]},
- )
- assert response.status_code == 403
- assert response.json()["error"]["code"] == "csrf_invalid"
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_bulk_toggle_rejects_without_csrf(db_session: AsyncSession) -> None:
- await insert_user(db_session, email="csrf-bulk-tog@example.com", password="pw123456")
- client = TestClient(app)
- login_user(client, email="csrf-bulk-tog@example.com", password="pw123456")
- response = client.post(
- "/api/v1/scholars/bulk-toggle",
- json={"scholar_profile_ids": [1], "is_enabled": False},
- )
- assert response.status_code == 403
- assert response.json()["error"]["code"] == "csrf_invalid"
diff --git a/tests/integration/test_api_settings.py b/tests/integration/test_api_settings.py
deleted file mode 100644
index aa9e2c1..0000000
--- a/tests/integration/test_api_settings.py
+++ /dev/null
@@ -1,183 +0,0 @@
-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
new file mode 100644
index 0000000..bb21f34
--- /dev/null
+++ b/tests/integration/test_api_v1.py
@@ -0,0 +1,2314 @@
+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 1e65489..f291866 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.dbops import run_publication_link_repair
+from app.services.domains.dbops import run_publication_link_repair
from tests.integration.helpers import insert_user
@@ -160,7 +160,10 @@ 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 d510344..b6dfb62 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.dbops.integrity import collect_integrity_report
+from app.services.domains.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
deleted file mode 100644
index 5ad5700..0000000
--- a/tests/integration/test_deferred_enrichment.py
+++ /dev/null
@@ -1,106 +0,0 @@
-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 bdc8bb6..eb3762a 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.ingestion.application import ScholarIngestionService
-from app.services.scholar.source import FetchResult
+from app.services.domains.ingestion.application import ScholarIngestionService
+from app.services.domains.scholar.source import FetchResult
from tests.integration.helpers import insert_user
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
@@ -34,7 +34,8 @@ 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,
@@ -76,9 +77,6 @@ class FixtureScholarSource:
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
return await self.fetch_profile_page_html(scholar_id, cstart=0, pagesize=100)
- async def fetch_author_search_html(self, query: str, *, start: int = 0) -> FetchResult:
- raise NotImplementedError
-
@pytest.mark.integration
@pytest.mark.db
@@ -98,7 +96,7 @@ async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
"retry": "RSTUVWX12345",
}
- for label in ("ok", "retry", "blocked"):
+ for label in ("ok", "blocked", "retry"):
await db_session.execute(
text(
"""
@@ -146,9 +144,7 @@ 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,
- rate_limit_retries=0,
- rate_limit_backoff_seconds=0.0,
- max_pages_per_scholar=10,
+ max_pages_per_scholar=1,
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 0bb3440..7b4295a 100644
--- a/tests/integration/test_migrations.py
+++ b/tests/integration/test_migrations.py
@@ -13,15 +13,13 @@ 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 = "20260226_0024"
+EXPECTED_REVISION = "20260221_0015"
@pytest.mark.integration
@@ -29,7 +27,9 @@ EXPECTED_REVISION = "20260226_0024"
@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,28 +137,6 @@ 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_new_publication_detection.py b/tests/integration/test_new_publication_detection.py
deleted file mode 100644
index 585b0c4..0000000
--- a/tests/integration/test_new_publication_detection.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from __future__ import annotations
-
-import pytest
-from fastapi.testclient import TestClient
-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 tests.integration.helpers import (
- api_csrf_headers,
- insert_user,
- login_user,
- wait_for_run_complete,
-)
-
-SCHOLAR_ID = "newPubDetc01"
-
-
-def _build_profile_html(publications: list[dict[str, str]]) -> str:
- rows = []
- for pub in publications:
- rows.append(
- f"""
-
-
- {pub["title"]}
- {pub.get("authors", "A Author")}
- {pub.get("venue", "Some Venue")}
-
- {pub.get("citations", "0")}
- {pub.get("year", "2024")}
- """
- )
- count = len(publications)
- return f"""
-
-
-
-
-
- New Pub Detector
- Articles 1-{count}
-
-
-
- """
-
-
-INITIAL_PUBLICATIONS = [
- {"cluster": "aaa111", "title": "Existing Paper One", "year": "2023", "citations": "10"},
- {"cluster": "bbb222", "title": "Existing Paper Two", "year": "2022", "citations": "5"},
-]
-
-UPDATED_PUBLICATIONS = [
- *INITIAL_PUBLICATIONS,
- {"cluster": "ccc333", "title": "Brand New Paper Three", "year": "2025", "citations": "0"},
-]
-
-
-class _MutableScholarSource:
- """Source that serves different HTML per phase to simulate page changes."""
-
- def __init__(self, initial_html: str) -> None:
- self.html = initial_html
-
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- return self._result(scholar_id)
-
- async def fetch_profile_page_html(
- self,
- scholar_id: str,
- *,
- cstart: int,
- pagesize: int,
- ) -> FetchResult:
- return self._result(scholar_id)
-
- def _result(self, scholar_id: str) -> FetchResult:
- return FetchResult(
- requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
- status_code=200,
- final_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
- body=self.html,
- error=None,
- )
-
-
-@pytest.mark.integration
-@pytest.mark.db
-@pytest.mark.asyncio
-async def test_new_publication_detected_after_page_change(
- db_session: AsyncSession,
-) -> None:
- """Verify the fingerprint short-circuit lets new publications through.
-
- Phase 1: Initial scrape discovers 2 publications.
- Phase 2: Same page → skipped via no_change fingerprint.
- Phase 3: Page gains a 3rd publication → fingerprint differs,
- scrape runs and discovers the new entry.
- """
- await insert_user(db_session, email="new-pub-detect@example.com", password="api-password")
-
- source = _MutableScholarSource(_build_profile_html(INITIAL_PUBLICATIONS))
- app.dependency_overrides[get_scholar_source] = lambda: source
- try:
- with TestClient(app) as client:
- login_user(client, email="new-pub-detect@example.com", password="api-password")
- headers = api_csrf_headers(client)
-
- create_resp = client.post("/api/v1/scholars", json={"scholar_id": SCHOLAR_ID}, headers=headers)
- assert create_resp.status_code == 201
-
- # ── Phase 1: initial scrape ─────────────────────────────
- run1 = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "detect-run-001"},
- )
- assert run1.status_code == 200
- run1_id = int(run1.json()["data"]["run_id"])
- run1_data = await wait_for_run_complete(client, run1_id)
-
- assert run1_data["scholar_results"][0]["outcome"] == "success"
- assert run1_data["scholar_results"][0]["publication_count"] == 2
- assert run1_data["scholar_results"][0].get("state_reason") != "no_change_initial_page_signature"
-
- pubs1 = client.get("/api/v1/publications?mode=latest").json()["data"]
- assert pubs1["total_count"] == 2
- assert all(p["is_new_in_latest_run"] for p in pubs1["publications"])
-
- # ── Phase 2: unchanged page → skipped ──────────────────
- run2 = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "detect-run-002"},
- )
- assert run2.status_code == 200
- run2_id = int(run2.json()["data"]["run_id"])
- run2_data = await wait_for_run_complete(client, run2_id)
-
- assert run2_data["scholar_results"][0]["state_reason"] == "no_change_initial_page_signature"
- assert run2_data["scholar_results"][0]["publication_count"] == 0
-
- # ── Phase 3: new publication appears ────────────────────
- source.html = _build_profile_html(UPDATED_PUBLICATIONS)
-
- run3 = client.post(
- "/api/v1/runs/manual",
- headers={**headers, "Idempotency-Key": "detect-run-003"},
- )
- assert run3.status_code == 200
- run3_id = int(run3.json()["data"]["run_id"])
- run3_data = await wait_for_run_complete(client, run3_id)
-
- assert run3_data["scholar_results"][0]["outcome"] == "success"
- assert run3_data["scholar_results"][0].get("state_reason") != "no_change_initial_page_signature"
- assert run3_data["scholar_results"][0]["publication_count"] == 3
-
- pubs3 = client.get("/api/v1/publications?mode=latest").json()["data"]
- assert pubs3["total_count"] == 3
-
- titles = {p["title"] for p in pubs3["publications"]}
- assert "Brand New Paper Three" in titles
-
- new_pub = next(p for p in pubs3["publications"] if p["title"] == "Brand New Paper Three")
- assert new_pub["is_new_in_latest_run"] is True
-
- old_pubs = [p for p in pubs3["publications"] if p["title"] != "Brand New Paper Three"]
- for p in old_pubs:
- assert p["is_new_in_latest_run"] is False
- finally:
- app.dependency_overrides.pop(get_scholar_source, None)
diff --git a/tests/integration/test_run_lifecycle_consistency.py b/tests/integration/test_run_lifecycle_consistency.py
deleted file mode 100644
index 10da609..0000000
--- a/tests/integration/test_run_lifecycle_consistency.py
+++ /dev/null
@@ -1,538 +0,0 @@
-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)
-
- async def fetch_author_search_html(self, query: str, *, start: int = 0) -> FetchResult:
- raise NotImplementedError
-
-
-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()
- run_id = int(run.id)
-
- 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, run_id)
- assert refreshed_run is not None
- 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": run_id},
- )
- link_count = int(link_count_result.scalar_one())
- assert int(refreshed_run.new_pub_count) == link_count
-
-
-@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 ba9ce76..848a034 100644
--- a/tests/smoke/test_db_connectivity.py
+++ b/tests/smoke/test_db_connectivity.py
@@ -20,3 +20,4 @@ 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 d107388..c12be25 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/conftest.py b/tests/unit/services/domains/arxiv/conftest.py
deleted file mode 100644
index 1f6325b..0000000
--- a/tests/unit/services/domains/arxiv/conftest.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from __future__ import annotations
-
-import pytest
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
-
-
-@pytest.fixture
-def patch_session_factory(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch):
- """Patch get_session_factory in arxiv modules to use the test's DB engine.
-
- Without this, the implementation creates its own engine via get_session_factory()
- which may not share transaction visibility with the test's db_session fixture.
- """
- factory = async_sessionmaker(db_session.bind, expire_on_commit=False)
- monkeypatch.setattr("app.services.arxiv.rate_limit.get_session_factory", lambda: factory)
- monkeypatch.setattr("app.services.arxiv.cache.get_session_factory", lambda: factory)
- return factory
diff --git a/tests/unit/services/domains/arxiv/test_cache.py b/tests/unit/services/domains/arxiv/test_cache.py
deleted file mode 100644
index 0ef753d..0000000
--- a/tests/unit/services/domains/arxiv/test_cache.py
+++ /dev/null
@@ -1,133 +0,0 @@
-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
deleted file mode 100644
index 400d7ff..0000000
--- a/tests/unit/services/domains/arxiv/test_client.py
+++ /dev/null
@@ -1,209 +0,0 @@
-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,
- patch_session_factory,
-) -> None:
- 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
deleted file mode 100644
index 211ab30..0000000
--- a/tests/unit/services/domains/arxiv/test_gateway.py
+++ /dev/null
@@ -1,128 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-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") -> Any:
- from types import 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) # type: ignore[arg-type]
- 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) # type: ignore[arg-type]
- 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
deleted file mode 100644
index a869433..0000000
--- a/tests/unit/services/domains/arxiv/test_guards.py
+++ /dev/null
@@ -1,63 +0,0 @@
-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
deleted file mode 100644
index 6ab0418..0000000
--- a/tests/unit/services/domains/arxiv/test_parser.py
+++ /dev/null
@@ -1,57 +0,0 @@
-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
deleted file mode 100644
index 35ac9f7..0000000
--- a/tests/unit/services/domains/arxiv/test_rate_limit.py
+++ /dev/null
@@ -1,167 +0,0 @@
-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, patch_session_factory) -> 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, patch_session_factory) -> 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, patch_session_factory) -> 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(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
- patch_session_factory,
-) -> 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(str(scheduled[0]["wait_seconds"])) >= 0.0
- assert int(str(completed[0]["status_code"])) == 200
- assert completed[0]["source_path"] == "search"
-
-
-@pytest.mark.asyncio
-async def test_arxiv_rate_limit_logs_cooldown_activation(
- db_session: AsyncSession,
- monkeypatch: pytest.MonkeyPatch,
- patch_session_factory,
-) -> 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(str(cooldown_events[0]["cooldown_remaining_seconds"])) > 0.0
-
-
-@pytest.mark.asyncio
-async def test_get_arxiv_cooldown_status_reads_active_cooldown(db_session: AsyncSession, patch_session_factory) -> 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
deleted file mode 100644
index 3d8827d..0000000
--- a/tests/unit/services/domains/openalex/test_client.py
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index f2da0fd..0000000
--- a/tests/unit/services/domains/openalex/test_matching.py
+++ /dev/null
@@ -1,63 +0,0 @@
-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
deleted file mode 100644
index 0820ecb..0000000
--- a/tests/unit/services/domains/publications/test_dedup.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""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 8b883b5..5d71281 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.crossref import application as crossref_app
+from app.services.domains.crossref import application as crossref_app
def _item(*, title: str, year: int | None, scholar_label: str = "Shinya Yamanaka"):
@@ -52,9 +52,7 @@ async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest.
@pytest.mark.asyncio
-async def test_crossref_relaxed_fallback_allows_large_year_mismatch_for_strong_title_match(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
+async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
async def _fake_fetch_items(**_kwargs):
return [
{
@@ -74,30 +72,4 @@ async def test_crossref_relaxed_fallback_allows_large_year_mismatch_for_strong_t
max_rows=10,
email=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"
+ assert doi is None
diff --git a/tests/unit/test_doi_normalize.py b/tests/unit/test_doi_normalize.py
index 0b14294..84b0ad0 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.doi.normalize import first_doi_from_texts, normalize_doi
+from app.services.domains.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
deleted file mode 100644
index 87c12ba..0000000
--- a/tests/unit/test_fingerprints.py
+++ /dev/null
@@ -1,279 +0,0 @@
-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
deleted file mode 100644
index 6d42a79..0000000
--- a/tests/unit/test_ingestion_arxiv_rate_limit.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from typing import Any, cast
-
-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(
- cast(Any, object()),
- publication=cast(Any, 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
deleted file mode 100644
index bdae206..0000000
--- a/tests/unit/test_ingestion_progress_reporting.py
+++ /dev/null
@@ -1,61 +0,0 @@
-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 a4d8d99..84cf882 100644
--- a/tests/unit/test_logging.py
+++ b/tests/unit/test_logging.py
@@ -7,10 +7,9 @@ from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
-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.logging_config import JsonLogFormatter, parse_redact_fields
from app.main import app
+from app.http.middleware import REQUEST_ID_HEADER, parse_skip_paths
def test_json_log_formatter_redacts_sensitive_fields() -> None:
@@ -54,67 +53,3 @@ 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
deleted file mode 100644
index c0fec71..0000000
--- a/tests/unit/test_logging_policy.py
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index b3e04d9..0000000
--- a/tests/unit/test_portability_import.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-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") -> Any:
- profile = MagicMock()
- profile.id = 1
- profile.scholar_id = scholar_id
- return profile
-
-
-def _scholar_map(scholar_id: str = "ABC123DEF456") -> dict[str, Any]:
- 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
deleted file mode 100644
index 833891a..0000000
--- a/tests/unit/test_portability_normalize.py
+++ /dev/null
@@ -1,194 +0,0 @@
-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:
- fp_a = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
- fp_b = _build_fingerprint(title="Test Title", year=2024, author_text="Smith", venue_text="ICML")
- assert fp_a == fp_b
-
- 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
deleted file mode 100644
index 1ec44ce..0000000
--- a/tests/unit/test_publication_identifiers.py
+++ /dev/null
@@ -1,241 +0,0 @@
-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 37a265b..3d4fe7f 100644
--- a/tests/unit/test_publication_pdf_queue_policy.py
+++ b/tests/unit/test_publication_pdf_queue_policy.py
@@ -1,16 +1,11 @@
from __future__ import annotations
-import contextlib
-from datetime import UTC, datetime, timedelta
-from types import SimpleNamespace
-from typing import Any
+from datetime import datetime, timedelta, timezone
import pytest
from app.db.models import PublicationPdfJob
-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
+from app.services.domains.publications import pdf_queue
def _job(
@@ -27,30 +22,9 @@ def _job(
)
-def _row(
- *, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz"
-) -> Any:
- 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=UTC)
- monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.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)
@@ -63,8 +37,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=UTC)
- monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.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)
@@ -77,8 +51,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=UTC)
- monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.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)
@@ -91,8 +65,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=UTC)
- monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.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,8 +81,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=UTC)
- monkeypatch.setattr(pdf_queue, "utcnow", lambda: now)
+ now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.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)
@@ -133,136 +107,3 @@ 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: Any = SimpleNamespace(**{**first.__dict__, "publication_id": 2})
-
- @contextlib.asynccontextmanager
- async def _raise_background_session_error():
- raise RuntimeError("skip user settings lookup in test")
- yield # pragma: no cover
-
- 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, "background_session", _raise_background_session_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
deleted file mode 100644
index c21791b..0000000
--- a/tests/unit/test_publication_pdf_resolution_pipeline.py
+++ /dev/null
@@ -1,211 +0,0 @@
-from __future__ import annotations
-
-from datetime import UTC, datetime
-from types import SimpleNamespace
-from typing import Any
-
-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) -> Any:
- 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 411ae3c..f8eb767 100644
--- a/tests/unit/test_request_delay_policy.py
+++ b/tests/unit/test_request_delay_policy.py
@@ -1,10 +1,9 @@
from __future__ import annotations
-from datetime import UTC, datetime
+from datetime import datetime, timezone
-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.services.domains.ingestion.application import ScholarIngestionService
+from app.services.domains.ingestion import scheduler as scheduler_module
from app.settings import settings
@@ -26,9 +25,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 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
+ 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
finally:
object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous)
@@ -38,7 +37,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=UTC),
+ now_utc=datetime(2026, 2, 21, tzinfo=timezone.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 ac0c297..60cd87c 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 UTC, datetime, timedelta
+from datetime import datetime, timedelta, timezone
from app.db.models import UserSetting
-from app.services.ingestion import safety as run_safety
+from app.services.domains.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=UTC)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.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=UTC)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.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=UTC)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.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=UTC)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.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=UTC)
+ now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.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 2c224d6..14ea44d 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.runs.application import extract_run_summary
+from app.services.domains.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 90bc2da..7c70dd3 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.scholar.parser import (
+from app.services.domains.scholar.parser import (
ParseState,
ScholarDomInvariantError,
parse_author_search_page,
parse_profile_page,
)
-from app.services.scholar.source import FetchResult
+from app.services.domains.scholar.source import FetchResult
def _fixture(name: str) -> str:
@@ -433,18 +433,3 @@ 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 b95108e..b3c35da 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.scholar.parser import ParseState
-from app.services.scholar.source import FetchResult
-from app.services.scholars import application as scholar_service
+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
pytestmark = [pytest.mark.integration, pytest.mark.db]
@@ -23,12 +23,6 @@ class StubScholarSource:
index = min(self.calls - 1, len(self._fetch_results) - 1)
return self._fetch_results[index]
- async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
- raise NotImplementedError
-
- async def fetch_profile_page_html(self, scholar_id: str, *, cstart: int, pagesize: int) -> FetchResult:
- raise NotImplementedError
-
def _ok_author_search_fetch() -> FetchResult:
body = (
@@ -57,7 +51,8 @@ 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 0d98f4c..c11ed1e 100644
--- a/tests/unit/test_scholar_source.py
+++ b/tests/unit/test_scholar_source.py
@@ -1,13 +1,4 @@
-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())
+from app.services.domains.scholar.source import _build_profile_url
def test_build_profile_url_includes_pagesize_for_initial_page() -> None:
@@ -20,106 +11,3 @@ 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
deleted file mode 100644
index 185d805..0000000
--- a/tests/unit/test_scholar_validators.py
+++ /dev/null
@@ -1,170 +0,0 @@
-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!")
-
- def test_rejects_empty_string(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("")
-
- def test_rejects_whitespace_only(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id(" ")
-
- def test_rejects_embedded_spaces(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("ABCDEF 12345")
-
- def test_rejects_tab_characters(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("ABCDEF\t12345")
-
- def test_rejects_url_as_id(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("https://scholar.google.com/citations?user=ABCDEF123456")
-
- def test_rejects_newline_in_id(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("ABCDEF\n12345")
-
- def test_rejects_unicode_characters(self) -> None:
- with pytest.raises(ScholarServiceError, match="12"):
- validate_scholar_id("ABCDEF12345\u00e9")
-
-
-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
deleted file mode 100644
index 10049e8..0000000
--- a/tests/unit/test_scholars_create_hydration.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from types import SimpleNamespace
-from typing import Any, cast
-
-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=cast(Any, None),
- profile=profile,
- source=cast(Any, 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=cast(Any, None),
- profile=profile,
- source=cast(Any, 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(
- cast(Any, 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(
- cast(Any, 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 2695fb8..16e1c27 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.unpaywall import pdf_discovery
+from app.services.domains.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):
+ async def get(self, url: str, follow_redirects: bool = True): # noqa: ARG002
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):
+ async def _skip_wait(*args, **kwargs): # noqa: ARG001, ANN002
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 37b4764..5cc34ce 100644
--- a/tests/unit/test_unpaywall_resolution.py
+++ b/tests/unit/test_unpaywall_resolution.py
@@ -1,13 +1,11 @@
from __future__ import annotations
-from dataclasses import replace
-from datetime import UTC, datetime
+from datetime import datetime, timezone
import pytest
-from app.services.publication_identifiers.types import DisplayIdentifier
-from app.services.publications.types import PublicationListItem
-from app.services.unpaywall import application as unpaywall_app
+from app.services.domains.publications.types import PublicationListItem
+from app.services.domains.unpaywall import application as unpaywall_app
class _DummyAsyncClient:
@@ -32,46 +30,14 @@ def _item(publication_id: int) -> PublicationListItem:
citation_count=1000,
venue_text="Cell",
pub_url="https://doi.org/10.1016/j.cell.2007.11.019",
- display_identifier=None,
+ doi=None,
pdf_url=None,
is_read=False,
- first_seen_at=datetime.now(UTC),
+ first_seen_at=datetime.now(timezone.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 = {
@@ -83,7 +49,7 @@ async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkey
}
async def _fake_resolve_item_payload(**_kwargs):
- return payload, False, "10.1016/j.cell.2007.11.019"
+ return payload, False
async def _fail_crawl(_client, *, page_url: str):
raise AssertionError(f"unexpected landing crawl: {page_url}")
@@ -109,7 +75,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, "10.1016/j.cell.2007.11.019"
+ return payload, False
async def _fake_crawl(_client, *, page_url: str):
crawled_pages.append(page_url)
@@ -121,22 +87,3 @@ 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 05d8607..83b1bc0 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.settings.application import (
+from app.services.domains.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=r"Check interval must be at least 15 minutes.",
+ match="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=r"Request delay must be at least 2 seconds.",
+ match="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=r"Check interval must be at least 30 minutes.",
+ match="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=r"Request delay must be at least 8 seconds.",
+ match="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=r"Dashboard, Scholars, and Settings must remain visible.",
+ match="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=r"Unsupported navigation page id: reports",
+ match="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
deleted file mode 100644
index dddc7f4..0000000
--- a/tests/unit/test_user_validation.py
+++ /dev/null
@@ -1,79 +0,0 @@
-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 cc622b5..ac9d550 100644
--- a/uv.lock
+++ b/uv.lock
@@ -253,18 +253,6 @@ 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"
@@ -297,27 +285,6 @@ 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"
@@ -341,30 +308,6 @@ 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"
@@ -483,15 +426,6 @@ 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"
@@ -542,78 +476,6 @@ 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"
@@ -626,18 +488,6 @@ 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"
@@ -713,57 +563,6 @@ 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"
@@ -782,15 +581,6 @@ 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"
@@ -979,19 +769,6 @@ 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"
@@ -1001,30 +778,6 @@ 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"
@@ -1071,69 +824,6 @@ 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"
@@ -1149,56 +839,6 @@ 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"
@@ -1212,19 +852,14 @@ 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]
@@ -1236,37 +871,14 @@ 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"
@@ -1336,24 +948,6 @@ 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"
@@ -1572,57 +1166,3 @@ 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 },
-]