Compare commits

..

7 commits

Author SHA1 Message Date
d1d0abeb11 fix(ci): fix mypy error in bulk_toggle_scholars
Use int(cursor.rowcount) instead of type: ignore comment.
2026-03-07 15:59:31 +01:00
587995a6bc fix(ci): fix ruff format in scholars router
Put each structured_log argument on its own line.
2026-03-07 15:56:40 +01:00
f3e94ec3f5 fix(test): add Pinia setup and admin user to AdminPdfQueueSection tests
The component uses useAuthStore() which requires an active Pinia
instance, and the "Queue all" button is admin-only (v-if="auth.isAdmin").
2026-03-07 15:51:49 +01:00
ef39ab6a41 fix(ci): fix import sorting, API contract, and pdf-queue test
- Add missing blank line after imports in application.py (ruff I001)
- Use full path string instead of template literal for export API call
  to avoid false positive in API contract drift check
- Update pdf-queue test to expect 200 for non-admin listing
2026-03-07 15:40:49 +01:00
7f78dd6353 feat(scholars): add multiselect and bulk actions (delete, toggle, export)
Add Set<number>-based selection state to ScholarsPage with checkboxes
in both table and card views, select-all toggle, and stale-key pruning.

Backend: POST /scholars/bulk-delete, POST /scholars/bulk-toggle, and
GET /scholars/export?ids= filter. Service functions use user-scoped
queries. Structured logging for bulk operations.

Frontend: useScholarBulkActions composable extracts selection and bulk
action logic. AppSelect-based action dropdown with dynamic options.
Delete prompts window.confirm; enable/disable applies immediately.
Export downloads filtered JSON.

Includes integration tests for all bulk endpoints and frontend unit
tests for selection toggle, select-all, and stale pruning.
2026-03-07 14:03:06 +01:00
6fa54db08f feat(settings): hours interval, pdf queue for all users, fix users tab loading
- Convert check interval setting from minutes to hours in the UI
- Show PDF queue tab to all authenticated users (not admin-only); requeue
  actions remain admin-only in both backend and frontend
- Fix users tab not loading on first visit by removing the AsyncStateGate
  wrapper that prevented child component refs from being populated before
  onMounted fired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:53:20 +01:00
17727187fd feat(frontend): show run and cancel buttons to all users
Remove the auth.isAdmin guard from the Start check and Cancel check
buttons in the Activity Monitor. Admin-only route links (check history,
diagnostics) are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 13:53:20 +01:00
22 changed files with 94 additions and 865 deletions

View file

@ -57,7 +57,7 @@ def _requested_by_value(*, payload, admin_user: User) -> str:
return from_payload or admin_user.email return from_payload or admin_user.email
def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, object]: def _serialize_pdf_queue_item(item) -> dict[str, object]:
return { return {
"publication_id": item.publication_id, "publication_id": item.publication_id,
"title": item.title, "title": item.title,
@ -69,7 +69,7 @@ def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, obje
"last_failure_detail": item.last_failure_detail, "last_failure_detail": item.last_failure_detail,
"last_source": item.last_source, "last_source": item.last_source,
"requested_by_user_id": item.requested_by_user_id, "requested_by_user_id": item.requested_by_user_id,
"requested_by_email": item.requested_by_email if is_admin else None, "requested_by_email": item.requested_by_email,
"queued_at": item.queued_at, "queued_at": item.queued_at,
"last_attempt_at": item.last_attempt_at, "last_attempt_at": item.last_attempt_at,
"resolved_at": item.resolved_at, "resolved_at": item.resolved_at,
@ -215,7 +215,7 @@ async def get_pdf_queue(
return success_payload( return success_payload(
request, request,
data={ data={
"items": [_serialize_pdf_queue_item(item, is_admin=current_user.is_admin) for item in queue_page.items], "items": [_serialize_pdf_queue_item(item) for item in queue_page.items],
**_pdf_queue_page_data( **_pdf_queue_page_data(
total_count=queue_page.total_count, total_count=queue_page.total_count,
page=resolved_page, page=resolved_page,

View file

@ -51,14 +51,7 @@ def _parse_ids_param(ids: str | None) -> list[int] | None:
parts = [p.strip() for p in ids.split(",") if p.strip()] parts = [p.strip() for p in ids.split(",") if p.strip()]
if not parts: if not parts:
return None return None
try: return [int(p) for p in parts]
return [int(p) for p in parts]
except ValueError as exc:
raise ApiException(
status_code=400,
code="invalid_ids_param",
message="The 'ids' parameter must be a comma-separated list of integers.",
) from exc
@router.get( @router.get(
@ -111,19 +104,12 @@ async def bulk_delete_scholars(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
try: deleted_count = await scholar_service.bulk_delete_scholars(
deleted_count = await scholar_service.bulk_delete_scholars( db_session,
db_session, user_id=current_user.id,
user_id=current_user.id, scholar_profile_ids=payload.scholar_profile_ids,
scholar_profile_ids=payload.scholar_profile_ids, upload_dir=settings.scholar_image_upload_dir,
upload_dir=settings.scholar_image_upload_dir, )
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=409,
code="scholar_bulk_delete_failed",
message=str(exc),
) from exc
structured_log( structured_log(
logger, logger,
"info", "info",
@ -345,18 +331,11 @@ async def delete_scholar(
code="scholar_not_found", code="scholar_not_found",
message="Scholar not found.", message="Scholar not found.",
) )
try: await scholar_service.delete_scholar(
await scholar_service.delete_scholar( db_session,
db_session, profile=profile,
profile=profile, upload_dir=settings.scholar_image_upload_dir,
upload_dir=settings.scholar_image_upload_dir, )
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=409,
code="scholar_delete_failed",
message=str(exc),
) from exc
structured_log( structured_log(
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
) )

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -53,11 +52,7 @@ async def bulk_delete_scholars(
_safe_remove_upload(upload_root, profile.profile_image_upload_path) _safe_remove_upload(upload_root, profile.profile_image_upload_path)
for profile in profiles: for profile in profiles:
await db_session.delete(profile) await db_session.delete(profile)
try: await db_session.commit()
await db_session.commit()
except IntegrityError as exc:
await db_session.rollback()
raise ScholarServiceError("Unable to bulk-delete scholars due to a database constraint.") from exc
return len(profiles) return len(profiles)
@ -68,9 +63,9 @@ async def bulk_toggle_scholars(
scholar_profile_ids: list[int], scholar_profile_ids: list[int],
is_enabled: bool, is_enabled: bool,
) -> int: ) -> int:
from sqlalchemy import CursorResult, update from sqlalchemy import update
cursor: CursorResult[Any] = await db_session.execute( # type: ignore[assignment] cursor = await db_session.execute(
update(ScholarProfile) update(ScholarProfile)
.where( .where(
ScholarProfile.id.in_(scholar_profile_ids), ScholarProfile.id.in_(scholar_profile_ids),
@ -79,7 +74,7 @@ async def bulk_toggle_scholars(
.values(is_enabled=is_enabled) .values(is_enabled=is_enabled)
) )
await db_session.commit() await db_session.commit()
return int(cursor.rowcount or 0) return int(cursor.rowcount)
__all__ = [ __all__ = [
@ -182,11 +177,7 @@ async def delete_scholar(
_safe_remove_upload(upload_root, profile.profile_image_upload_path) _safe_remove_upload(upload_root, profile.profile_image_upload_path)
await db_session.delete(profile) await db_session.delete(profile)
try: await db_session.commit()
await db_session.commit()
except IntegrityError as exc:
await db_session.rollback()
raise ScholarServiceError("Unable to delete scholar due to a database constraint.") from exc
async def hydrate_profile_metadata( async def hydrate_profile_metadata(

View file

@ -1,61 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
return { ...createDefaultSafetyState(), ...overrides };
}
describe("ScrapeSafetyBadge", () => {
it("shows ready tooltip when cooldown is inactive", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: { state: buildState({ cooldown_active: false }) },
});
expect(wrapper.text()).toContain("Safety ready");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
expect(hint.props("text")).toContain("No active cooldown");
});
it("shows cooldown tooltip with reason and action when active", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Too many blocked requests",
recommended_action: "Wait for cooldown to expire",
cooldown_remaining_seconds: 120,
}),
},
});
expect(wrapper.text()).toContain("Safety cooldown");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).toContain("Too many blocked requests");
expect(text).toContain("Wait for cooldown to expire");
});
it("shows cooldown tooltip without optional fields", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "some_reason",
cooldown_reason_label: null,
recommended_action: null,
cooldown_remaining_seconds: 60,
}),
},
});
const hint = wrapper.findComponent({ name: "AppHelpHint" });
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).not.toContain("Why:");
expect(text).not.toContain("Action:");
});
});

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { type ScrapeSafetyState } from "@/features/safety"; import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{ const props = defineProps<{
@ -19,30 +18,10 @@ const toneClass = computed(() => {
} }
return "border-state-warning-border bg-state-warning-bg text-state-warning-text"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}); });
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
const RATE_LIMIT_INTRO =
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
const tooltipText = computed(() => {
if (!props.state.cooldown_active) return READY_TOOLTIP;
const parts = [RATE_LIMIT_INTRO];
if (props.state.cooldown_reason_label) {
parts.push(`Why: ${props.state.cooldown_reason_label}`);
}
if (props.state.recommended_action) {
parts.push(`Action: ${props.state.recommended_action}`);
}
return parts.join(" \u2014 ");
});
</script> </script>
<template> <template>
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass"> {{ label }}
{{ label }}
</span>
<AppHelpHint :text="tooltipText" />
</span> </span>
</template> </template>

View file

@ -2,7 +2,6 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AppAlert from "@/components/ui/AppAlert.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { import {
formatCooldownCountdown, formatCooldownCountdown,
type ScrapeSafetyState, type ScrapeSafetyState,
@ -80,10 +79,6 @@ const actionText = computed(() => {
return props.safetyState.recommended_action; return props.safetyState.recommended_action;
}); });
const bannerHintText = computed(() => {
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
});
onMounted(() => { onMounted(() => {
timer = setInterval(() => { timer = setInterval(() => {
now.value = Date.now(); now.value = Date.now();
@ -100,12 +95,7 @@ onBeforeUnmount(() => {
<template> <template>
<AppAlert v-if="isVisible" :tone="tone"> <AppAlert v-if="isVisible" :tone="tone">
<template #title> <template #title>{{ title }}</template>
<span class="inline-flex items-center gap-1">
{{ title }}
<AppHelpHint :text="bannerHintText" />
</span>
</template>
<p>{{ detailText }}</p> <p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p> <p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert> </AppAlert>

View file

@ -1,28 +0,0 @@
<script setup lang="ts">
import AppButton from "@/components/ui/AppButton.vue";
import AppModal from "@/components/ui/AppModal.vue";
withDefaults(
defineProps<{
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
}>(),
{ confirmLabel: "Confirm", cancelLabel: "Cancel", variant: "default" },
);
const emit = defineEmits<{ confirm: []; cancel: [] }>();
</script>
<template>
<AppModal :open="open" :title="title" @close="emit('cancel')">
<p class="mb-6 text-sm text-secondary">{{ message }}</p>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" @click="emit('cancel')">{{ cancelLabel }}</AppButton>
<AppButton :variant="variant === 'danger' ? 'danger' : 'primary'" @click="emit('confirm')">{{ confirmLabel }}</AppButton>
</div>
</AppModal>
</template>

View file

@ -2,144 +2,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils"; import { mount } from "@vue/test-utils";
import ScholarBatchAdd from "./ScholarBatchAdd.vue"; import ScholarBatchAdd from "./ScholarBatchAdd.vue";
import {
parseScholarIds,
parseScholarTokens,
extractScholarIdFromUrl,
validateTokenAsId,
} from "./scholar-batch-parsing";
const defaultProps = { saving: false, loading: false }; const defaultProps = { saving: false, loading: false };
describe("parseScholarIds (unit)", () => { describe("ScholarBatchAdd", () => {
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", () => { it("renders the heading and input", () => {
const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
expect(wrapper.text()).toContain("Add Scholar Profiles"); expect(wrapper.text()).toContain("Add Scholar Profiles");
@ -207,18 +73,4 @@ describe("ScholarBatchAdd (component)", () => {
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } }); const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
expect(wrapper.text()).toContain("Adding..."); 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");
});
}); });

View file

@ -3,7 +3,6 @@ import { computed, ref } from "vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { parseScholarTokens } from "./scholar-batch-parsing";
defineProps<{ defineProps<{
saving: boolean; saving: boolean;
@ -16,13 +15,38 @@ const emit = defineEmits<{
const scholarBatchInput = ref(""); const scholarBatchInput = ref("");
const parsedTokens = computed(() => parseScholarTokens(scholarBatchInput.value)); const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
const validIds = computed(() => parsedTokens.value.filter((t) => t.id !== null)); const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
const invalidTokens = computed(() => parsedTokens.value.filter((t) => t.id === null));
const parsedBatchCount = computed(() => validIds.value.length); function parseScholarIds(raw: string): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
for (const token of tokens) {
let candidate: string | null = null;
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
if (!candidate) {
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
if (directParamMatch) candidate = directParamMatch[1];
}
if (!candidate && token.includes("scholar.google")) {
try {
const parsed = new URL(token);
const userParam = parsed.searchParams.get("user");
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
} catch (_error) { /* Ignore non-URL tokens. */ }
}
if (!candidate || seen.has(candidate)) continue;
seen.add(candidate);
ordered.push(candidate);
}
return ordered;
}
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
function onSubmit(): void { function onSubmit(): void {
const ids = validIds.value.map((t) => t.id!); const ids = parseScholarIds(scholarBatchInput.value);
if (ids.length > 0) { if (ids.length > 0) {
emit("add-scholars", ids); emit("add-scholars", ids);
scholarBatchInput.value = ""; scholarBatchInput.value = "";
@ -52,33 +76,11 @@ function onSubmit(): void {
/> />
</label> </label>
<div v-if="parsedTokens.length > 0" class="space-y-1"> <div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-secondary"> <p class="text-xs text-secondary">
<strong class="text-ink-primary">{{ parsedBatchCount }}</strong> valid ID{{ parsedBatchCount === 1 ? "" : "s" }} Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
<template v-if="invalidTokens.length > 0">
· <span class="text-state-warning-text">{{ invalidTokens.length }} skipped</span>
</template>
</p> </p>
<ul v-if="invalidTokens.length > 0" class="space-y-0.5" data-testid="validation-errors"> <AppButton type="submit" :disabled="saving || loading">
<li
v-for="item in invalidTokens.slice(0, 5)"
:key="item.index"
class="text-xs text-state-warning-text"
>
#{{ item.index }}: {{ item.error }}
<code class="ml-1 break-all text-ink-muted">{{ item.raw.length > 60 ? item.raw.slice(0, 57) + "..." : item.raw }}</code>
</li>
<li v-if="invalidTokens.length > 5" class="text-xs text-state-warning-text">
+{{ invalidTokens.length - 5 }} more skipped
</li>
</ul>
</div>
<div v-else class="text-xs text-secondary">
Parsed IDs: <strong class="text-ink-primary">0</strong>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<AppButton type="submit" :disabled="saving || loading || parsedBatchCount === 0">
{{ saving ? "Adding..." : "Add scholars" }} {{ saving ? "Adding..." : "Add scholars" }}
</AppButton> </AppButton>
</div> </div>

View file

@ -47,14 +47,7 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer
:image-url="scholar.profile_image_url" :image-url="scholar.profile_image_url"
/> />
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary"> <p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(scholar) }}</p>
{{ scholarLabel(scholar) }}
<span
v-if="!scholar.baseline_completed"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
>Pending</span>
</p>
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p> <p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs"> <RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">

View file

@ -1,29 +0,0 @@
<script setup lang="ts">
defineProps<{
baselineCompleted: boolean;
lastRunStatus: string | null;
}>();
</script>
<template>
<span
v-if="!baselineCompleted"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
>Pending</span>
<span
v-if="lastRunStatus === 'failed'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-danger-border bg-state-danger-bg px-1.5 py-0.5 text-[10px] font-medium text-state-danger-text"
title="The last scrape run for this scholar failed."
>Failed</span>
<span
v-else-if="lastRunStatus === 'partial'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
title="The last scrape run for this scholar completed with partial results."
>Partial</span>
<span
v-else-if="lastRunStatus === 'success'"
class="ml-1.5 inline-flex items-center rounded-full border border-state-success-border bg-state-success-bg px-1.5 py-0.5 text-[10px] font-medium text-state-success-text"
title="The last scrape run for this scholar completed successfully."
>OK</span>
</template>

View file

@ -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<string>();
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!);
}

View file

@ -109,40 +109,4 @@ describe("useScholarBulkActions", () => {
await bulk.onApplyBulkAction(); await bulk.onApplyBulkAction();
expect(bulk.selectedIds.value.size).toBe(0); 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);
});
}); });

View file

@ -6,6 +6,7 @@ import {
exportScholarData, exportScholarData,
type ScholarProfile, type ScholarProfile,
} from "@/features/scholars"; } from "@/features/scholars";
import { ApiRequestError } from "@/lib/api/errors";
export type ScholarBulkAction = export type ScholarBulkAction =
| "delete_selected" | "delete_selected"
@ -20,14 +21,6 @@ export interface ScholarBulkActionOption {
label: string; label: string;
} }
export interface ConfirmState {
open: boolean;
title: string;
message: string;
variant: "danger" | "default";
onConfirm: () => void;
}
export interface BulkActionCallbacks { export interface BulkActionCallbacks {
clearMessages: () => void; clearMessages: () => void;
assignError: (error: unknown, fallback: string) => void; assignError: (error: unknown, fallback: string) => void;
@ -42,7 +35,6 @@ export function useScholarBulkActions(
const selectedIds = ref<Set<number>>(new Set()); const selectedIds = ref<Set<number>>(new Set());
const bulkAction = ref<ScholarBulkAction>("select_all"); const bulkAction = ref<ScholarBulkAction>("select_all");
const bulkBusy = ref(false); const bulkBusy = ref(false);
const confirmState = ref<ConfirmState>({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
const selectedCount = computed(() => selectedIds.value.size); const selectedCount = computed(() => selectedIds.value.size);
const hasSelection = computed(() => selectedCount.value > 0); const hasSelection = computed(() => selectedCount.value > 0);
@ -136,36 +128,21 @@ export function useScholarBulkActions(
if (bulkAction.value === "export_selected") { await onBulkExport(); return; } if (bulkAction.value === "export_selected") { await onBulkExport(); return; }
} }
function dismissConfirm(): void { async function onBulkDelete(): Promise<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]; const ids = [...selectedIds.value];
requestConfirm( if (!window.confirm(`Delete ${ids.length} scholar(s)? This removes all linked publications and queue data.`)) return;
`Delete ${ids.length} scholar(s)?`, bulkBusy.value = true;
"This removes all linked publications and queue data. This action cannot be undone.", callbacks.clearMessages();
"danger", try {
async () => { const result = await bulkDeleteScholars(ids);
dismissConfirm(); callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`);
bulkBusy.value = true; selectedIds.value = new Set();
callbacks.clearMessages(); await callbacks.reloadScholars();
try { } catch (error) {
const result = await bulkDeleteScholars(ids); callbacks.assignError(error, "Unable to bulk delete scholars.");
callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`); } finally {
selectedIds.value = new Set(); bulkBusy.value = false;
await callbacks.reloadScholars(); }
} catch (error) {
callbacks.assignError(error, "Unable to bulk delete scholars.");
} finally {
bulkBusy.value = false;
}
},
);
} }
async function onBulkToggle(isEnabled: boolean): Promise<void> { async function onBulkToggle(isEnabled: boolean): Promise<void> {
@ -211,9 +188,6 @@ export function useScholarBulkActions(
bulkActionOptions, bulkActionOptions,
bulkApplyLabel, bulkApplyLabel,
bulkApplyDisabled, bulkApplyDisabled,
confirmState,
dismissConfirm,
requestConfirm,
onToggleAll, onToggleAll,
onToggleRow, onToggleRow,
onApplyBulkAction, onApplyBulkAction,

View file

@ -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();
});
});

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, watch } from "vue"; import { onMounted, watch } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
@ -25,10 +25,7 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null); const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null); const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
let loadGeneration = 0;
async function loadSection(): Promise<void> { async function loadSection(): Promise<void> {
const gen = ++loadGeneration;
clearAlerts(); clearAlerts();
try { try {
if (props.section === SECTION_USERS && usersRef.value) { if (props.section === SECTION_USERS && usersRef.value) {
@ -37,21 +34,17 @@ async function loadSection(): Promise<void> {
await integrityRef.value.load(); await integrityRef.value.load();
} else if (props.section === SECTION_REPAIRS) { } else if (props.section === SECTION_REPAIRS) {
await usersRef.value?.load(); await usersRef.value?.load();
if (gen !== loadGeneration) return;
await repairsRef.value?.load(); await repairsRef.value?.load();
} else if (props.section === SECTION_PDF && pdfQueueRef.value) { } else if (props.section === SECTION_PDF && pdfQueueRef.value) {
await pdfQueueRef.value.load(); await pdfQueueRef.value.load();
} }
} catch (error) { } catch (error) {
if (gen !== loadGeneration) return;
assignError(error, "Unable to load admin data."); assignError(error, "Unable to load admin data.");
} }
} }
onMounted(() => { onMounted(loadSection);
nextTick(loadSection); watch(() => props.section, loadSection);
});
watch(() => props.section, loadSection, { flush: "post" });
</script> </script>
<template> <template>

View file

@ -494,11 +494,6 @@ watch(
<template v-else> <template v-else>
Processed {{ displayedLatestRun.scholar_count }} scholars and discovered Processed {{ displayedLatestRun.scholar_count }} scholars and discovered
{{ displayedLatestRun.new_publication_count }} new publications. {{ displayedLatestRun.new_publication_count }} new publications.
<template v-if="displayedLatestRun.failed_count > 0 || displayedLatestRun.partial_count > 0">
<span class="text-state-warning-text">
{{ displayedLatestRun.failed_count > 0 ? `${displayedLatestRun.failed_count} failed` : "" }}{{ displayedLatestRun.failed_count > 0 && displayedLatestRun.partial_count > 0 ? ", " : "" }}{{ displayedLatestRun.partial_count > 0 ? `${displayedLatestRun.partial_count} partial` : "" }}.
</span>
</template>
</template> </template>
</p> </p>
</div> </div>
@ -532,9 +527,6 @@ watch(
</div> </div>
<span class="text-xs text-secondary"> <span class="text-xs text-secondary">
{{ formatDate(run.start_dt) }} {{ run.new_publication_count }} new {{ formatDate(run.start_dt) }} {{ run.new_publication_count }} new
<template v-if="run.failed_count > 0">
<span class="text-state-warning-text">{{ run.failed_count }} failed</span>
</template>
</span> </span>
</li> </li>
</ul> </ul>

View file

@ -6,7 +6,6 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppConfirmModal from "@/components/ui/AppConfirmModal.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
@ -33,7 +32,6 @@ import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue";
import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue";
import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue"; import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue";
import ScholarStatusBadges from "@/features/scholars/components/ScholarStatusBadges.vue";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status"; import { useRunStatusStore } from "@/stores/run_status";
@ -273,30 +271,23 @@ async function onToggleScholar(): Promise<void> {
} }
} }
function onDeleteScholar(): void { async function onDeleteScholar(): Promise<void> {
const profile = activeScholarSettings.value; const profile = activeScholarSettings.value;
if (!profile) return; if (!profile) return;
const label = scholarLabel(profile); const label = scholarLabel(profile);
bulk.requestConfirm( if (!window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`)) return;
`Delete ${label}?`, activeScholarId.value = profile.id;
"This removes all linked publications and queue data. This action cannot be undone.", clearMessages();
"danger", try {
async () => { await deleteScholar(profile.id);
bulk.dismissConfirm(); successMessage.value = `${label} deleted.`;
activeScholarId.value = profile.id; activeScholarSettingsId.value = null;
clearMessages(); await loadScholars();
try { } catch (error) {
await deleteScholar(profile.id); assignError(error, "Unable to delete scholar.");
successMessage.value = `${label} deleted.`; } finally {
activeScholarSettingsId.value = null; activeScholarId.value = null;
await loadScholars(); }
} catch (error) {
assignError(error, "Unable to delete scholar.");
} finally {
activeScholarId.value = null;
}
},
);
} }
async function onSaveImageUrl(): Promise<void> { async function onSaveImageUrl(): Promise<void> {
@ -519,10 +510,7 @@ watch(
/> />
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary"> <p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
{{ scholarLabel(item) }}
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
</p>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a> <a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
@ -565,10 +553,7 @@ watch(
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
<div class="grid min-w-0 gap-1"> <div class="grid min-w-0 gap-1">
<strong class="truncate text-ink-primary"> <strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
{{ scholarLabel(item) }}
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
</strong>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink> <RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a> <a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
@ -605,16 +590,6 @@ watch(
@toggle="onToggleScholar" @toggle="onToggleScholar"
@delete="onDeleteScholar" @delete="onDeleteScholar"
/> />
<AppConfirmModal
:open="bulk.confirmState.value.open"
:title="bulk.confirmState.value.title"
:message="bulk.confirmState.value.message"
:variant="bulk.confirmState.value.variant"
confirm-label="Delete"
@confirm="bulk.confirmState.value.onConfirm()"
@cancel="bulk.dismissConfirm()"
/>
</AppPage> </AppPage>
</template> </template>

View file

@ -168,11 +168,6 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
return parsed; 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 { function parseHoursToMinutes(value: string, minMinutes: number): number {
const hours = Number(value); const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) { if (!Number.isFinite(hours) || hours <= 0) {
@ -180,7 +175,7 @@ function parseHoursToMinutes(value: string, minMinutes: number): number {
} }
const minutes = Math.round(hours * 60); const minutes = Math.round(hours * 60);
if (minutes < minMinutes) { if (minutes < minMinutes) {
throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`); throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`);
} }
return minutes; return minutes;
} }
@ -377,7 +372,7 @@ onMounted(async () => {
<AppHelpHint text="Minimum is controlled by server policy." /> <AppHelpHint text="Minimum is controlled by server policy." />
</span> </span>
<AppInput v-model="runIntervalHours" inputmode="decimal" /> <AppInput v-model="runIntervalHours" inputmode="decimal" />
<span class="text-xs text-secondary">Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours</span> <span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes / 60 }} hours</span>
</label> </label>
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">

View file

@ -70,110 +70,6 @@ async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
assert delete_response.json()["data"]["message"] == "Scholar deleted." 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.integration
@pytest.mark.db @pytest.mark.db
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -138,33 +138,3 @@ async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
resp_all = client.get("/api/v1/scholars/export") resp_all = client.get("/api/v1/scholars/export")
assert resp_all.status_code == 200 assert resp_all.status_code == 200
assert len(resp_all.json()["data"]["scholars"]) == 2 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"

View file

@ -42,34 +42,6 @@ class TestValidateScholarId:
with pytest.raises(ScholarServiceError, match="12"): with pytest.raises(ScholarServiceError, match="12"):
validate_scholar_id("ABCDEF12345!") 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: class TestNormalizeDisplayName:
def test_returns_stripped_name(self) -> None: def test_returns_stripped_name(self) -> None: