mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(koreader): trigger hardcover auto-sync on progress update
KOReader progress updates were updating the database but failing to emit the progress changed event, causing Hardcover auto-sync to be skipped. This change adds the missing event emission to the KOReader service so that external syncs trigger correctly, and patches a query filter issue in the Hardcover repository. Closes #258
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
HardcoverAdminSettings,
|
||||
HardcoverActiveSyncStatus,
|
||||
HardcoverSyncPendingSummary,
|
||||
HardcoverSettings,
|
||||
@@ -97,18 +96,3 @@ export async function fetchHardcoverSyncPendingSummary(): Promise<HardcoverSyncP
|
||||
if (!res.ok) return { totalBooks: 0, pendingBooks: 0 }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchHardcoverAdminSettings(): Promise<HardcoverAdminSettings> {
|
||||
const res = await api('/api/v1/admin/hardcover/settings')
|
||||
if (!res.ok) throw new Error('Failed to fetch admin settings')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function setHardcoverFeatureEnabled(enabled: boolean): Promise<void> {
|
||||
const res = await api('/api/v1/admin/hardcover/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update Hardcover feature flag')
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ const privacyOptions = [
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm">Sync on progress update</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Push reading progress and dates when a session is saved.</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Push reading progress and dates when BookOrbit or KOReader progress changes.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.autoSyncOnProgressUpdate" :disabled="!form.enabled" />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RefreshCw, XCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { AlertCircle, RefreshCw, XCircle, Loader2 } from 'lucide-vue-next'
|
||||
import { useHardcoverSync } from '../composables/useHardcoverSync'
|
||||
import { useHardcoverSettings } from '../composables/useHardcoverSettings'
|
||||
|
||||
const { activeSyncStatus, syncing, isSyncing, syncProgress, pendingSummary, loadingPending, startSync, cancelSync } = useHardcoverSync()
|
||||
const { activeSyncStatus, syncing, isSyncing, syncProgress, pendingSummary, loadingPending, error, startSync, cancelSync } = useHardcoverSync()
|
||||
const { settings } = useHardcoverSettings()
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
@@ -14,8 +14,21 @@ const progressLabel = computed(() => {
|
||||
})
|
||||
|
||||
const hasPending = computed(() => pendingSummary.value.pendingBooks > 0)
|
||||
const syncButtonDisabled = computed(() => syncing.value || loadingPending.value || !hasPending.value)
|
||||
const syncButtonLabel = computed(() => (hasPending.value ? `Sync now (${pendingSummary.value.pendingBooks})` : 'Sync now'))
|
||||
const syncUnavailableReason = computed(() => {
|
||||
switch (settings.value?.disabledReason) {
|
||||
case 'permission_denied':
|
||||
return 'You do not have permission to use Hardcover sync.'
|
||||
case 'user_disabled':
|
||||
return 'Sync is paused in your Hardcover settings.'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
const syncButtonDisabled = computed(() => syncing.value || loadingPending.value || !hasPending.value || syncUnavailableReason.value !== null)
|
||||
const syncButtonLabel = computed(() => {
|
||||
if (syncUnavailableReason.value) return 'Sync unavailable'
|
||||
return hasPending.value ? `Sync now (${pendingSummary.value.pendingBooks})` : 'Sync now'
|
||||
})
|
||||
|
||||
const lastSyncedLabel = computed(() => {
|
||||
const iso = settings.value?.lastSyncedAt
|
||||
@@ -39,11 +52,21 @@ const lastSyncedLabel = computed(() => {
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Push all your reading data to Hardcover now.</p>
|
||||
<p v-if="!isSyncing" class="text-xs text-muted-foreground mt-1">
|
||||
<template v-if="loadingPending">Checking pending items...</template>
|
||||
<template v-else-if="hasPending">{{ pendingSummary.pendingBooks }} pending of {{ pendingSummary.totalBooks }} books.</template>
|
||||
<template v-else-if="hasPending">
|
||||
{{ pendingSummary.pendingBooks }} pending of {{ pendingSummary.totalBooks }} books.
|
||||
<span v-if="syncUnavailableReason">{{ syncUnavailableReason }}</span>
|
||||
</template>
|
||||
<template v-else-if="syncUnavailableReason">{{ syncUnavailableReason }}</template>
|
||||
<template v-else-if="pendingSummary.totalBooks === 0">No eligible books to sync.</template>
|
||||
<template v-else>All books are already synced.</template>
|
||||
</p>
|
||||
<p v-if="lastSyncedLabel && !isSyncing" class="text-xs text-muted-foreground mt-0.5">Last synced: {{ lastSyncedLabel }}</p>
|
||||
<p v-if="lastSyncedLabel && !isSyncing" class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ syncUnavailableReason ? 'Last successful sync' : 'Last synced' }}: {{ lastSyncedLabel }}
|
||||
</p>
|
||||
<p v-if="error && !isSyncing" class="flex items-center gap-1 text-xs text-destructive mt-1">
|
||||
<AlertCircle class="size-3.5" />
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -18,6 +18,8 @@ const mockValidate = vi.mocked(validateHardcoverToken)
|
||||
const SETTINGS: HardcoverSettings = {
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
effectiveEnabled: true,
|
||||
disabledReason: null,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
|
||||
@@ -33,6 +33,18 @@ const RUNNING_STATUS: HardcoverActiveSyncStatus = {
|
||||
syncedBooks: 3,
|
||||
}
|
||||
|
||||
const SETTINGS: HardcoverSettings = {
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
effectiveEnabled: true,
|
||||
disabledReason: null,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: 3,
|
||||
lastSyncedAt: null,
|
||||
}
|
||||
|
||||
async function loadComposable() {
|
||||
const { useHardcoverSync } = await import('../useHardcoverSync')
|
||||
return useHardcoverSync()
|
||||
@@ -46,7 +58,7 @@ describe('useHardcoverSync', () => {
|
||||
mockStatus.mockResolvedValue(null)
|
||||
mockPendingSummary.mockResolvedValue({ totalBooks: 0, pendingBooks: 0 })
|
||||
mockStream.mockResolvedValue(undefined)
|
||||
mockFetchSettings.mockResolvedValue(null as unknown as HardcoverSettings)
|
||||
mockFetchSettings.mockResolvedValue(SETTINGS)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,6 +87,15 @@ describe('useHardcoverSync', () => {
|
||||
expect(c.error.value).toBe('Server busy')
|
||||
})
|
||||
|
||||
it('startSync does not mark a run active when the server reports no run', async () => {
|
||||
mockStart.mockResolvedValue({ runId: 0 })
|
||||
const c = await loadComposable()
|
||||
await c.startSync()
|
||||
expect(c.activeSyncStatus.value).toBeNull()
|
||||
expect(c.error.value).toBe('Hardcover sync is not available right now')
|
||||
expect(mockFetchSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancelSync clears status and stops stream tracking', async () => {
|
||||
mockCancel.mockResolvedValue(undefined)
|
||||
const c = await loadComposable()
|
||||
|
||||
@@ -69,6 +69,11 @@ export function useHardcoverSync() {
|
||||
error.value = null
|
||||
try {
|
||||
const { runId } = await startHardcoverSync()
|
||||
if (runId <= 0) {
|
||||
error.value = 'Hardcover sync is not available right now'
|
||||
await Promise.all([fetchPendingSummary(), useHardcoverSettings().fetchSettings()])
|
||||
return
|
||||
}
|
||||
activeSyncStatus.value = {
|
||||
runId,
|
||||
status: 'running',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award, BookHeart } from 'lucide-vue-next'
|
||||
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
@@ -22,8 +22,6 @@ const migrationLoading = ref(true)
|
||||
|
||||
const updateCheckEnabled = ref(true)
|
||||
const updateCheckLoading = ref(false)
|
||||
const hardcoverEnabled = ref(false)
|
||||
const hardcoverLoading = ref(false)
|
||||
const achievementBackfillRunning = ref(false)
|
||||
const achievementBackfillError = ref<string | null>(null)
|
||||
const achievementBackfillResult = ref<{ usersProcessed: number; awardsGranted: number } | null>(null)
|
||||
@@ -55,8 +53,6 @@ onMounted(async () => {
|
||||
const settings: { key: string; value: string }[] = await res.json()
|
||||
const row = settings.find((s) => s.key === 'update_check_enabled')
|
||||
if (row) updateCheckEnabled.value = row.value === 'true'
|
||||
const hcRow = settings.find((s) => s.key === 'hardcover_enabled')
|
||||
if (hcRow) hardcoverEnabled.value = hcRow.value === 'true'
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
@@ -97,27 +93,6 @@ async function toggleUpdateCheck(newVal: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHardcoverEnabled(newVal: boolean) {
|
||||
hardcoverLoading.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/admin/hardcover/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: newVal }),
|
||||
})
|
||||
if (res.ok) {
|
||||
hardcoverEnabled.value = newVal
|
||||
toast.success(`Hardcover sync ${newVal ? 'enabled' : 'disabled'}`)
|
||||
} else {
|
||||
toast.error('Failed to update setting')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to update setting')
|
||||
} finally {
|
||||
hardcoverLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildEmbeddings() {
|
||||
running.value = true
|
||||
queued.value = null
|
||||
@@ -352,24 +327,5 @@ function formatDate(iso: string | null | undefined): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrations -->
|
||||
<div>
|
||||
<p class="settings-group-label">Integrations</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<BookHeart :size="16" class="text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Hardcover sync</p>
|
||||
<p class="settings-hint">Allow users to connect their Hardcover account and sync reading status, progress, and ratings.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="hardcoverEnabled" :disabled="hardcoverLoading" @update:model-value="toggleHardcoverEnabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export type HardcoverSyncDisabledReason = "permission_denied" | "missing_token" | "user_disabled";
|
||||
|
||||
export interface HardcoverSettings {
|
||||
tokenConfigured: boolean;
|
||||
enabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
disabledReason: HardcoverSyncDisabledReason | null;
|
||||
autoSyncOnStatusChange: boolean;
|
||||
autoSyncOnProgressUpdate: boolean;
|
||||
autoSyncOnRatingChange: boolean;
|
||||
@@ -8,10 +12,6 @@ export interface HardcoverSettings {
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export interface HardcoverAdminSettings {
|
||||
featureEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface UpsertHardcoverSettingsPayload {
|
||||
apiToken?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -23,7 +23,6 @@ export const APP_SETTING_KEYS = {
|
||||
AUDIT_RETENTION_DAYS: 'audit_retention_days',
|
||||
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
|
||||
UPDATE_CHECK_ENABLED: 'update_check_enabled',
|
||||
HARDCOVER_ENABLED: 'hardcover_enabled',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "hardcover_book_state" ALTER COLUMN "match_method" SET DATA TYPE varchar(20);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1780678523024,
|
||||
"tag": "0015_add_kobo_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1780768608213,
|
||||
"tag": "0016_widen_hardcover_match_method",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const hardcoverBookState = pgTable(
|
||||
hardcoverEditionId: integer('hardcover_edition_id'),
|
||||
hardcoverUserBookId: integer('hardcover_user_book_id'),
|
||||
hardcoverReadId: integer('hardcover_read_id'),
|
||||
matchMethod: varchar('match_method', { length: 10 }),
|
||||
matchMethod: varchar('match_method', { length: 20 }),
|
||||
matchError: text('match_error'),
|
||||
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
|
||||
lastSyncedStatus: varchar('last_synced_status', { length: 20 }),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ACHIEVEMENT_EVENT_LIBRARY_CATALOG_CHANGED = 'library.catalog-change
|
||||
export const ACHIEVEMENT_EVENT_BACKFILL = 'achievement.backfill';
|
||||
export const ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED = 'achievement.awarded';
|
||||
export const ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED = 'book.rating-changed';
|
||||
export const ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED = 'book.progress-changed';
|
||||
|
||||
export interface ReadingSessionSavedPayload {
|
||||
userId: number;
|
||||
@@ -50,13 +51,22 @@ export interface BookRatingChangedPayload {
|
||||
rating: number | null;
|
||||
}
|
||||
|
||||
export interface BookProgressChangedPayload {
|
||||
userId: number;
|
||||
bookId: number;
|
||||
bookFileId: number;
|
||||
progress: number;
|
||||
source: 'koreader' | 'web_reader';
|
||||
}
|
||||
|
||||
export type AchievementEventPayload =
|
||||
| ReadingSessionSavedPayload
|
||||
| BookStatusChangedPayload
|
||||
| AnnotationCreatedPayload
|
||||
| CollectionCreatedPayload
|
||||
| LibraryCatalogChangedPayload
|
||||
| BookRatingChangedPayload;
|
||||
| BookRatingChangedPayload
|
||||
| BookProgressChangedPayload;
|
||||
|
||||
@Injectable()
|
||||
export class AchievementEventsService extends EventEmitter {}
|
||||
|
||||
@@ -238,15 +238,6 @@ export class AppSettingsService {
|
||||
const row = await this.repo.findByKey(APP_SETTING_KEYS.UPDATE_CHECK_ENABLED);
|
||||
return parseBooleanSetting(row?.value, true);
|
||||
}
|
||||
|
||||
async isHardcoverEnabled(): Promise<boolean> {
|
||||
const row = await this.repo.findByKey(APP_SETTING_KEYS.HARDCOVER_ENABLED);
|
||||
return parseBooleanSetting(row?.value, false);
|
||||
}
|
||||
|
||||
async setHardcoverEnabled(enabled: boolean): Promise<void> {
|
||||
await this.repo.upsert(APP_SETTING_KEYS.HARDCOVER_ENABLED, String(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
|
||||
|
||||
@@ -34,8 +34,3 @@ export class ValidateHardcoverTokenDto {
|
||||
@MaxLength(2048)
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export class SetHardcoverFeatureEnabledDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { HardcoverAdminController } from './hardcover-admin.controller';
|
||||
|
||||
const mockSettingsService = {
|
||||
getAdminSettings: vi.fn(),
|
||||
setFeatureEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
function makeController() {
|
||||
return new HardcoverAdminController(mockSettingsService as any);
|
||||
}
|
||||
|
||||
describe('HardcoverAdminController', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('getAdminSettings delegates to the settings service', async () => {
|
||||
mockSettingsService.getAdminSettings.mockResolvedValue({ featureEnabled: true });
|
||||
|
||||
const result = await makeController().getAdminSettings();
|
||||
|
||||
expect(result).toEqual({ featureEnabled: true });
|
||||
expect(mockSettingsService.getAdminSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('setFeatureEnabled delegates the enabled flag', async () => {
|
||||
mockSettingsService.setFeatureEnabled.mockResolvedValue(undefined);
|
||||
|
||||
await makeController().setFeatureEnabled({ enabled: false });
|
||||
|
||||
expect(mockSettingsService.setFeatureEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { SetHardcoverFeatureEnabledDto } from './dto';
|
||||
import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
|
||||
@Controller('admin/hardcover')
|
||||
export class HardcoverAdminController {
|
||||
constructor(private readonly settingsService: HardcoverSettingsService) {}
|
||||
|
||||
@Get('settings')
|
||||
@RequirePermission(Permission.ManageAppSettings)
|
||||
getAdminSettings() {
|
||||
return this.settingsService.getAdminSettings();
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@RequirePermission(Permission.ManageAppSettings)
|
||||
setFeatureEnabled(@Body() dto: SetHardcoverFeatureEnabledDto) {
|
||||
return this.settingsService.setFeatureEnabled(dto.enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { HardcoverSettings } from '@bookorbit/types';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
HardcoverAutoSyncSchedulerService,
|
||||
HARDCOVER_AUTO_SYNC_DEBOUNCE_MS,
|
||||
type HardcoverAutoSyncReason,
|
||||
} from './hardcover-auto-sync-scheduler.service';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
|
||||
const mockSettingsService = {
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSyncService = {
|
||||
syncBook: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findBookIdByFileId: vi.fn(),
|
||||
};
|
||||
|
||||
const enabledSettings: HardcoverSettings = {
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
effectiveEnabled: true,
|
||||
disabledReason: null,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: 3,
|
||||
lastSyncedAt: null,
|
||||
};
|
||||
|
||||
function makeDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function advanceDebounce(ms = HARDCOVER_AUTO_SYNC_DEBOUNCE_MS) {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('HardcoverAutoSyncSchedulerService', () => {
|
||||
let service: HardcoverAutoSyncSchedulerService;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
mockSyncService.syncBook.mockResolvedValue(undefined);
|
||||
mockRepository.findBookIdByFileId.mockResolvedValue(42);
|
||||
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
service = new HardcoverAutoSyncSchedulerService(
|
||||
mockSettingsService as unknown as HardcoverSettingsService,
|
||||
mockSyncService as unknown as HardcoverSyncService,
|
||||
mockRepository as unknown as HardcoverRepository,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('coalesces status and progress events for the same user-book into one sync', async () => {
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(HARDCOVER_AUTO_SYNC_DEBOUNCE_MS - 1);
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockSettingsService.getSettings).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['status', 'autoSyncOnStatusChange'],
|
||||
['progress', 'autoSyncOnProgressUpdate'],
|
||||
['rating', 'autoSyncOnRatingChange'],
|
||||
] as Array<[HardcoverAutoSyncReason, keyof typeof enabledSettings]>)(
|
||||
'skips %s sync when the matching toggle is disabled',
|
||||
async (reason, settingKey) => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, [settingKey]: false });
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('syncs when at least one coalesced reason has an enabled toggle', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
|
||||
});
|
||||
|
||||
it('does not resolve reading-session book files when progress auto-sync is disabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
|
||||
|
||||
service.requestSyncForBookFile({ userId: 1, bookFileId: 5, reason: 'progress' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRepository.findBookIdByFileId).not.toHaveBeenCalled();
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves reading-session book files after settings pass and schedules progress sync', async () => {
|
||||
service.requestSyncForBookFile({ userId: 1, bookFileId: 5, reason: 'progress' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRepository.findBookIdByFileId).toHaveBeenCalledWith(5);
|
||||
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 42);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['permission denied', { effectiveEnabled: false, disabledReason: 'permission_denied' }],
|
||||
['token missing', { tokenConfigured: false, effectiveEnabled: false, disabledReason: 'missing_token' }],
|
||||
['integration disabled', { enabled: false, effectiveEnabled: false, disabledReason: 'user_disabled' }],
|
||||
] as Array<[string, Partial<typeof enabledSettings>]>)('skips when %s', async (_name, settingsPatch) => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, ...settingsPatch });
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules one follow-up sync when a request arrives while the same user-book is in flight', async () => {
|
||||
const firstSync = makeDeferred<void>();
|
||||
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
await advanceDebounce();
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'status' });
|
||||
await advanceDebounce();
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstSync.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(HARDCOVER_AUTO_SYNC_DEBOUNCE_MS - 1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 1, 10);
|
||||
});
|
||||
|
||||
it('serializes syncs for different books belonging to the same user', async () => {
|
||||
const firstSync = makeDeferred<void>();
|
||||
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'rating' });
|
||||
service.requestSync({ userId: 1, bookId: 11, reason: 'rating' });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
|
||||
|
||||
firstSync.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 1, 11);
|
||||
});
|
||||
|
||||
it('allows different users to sync independently', async () => {
|
||||
const firstSync = makeDeferred<void>();
|
||||
mockSyncService.syncBook.mockReturnValueOnce(firstSync.promise).mockResolvedValueOnce(undefined);
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'rating' });
|
||||
service.requestSync({ userId: 2, bookId: 20, reason: 'rating' });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(2);
|
||||
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(1, 1, 10);
|
||||
expect(mockSyncService.syncBook).toHaveBeenNthCalledWith(2, 2, 20);
|
||||
|
||||
firstSync.resolve();
|
||||
});
|
||||
|
||||
it('swallows sync failures and logs a sanitized warning', async () => {
|
||||
mockSyncService.syncBook.mockRejectedValue(new Error('API "bad"\nline'));
|
||||
|
||||
service.requestSync({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
await advanceDebounce();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.auto_sync] [fail] userId=1 bookId=10 reasons=progress'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('errorClass=Error error="API \\"bad\\" line"'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
|
||||
export const HARDCOVER_AUTO_SYNC_DEBOUNCE_MS = 1000;
|
||||
|
||||
export type HardcoverAutoSyncReason = 'status' | 'progress' | 'rating';
|
||||
|
||||
type HardcoverSettingsSnapshot = Awaited<ReturnType<HardcoverSettingsService['getSettings']>>;
|
||||
|
||||
interface AutoSyncRequest {
|
||||
userId: number;
|
||||
bookId: number;
|
||||
reason: HardcoverAutoSyncReason;
|
||||
}
|
||||
|
||||
interface AutoSyncBookFileRequest {
|
||||
userId: number;
|
||||
bookFileId: number;
|
||||
reason: Extract<HardcoverAutoSyncReason, 'progress'>;
|
||||
}
|
||||
|
||||
interface AutoSyncState {
|
||||
key: string;
|
||||
userId: number;
|
||||
bookId: number;
|
||||
reasons: Set<HardcoverAutoSyncReason>;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
const AUTO_SYNC_EVENT = 'hardcover.auto_sync';
|
||||
const REASON_ORDER: HardcoverAutoSyncReason[] = ['status', 'progress', 'rating'];
|
||||
|
||||
@Injectable()
|
||||
export class HardcoverAutoSyncSchedulerService {
|
||||
private readonly logger = new Logger(HardcoverAutoSyncSchedulerService.name);
|
||||
private readonly states = new Map<string, AutoSyncState>();
|
||||
private readonly userSyncQueues = new Map<number, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly settingsService: HardcoverSettingsService,
|
||||
private readonly syncService: HardcoverSyncService,
|
||||
private readonly repository: HardcoverRepository,
|
||||
) {}
|
||||
|
||||
requestSync(request: AutoSyncRequest): void {
|
||||
const state = this.getOrCreateState(request.userId, request.bookId);
|
||||
state.reasons.add(request.reason);
|
||||
|
||||
if (state.inFlight) return;
|
||||
|
||||
this.schedule(state);
|
||||
}
|
||||
|
||||
requestSyncForBookFile(request: AutoSyncBookFileRequest): void {
|
||||
void this.resolveBookFileAndRequestSync(request);
|
||||
}
|
||||
|
||||
private getOrCreateState(userId: number, bookId: number): AutoSyncState {
|
||||
const key = `${userId}:${bookId}`;
|
||||
const existing = this.states.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const state: AutoSyncState = {
|
||||
key,
|
||||
userId,
|
||||
bookId,
|
||||
reasons: new Set(),
|
||||
timer: null,
|
||||
inFlight: false,
|
||||
};
|
||||
this.states.set(key, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private schedule(state: AutoSyncState): void {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
void this.runDueSync(state.key);
|
||||
}, HARDCOVER_AUTO_SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async runDueSync(key: string): Promise<void> {
|
||||
const state = this.states.get(key);
|
||||
if (!state || state.inFlight || state.reasons.size === 0) return;
|
||||
|
||||
const reasons = new Set(state.reasons);
|
||||
state.reasons.clear();
|
||||
state.inFlight = true;
|
||||
|
||||
try {
|
||||
await this.runSync(state.userId, state.bookId, reasons);
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
if (state.reasons.size > 0) {
|
||||
this.schedule(state);
|
||||
} else {
|
||||
this.states.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runSync(userId: number, bookId: number, reasons: Set<HardcoverAutoSyncReason>): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const formattedReasons = this.formatReasons(reasons);
|
||||
|
||||
await this.enqueueUserSync(userId, async () => {
|
||||
try {
|
||||
if (!(await this.isAutoSyncEnabled(userId, reasons))) return;
|
||||
|
||||
await this.syncService.syncBook(userId, bookId);
|
||||
} catch (err) {
|
||||
this.logFailure({ userId, bookId, reasons: formattedReasons, startedAt, err });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveBookFileAndRequestSync(request: AutoSyncBookFileRequest): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const reasons = new Set<HardcoverAutoSyncReason>([request.reason]);
|
||||
const formattedReasons = this.formatReasons(reasons);
|
||||
|
||||
try {
|
||||
if (!(await this.isAutoSyncEnabled(request.userId, reasons))) return;
|
||||
|
||||
const bookId = await this.repository.findBookIdByFileId(request.bookFileId);
|
||||
if (!bookId) return;
|
||||
|
||||
this.requestSync({ userId: request.userId, bookId, reason: request.reason });
|
||||
} catch (err) {
|
||||
this.logFailure({ userId: request.userId, bookFileId: request.bookFileId, reasons: formattedReasons, startedAt, err });
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueUserSync(userId: number, task: () => Promise<void>): Promise<void> {
|
||||
const previous = this.userSyncQueues.get(userId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(task);
|
||||
this.userSyncQueues.set(userId, current);
|
||||
current
|
||||
.finally(() => {
|
||||
if (this.userSyncQueues.get(userId) === current) {
|
||||
this.userSyncQueues.delete(userId);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return current;
|
||||
}
|
||||
|
||||
private async isAutoSyncEnabled(userId: number, reasons: Set<HardcoverAutoSyncReason>): Promise<boolean> {
|
||||
const settings = await this.settingsService.getSettings(userId);
|
||||
return settings.effectiveEnabled && this.hasEnabledReason(settings, reasons);
|
||||
}
|
||||
|
||||
private hasEnabledReason(settings: HardcoverSettingsSnapshot, reasons: Set<HardcoverAutoSyncReason>): boolean {
|
||||
if (reasons.has('status') && settings.autoSyncOnStatusChange) return true;
|
||||
if (reasons.has('progress') && settings.autoSyncOnProgressUpdate) return true;
|
||||
if (reasons.has('rating') && settings.autoSyncOnRatingChange) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private formatReasons(reasons: Set<HardcoverAutoSyncReason>): string {
|
||||
return REASON_ORDER.filter((reason) => reasons.has(reason)).join(',');
|
||||
}
|
||||
|
||||
private logFailure(input: { userId: number; bookId?: number; bookFileId?: number; reasons: string; startedAt: number; err: unknown }): void {
|
||||
const errorClass = input.err instanceof Error ? input.err.constructor.name : 'Error';
|
||||
const error = sanitizeLogValue(input.err instanceof Error ? input.err.message : String(input.err));
|
||||
const bookField = input.bookId !== undefined ? `bookId=${input.bookId}` : `bookFileId=${input.bookFileId}`;
|
||||
this.logger.warn(
|
||||
`[${AUTO_SYNC_EVENT}] [fail] userId=${input.userId} ${bookField} reasons=${input.reasons} durationMs=${Date.now() - input.startedAt} errorClass=${errorClass} error="${error}" - auto-sync failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,22 @@ describe('HardcoverBookMatchService', () => {
|
||||
expect(result?.hardcoverBookId).toBe(77);
|
||||
});
|
||||
|
||||
it('matches by metadata slug when hardcoverMetadataId is not numeric', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue(undefined);
|
||||
mockClient.query.mockResolvedValue({ books: [{ id: 686104, editions: [{ id: 30673405, pages: 382 }] }] });
|
||||
const book = { ...baseBook, hardcoverMetadataId: 'fyrebirds', isbn13: null };
|
||||
|
||||
const result = await makeService().matchBook(1, 'tok', book);
|
||||
|
||||
expect(result).toEqual({ hardcoverBookId: 686104, hardcoverEditionId: 30673405, editionPages: 382, matchMethod: 'metadata_id' });
|
||||
expect(mockClient.query).toHaveBeenCalledWith(
|
||||
1,
|
||||
'tok',
|
||||
expect.stringContaining('query FindBookBySlug'),
|
||||
expect.objectContaining({ slug: 'fyrebirds' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to isbn13 when metadata_id fails', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue(undefined);
|
||||
mockClient.query.mockRejectedValueOnce(new Error('not found')).mockResolvedValueOnce({ books: [{ id: 88, editions: [{ id: 11 }] }] });
|
||||
|
||||
@@ -70,6 +70,17 @@ query FindBookById($id: Int!) {
|
||||
}
|
||||
}`;
|
||||
|
||||
const FIND_BOOK_BY_HARDCOVER_SLUG_QUERY = `
|
||||
query FindBookBySlug($slug: String!) {
|
||||
books(where: { slug: { _eq: $slug } }, limit: 1) {
|
||||
id
|
||||
editions(limit: 1) {
|
||||
id
|
||||
pages
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const FIND_BOOK_EDITIONS_BY_HARDCOVER_ID_QUERY = `
|
||||
query FindBookEditionsById($id: Int!) {
|
||||
books(where: { id: { _eq: $id } }, limit: 1) {
|
||||
@@ -133,6 +144,8 @@ export class HardcoverBookMatchService {
|
||||
const id = parseInt(book.hardcoverMetadataId, 10);
|
||||
if (!isNaN(id)) {
|
||||
match = await this.matchByHardcoverId(userId, token, id, book.bookId);
|
||||
} else {
|
||||
match = await this.matchByHardcoverSlug(userId, token, book.hardcoverMetadataId, book.bookId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +202,26 @@ export class HardcoverBookMatchService {
|
||||
}
|
||||
}
|
||||
|
||||
private async matchByHardcoverSlug(userId: number, token: string, slug: string, bookId: number): Promise<HardcoverBookMatch | null> {
|
||||
try {
|
||||
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOK_BY_HARDCOVER_SLUG_QUERY, { slug });
|
||||
const book = data.books?.[0];
|
||||
if (!book) return null;
|
||||
return {
|
||||
hardcoverBookId: book.id,
|
||||
hardcoverEditionId: book.editions?.[0]?.id ?? null,
|
||||
editionPages: book.editions?.[0]?.pages ?? null,
|
||||
matchMethod: 'metadata_id',
|
||||
};
|
||||
} catch (err) {
|
||||
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[hardcover.book_match] [fail] userId=${userId} bookId=${bookId} method=metadata_slug error="${error}" - metadata_slug lookup failed`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async matchByIsbn(userId: number, token: string, isbn: string, bookId: number, version: 10 | 13): Promise<HardcoverBookMatch | null> {
|
||||
const query = version === 13 ? FIND_BOOK_BY_ISBN13_QUERY : FIND_BOOK_BY_ISBN10_QUERY;
|
||||
try {
|
||||
|
||||
@@ -1,144 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
|
||||
AchievementEventsService,
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
|
||||
import { HardcoverEventListener } from './hardcover-event-listener.service';
|
||||
|
||||
const mockSettingsService = {
|
||||
getSettings: vi.fn(),
|
||||
isFeatureEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSyncService = {
|
||||
syncBook: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRepository = {
|
||||
findBookIdByFileId: vi.fn().mockResolvedValue(42),
|
||||
const mockScheduler = {
|
||||
requestSync: vi.fn(),
|
||||
requestSyncForBookFile: vi.fn(),
|
||||
};
|
||||
|
||||
function makeListener() {
|
||||
const events = new AchievementEventsService();
|
||||
const listener = new HardcoverEventListener(events, mockSettingsService as any, mockSyncService as any, mockRepository as any);
|
||||
const listener = new HardcoverEventListener(events, mockScheduler as unknown as HardcoverAutoSyncSchedulerService);
|
||||
listener.onModuleInit();
|
||||
return { events, listener };
|
||||
return { events };
|
||||
}
|
||||
|
||||
const enabledSettings = {
|
||||
tokenConfigured: true,
|
||||
enabled: true,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: 3,
|
||||
};
|
||||
|
||||
describe('HardcoverEventListener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSettingsService.isFeatureEnabled.mockResolvedValue(true);
|
||||
mockSyncService.syncBook.mockResolvedValue(undefined);
|
||||
mockRepository.findBookIdByFileId.mockResolvedValue(42);
|
||||
});
|
||||
|
||||
describe('status changed event', () => {
|
||||
it('syncs book on status change when enabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 10);
|
||||
});
|
||||
it('schedules status auto-sync on status change', () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
it('skips when feature disabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
mockSettingsService.isFeatureEnabled.mockResolvedValue(false);
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
|
||||
it('skips when token not configured', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, tokenConfigured: false });
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when autoSyncOnStatusChange disabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnStatusChange: false });
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when sync fails', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
mockSyncService.syncBook.mockRejectedValue(new Error('API error'));
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED, { userId: 1, bookId: 10, newStatus: 'reading', previousStatus: 'unread' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith({ userId: 1, bookId: 10, reason: 'status' });
|
||||
});
|
||||
|
||||
describe('reading session saved event', () => {
|
||||
it('resolves bookId from bookFileId and syncs', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, {
|
||||
userId: 1,
|
||||
bookFileId: 5,
|
||||
durationSeconds: 300,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
progressDelta: 10,
|
||||
endProgress: 50,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledWith(1, 42);
|
||||
it('schedules file progress auto-sync for reading sessions', () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, {
|
||||
userId: 1,
|
||||
bookFileId: 5,
|
||||
durationSeconds: 300,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
progressDelta: 10,
|
||||
endProgress: 50,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
it('skips when autoSyncOnProgressUpdate disabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnProgressUpdate: false });
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, {
|
||||
userId: 1,
|
||||
bookFileId: 5,
|
||||
durationSeconds: 300,
|
||||
startedAt: new Date(),
|
||||
endedAt: new Date(),
|
||||
progressDelta: 10,
|
||||
endProgress: 50,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockScheduler.requestSyncForBookFile).toHaveBeenCalledWith({ userId: 1, bookFileId: 5, reason: 'progress' });
|
||||
});
|
||||
|
||||
describe('rating changed event', () => {
|
||||
it('syncs each book when rating changes', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue(enabledSettings);
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1, 2, 3], rating: 4 });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).toHaveBeenCalledTimes(3);
|
||||
it('schedules progress auto-sync on book progress changes', () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
|
||||
userId: 1,
|
||||
bookId: 10,
|
||||
bookFileId: 5,
|
||||
progress: 40,
|
||||
source: 'koreader',
|
||||
});
|
||||
|
||||
it('skips when autoSyncOnRatingChange disabled', async () => {
|
||||
mockSettingsService.getSettings.mockResolvedValue({ ...enabledSettings, autoSyncOnRatingChange: false });
|
||||
const { events } = makeListener();
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1], rating: 4 });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mockSyncService.syncBook).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledWith({ userId: 1, bookId: 10, reason: 'progress' });
|
||||
});
|
||||
|
||||
it('schedules rating auto-sync for each affected book', () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, { userId: 1, bookIds: [1, 2, 3], rating: 4 });
|
||||
|
||||
expect(mockScheduler.requestSync).toHaveBeenCalledTimes(3);
|
||||
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(1, { userId: 1, bookId: 1, reason: 'rating' });
|
||||
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(2, { userId: 1, bookId: 2, reason: 'rating' });
|
||||
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(3, { userId: 1, bookId: 3, reason: 'rating' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
|
||||
AchievementEventsService,
|
||||
type BookProgressChangedPayload,
|
||||
type BookRatingChangedPayload,
|
||||
type BookStatusChangedPayload,
|
||||
type ReadingSessionSavedPayload,
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
|
||||
|
||||
@Injectable()
|
||||
export class HardcoverEventListener implements OnModuleInit {
|
||||
private readonly logger = new Logger(HardcoverEventListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
private readonly settingsService: HardcoverSettingsService,
|
||||
private readonly syncService: HardcoverSyncService,
|
||||
private readonly repository: HardcoverRepository,
|
||||
private readonly scheduler: HardcoverAutoSyncSchedulerService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -30,54 +26,33 @@ export class HardcoverEventListener implements OnModuleInit {
|
||||
});
|
||||
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_READING_SESSION_SAVED, (payload: ReadingSessionSavedPayload) => {
|
||||
void this.handleReadingSessionSaved(payload);
|
||||
this.handleReadingSessionSaved(payload);
|
||||
});
|
||||
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, (payload: BookProgressChangedPayload) => {
|
||||
this.handleProgressChanged(payload);
|
||||
});
|
||||
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, (payload: BookRatingChangedPayload) => {
|
||||
void this.handleRatingChanged(payload);
|
||||
this.handleRatingChanged(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleStatusChanged(payload: BookStatusChangedPayload): Promise<void> {
|
||||
const settings = await this.settingsService.getSettings(payload.userId);
|
||||
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnStatusChange) return;
|
||||
if (!(await this.settingsService.isFeatureEnabled())) return;
|
||||
|
||||
try {
|
||||
await this.syncService.syncBook(payload.userId, payload.bookId);
|
||||
} catch {
|
||||
this.logger.warn(`[hardcover.event_listener] userId=${payload.userId} bookId=${payload.bookId} event=status_changed - sync failed silently`);
|
||||
}
|
||||
private handleStatusChanged(payload: BookStatusChangedPayload): void {
|
||||
this.scheduler.requestSync({ userId: payload.userId, bookId: payload.bookId, reason: 'status' });
|
||||
}
|
||||
|
||||
private async handleReadingSessionSaved(payload: ReadingSessionSavedPayload): Promise<void> {
|
||||
const settings = await this.settingsService.getSettings(payload.userId);
|
||||
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnProgressUpdate) return;
|
||||
if (!(await this.settingsService.isFeatureEnabled())) return;
|
||||
|
||||
const bookId = await this.repository.findBookIdByFileId(payload.bookFileId);
|
||||
if (!bookId) return;
|
||||
|
||||
try {
|
||||
await this.syncService.syncBook(payload.userId, bookId);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`[hardcover.event_listener] userId=${payload.userId} bookFileId=${payload.bookFileId} event=reading_session_saved - sync failed silently`,
|
||||
);
|
||||
}
|
||||
private handleReadingSessionSaved(payload: ReadingSessionSavedPayload): void {
|
||||
this.scheduler.requestSyncForBookFile({ userId: payload.userId, bookFileId: payload.bookFileId, reason: 'progress' });
|
||||
}
|
||||
|
||||
private async handleRatingChanged(payload: BookRatingChangedPayload): Promise<void> {
|
||||
const settings = await this.settingsService.getSettings(payload.userId);
|
||||
if (!settings.tokenConfigured || !settings.enabled || !settings.autoSyncOnRatingChange) return;
|
||||
if (!(await this.settingsService.isFeatureEnabled())) return;
|
||||
private handleProgressChanged(payload: BookProgressChangedPayload): void {
|
||||
this.scheduler.requestSync({ userId: payload.userId, bookId: payload.bookId, reason: 'progress' });
|
||||
}
|
||||
|
||||
private handleRatingChanged(payload: BookRatingChangedPayload): void {
|
||||
for (const bookId of payload.bookIds) {
|
||||
try {
|
||||
await this.syncService.syncBook(payload.userId, bookId);
|
||||
} catch {
|
||||
this.logger.warn(`[hardcover.event_listener] userId=${payload.userId} bookId=${bookId} event=rating_changed - sync failed silently`);
|
||||
}
|
||||
this.scheduler.requestSync({ userId: payload.userId, bookId, reason: 'rating' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
|
||||
const mockRepo = {
|
||||
findSettings: vi.fn(),
|
||||
userHasHardcoverSyncPermission: vi.fn(),
|
||||
upsertSettings: vi.fn(),
|
||||
deleteSettings: vi.fn(),
|
||||
};
|
||||
@@ -13,30 +14,14 @@ const mockClient = {
|
||||
query: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAppSettings = {
|
||||
isHardcoverEnabled: vi.fn(),
|
||||
setHardcoverEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return new HardcoverSettingsService(mockRepo as any, mockClient as any, mockAppSettings as any);
|
||||
return new HardcoverSettingsService(mockRepo as any, mockClient as any);
|
||||
}
|
||||
|
||||
describe('HardcoverSettingsService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('returns true when enabled', async () => {
|
||||
mockAppSettings.isHardcoverEnabled.mockResolvedValue(true);
|
||||
expect(await makeService().isFeatureEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false by default', async () => {
|
||||
mockAppSettings.isHardcoverEnabled.mockResolvedValue(false);
|
||||
expect(await makeService().isFeatureEnabled()).toBe(false);
|
||||
});
|
||||
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
@@ -45,6 +30,8 @@ describe('HardcoverSettingsService', () => {
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.tokenConfigured).toBe(false);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('missing_token');
|
||||
expect(result.privacySettingId).toBe(3);
|
||||
});
|
||||
|
||||
@@ -61,11 +48,49 @@ describe('HardcoverSettingsService', () => {
|
||||
const result = await makeService().getSettings(1);
|
||||
expect(result.tokenConfigured).toBe(true);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.effectiveEnabled).toBe(true);
|
||||
expect(result.disabledReason).toBeNull();
|
||||
expect(result.autoSyncOnProgressUpdate).toBe(false);
|
||||
expect((result as any).apiToken).toBeUndefined();
|
||||
expect(result.lastSyncedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns user disabled effective state when configured sync is paused', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({
|
||||
apiToken: 'secret',
|
||||
enabled: false,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: 3,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
const result = await makeService().getSettings(1);
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('user_disabled');
|
||||
});
|
||||
|
||||
it('returns permission denied effective state when the user lacks Hardcover sync permission', async () => {
|
||||
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(false);
|
||||
mockRepo.findSettings.mockResolvedValue({
|
||||
apiToken: 'secret',
|
||||
enabled: true,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: 3,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
const result = await makeService().getSettings(1);
|
||||
|
||||
expect(result.effectiveEnabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('permission_denied');
|
||||
});
|
||||
|
||||
it('returns lastSyncedAt as ISO string when set', async () => {
|
||||
const syncedAt = new Date('2025-05-01T10:00:00Z');
|
||||
mockRepo.findSettings.mockResolvedValue({
|
||||
@@ -199,6 +224,12 @@ describe('HardcoverSettingsService', () => {
|
||||
expect(await makeService().getTokenForUser(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the user lacks Hardcover sync permission', async () => {
|
||||
mockRepo.userHasHardcoverSyncPermission.mockResolvedValue(false);
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok', enabled: true });
|
||||
expect(await makeService().getTokenForUser(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when disabled', async () => {
|
||||
mockRepo.findSettings.mockResolvedValue({ apiToken: 'tok', enabled: false });
|
||||
expect(await makeService().getTokenForUser(1)).toBeNull();
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { HardcoverAdminSettings, HardcoverSettings, HardcoverTokenValidationResult, UpsertHardcoverSettingsPayload } from '@bookorbit/types';
|
||||
import type {
|
||||
HardcoverSettings,
|
||||
HardcoverSyncDisabledReason,
|
||||
HardcoverTokenValidationResult,
|
||||
UpsertHardcoverSettingsPayload,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { HARDCOVER_PRIVACY } from './hardcover.constants';
|
||||
import { HardcoverClientService } from './hardcover-client.service';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
@@ -17,43 +21,23 @@ export class HardcoverSettingsService {
|
||||
constructor(
|
||||
private readonly repo: HardcoverRepository,
|
||||
private readonly client: HardcoverClientService,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
) {}
|
||||
|
||||
async isFeatureEnabled(): Promise<boolean> {
|
||||
return this.appSettings.isHardcoverEnabled();
|
||||
}
|
||||
|
||||
async setFeatureEnabled(enabled: boolean): Promise<void> {
|
||||
await this.appSettings.setHardcoverEnabled(enabled);
|
||||
this.logger.log(`[hardcover.admin_toggle] [end] enabled=${enabled} - feature toggled`);
|
||||
}
|
||||
|
||||
async getAdminSettings(): Promise<HardcoverAdminSettings> {
|
||||
return { featureEnabled: await this.isFeatureEnabled() };
|
||||
}
|
||||
|
||||
async getSettings(userId: number): Promise<HardcoverSettings> {
|
||||
const row = await this.repo.findSettings(userId);
|
||||
if (!row) {
|
||||
return {
|
||||
tokenConfigured: false,
|
||||
enabled: false,
|
||||
autoSyncOnStatusChange: true,
|
||||
autoSyncOnProgressUpdate: true,
|
||||
autoSyncOnRatingChange: true,
|
||||
privacySettingId: HARDCOVER_PRIVACY.PRIVATE,
|
||||
lastSyncedAt: null,
|
||||
};
|
||||
}
|
||||
const [row, hasSyncPermission] = await Promise.all([this.repo.findSettings(userId), this.repo.userHasHardcoverSyncPermission(userId)]);
|
||||
const tokenConfigured = Boolean(row?.apiToken);
|
||||
const enabled = row?.enabled ?? false;
|
||||
|
||||
return {
|
||||
tokenConfigured: true,
|
||||
enabled: row.enabled,
|
||||
autoSyncOnStatusChange: row.autoSyncOnStatusChange,
|
||||
autoSyncOnProgressUpdate: row.autoSyncOnProgressUpdate,
|
||||
autoSyncOnRatingChange: row.autoSyncOnRatingChange,
|
||||
privacySettingId: row.privacySettingId,
|
||||
lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
|
||||
tokenConfigured,
|
||||
enabled,
|
||||
effectiveEnabled: hasSyncPermission && tokenConfigured && enabled,
|
||||
disabledReason: this.resolveDisabledReason({ hasSyncPermission, tokenConfigured, enabled }),
|
||||
autoSyncOnStatusChange: row?.autoSyncOnStatusChange ?? true,
|
||||
autoSyncOnProgressUpdate: row?.autoSyncOnProgressUpdate ?? true,
|
||||
autoSyncOnRatingChange: row?.autoSyncOnRatingChange ?? true,
|
||||
privacySettingId: row?.privacySettingId ?? HARDCOVER_PRIVACY.PRIVATE,
|
||||
lastSyncedAt: row?.lastSyncedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +97,8 @@ export class HardcoverSettingsService {
|
||||
}
|
||||
|
||||
async getTokenForUser(userId: number): Promise<string | null> {
|
||||
const settings = await this.repo.findSettings(userId);
|
||||
const [settings, hasSyncPermission] = await Promise.all([this.repo.findSettings(userId), this.repo.userHasHardcoverSyncPermission(userId)]);
|
||||
if (!hasSyncPermission) return null;
|
||||
if (!settings || !settings.enabled) return null;
|
||||
return settings.apiToken;
|
||||
}
|
||||
@@ -123,4 +108,15 @@ export class HardcoverSettingsService {
|
||||
const lower = token.toLowerCase();
|
||||
return lower.startsWith('bearer ') ? token.slice(7).trim() : token;
|
||||
}
|
||||
|
||||
private resolveDisabledReason(input: {
|
||||
hasSyncPermission: boolean;
|
||||
tokenConfigured: boolean;
|
||||
enabled: boolean;
|
||||
}): HardcoverSyncDisabledReason | null {
|
||||
if (!input.hasSyncPermission) return 'permission_denied';
|
||||
if (!input.tokenConfigured) return 'missing_token';
|
||||
if (!input.enabled) return 'user_disabled';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
@@ -54,6 +55,10 @@ const readingBook = {
|
||||
describe('HardcoverSyncService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepo.findBookState.mockResolvedValue(undefined);
|
||||
mockRepo.findBookStatesByBookIds.mockResolvedValue([]);
|
||||
mockRepo.findSyncableBooks.mockResolvedValue([]);
|
||||
mockRepo.findSyncableBook.mockResolvedValue(null);
|
||||
mockRepo.upsertBookState.mockResolvedValue({});
|
||||
mockRepo.updateLastSyncedAt.mockResolvedValue(undefined);
|
||||
mockSettingsService.getSettings.mockResolvedValue(defaultSettings);
|
||||
@@ -76,16 +81,58 @@ describe('HardcoverSyncService', () => {
|
||||
it('does nothing for unread status', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findSyncableBook.mockResolvedValue({ ...readingBook, status: 'unread' });
|
||||
await makeService().syncBook(1, 1);
|
||||
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
|
||||
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips when the local sync snapshot has no changes', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
|
||||
mockRepo.findBookState.mockResolvedValue({
|
||||
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
|
||||
lastSyncedStatus: 'reading',
|
||||
lastSyncedProgress: 42,
|
||||
lastSyncedRating: null,
|
||||
lastSyncedStartedAt: '2024-01-01',
|
||||
lastSyncedFinishedAt: null,
|
||||
});
|
||||
|
||||
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
|
||||
|
||||
expect(mockMatchService.matchBook).not.toHaveBeenCalled();
|
||||
expect(mockClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries an unchanged snapshot when a Hardcover metadata id was added after a failed match', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const bookWithMetadataId = { ...readingBook, hardcoverMetadataId: 'fyrebirds' };
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findSyncableBook.mockResolvedValue(bookWithMetadataId);
|
||||
mockRepo.findBookState.mockResolvedValue({
|
||||
hardcoverBookId: null,
|
||||
syncError: 'no_match',
|
||||
lastSyncedAt: new Date('2024-02-01T00:00:00Z'),
|
||||
lastSyncedStatus: 'reading',
|
||||
lastSyncedProgress: 42,
|
||||
lastSyncedRating: null,
|
||||
lastSyncedStartedAt: '2024-01-01',
|
||||
lastSyncedFinishedAt: null,
|
||||
});
|
||||
mockMatchService.matchBook.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
|
||||
|
||||
expect(mockMatchService.matchBook).toHaveBeenCalledWith(1, 'tok', bookWithMetadataId);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('stores no_match error when match fails', async () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
|
||||
mockMatchService.matchBook.mockResolvedValue(null);
|
||||
mockRepo.findBookState.mockResolvedValue(null);
|
||||
await makeService().syncBook(1, 1);
|
||||
await expect(makeService().syncBook(1, 1)).resolves.toBe('skipped');
|
||||
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
syncError: 'no_match',
|
||||
@@ -95,6 +142,9 @@ describe('HardcoverSyncService', () => {
|
||||
lastSyncedStartedAt: '2024-01-01',
|
||||
}),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.sync_book] [fail] userId=1 bookId=1'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('errorClass=MatchError error="no_match"'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('syncs book successfully', async () => {
|
||||
@@ -107,7 +157,7 @@ describe('HardcoverSyncService', () => {
|
||||
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
|
||||
.mockResolvedValueOnce({ user_book_reads: [] })
|
||||
.mockResolvedValueOnce({ insert_user_book_read: { user_book_read: { id: 77 }, error: null } });
|
||||
await makeService().syncBook(1, 1);
|
||||
await expect(makeService().syncBook(1, 1)).resolves.toBe('synced');
|
||||
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hardcoverUserBookId: 55,
|
||||
@@ -146,6 +196,7 @@ describe('HardcoverSyncService', () => {
|
||||
});
|
||||
|
||||
it('syncs progress to sibling unfinished reads to avoid page 0 in Hardcover UI', async () => {
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findSyncableBook.mockResolvedValue(readingBook);
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverReadId: 900 });
|
||||
@@ -171,6 +222,9 @@ describe('HardcoverSyncService', () => {
|
||||
expect.stringContaining('mutation UpdateUserBookRead'),
|
||||
expect.objectContaining({ id: 899, object: expect.objectContaining({ progress_pages: 126 }) }),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.sync_progress] [end] userId=1 bookId=1'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('progress=42 progressPages=126 - progress sent to Hardcover'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps progress pending when edition pages are unavailable', async () => {
|
||||
|
||||
@@ -89,6 +89,9 @@ type FindUserBookReadsResult = {
|
||||
user_book_reads: Array<{ id: number; started_at: string | null; finished_at: string | null; progress_pages: number | null }>;
|
||||
};
|
||||
|
||||
export type HardcoverSyncBookResult = 'synced' | 'skipped' | 'failed';
|
||||
type HardcoverBookStateSnapshot = Awaited<ReturnType<HardcoverRepository['findBookState']>>;
|
||||
|
||||
function toDateString(d: Date | null | undefined): string | null {
|
||||
if (!d) return null;
|
||||
return d instanceof Date ? d.toISOString().split('T')[0]! : null;
|
||||
@@ -109,22 +112,22 @@ export class HardcoverSyncService {
|
||||
private readonly settingsService: HardcoverSettingsService,
|
||||
) {}
|
||||
|
||||
async syncBook(userId: number, bookId: number): Promise<void> {
|
||||
async syncBook(userId: number, bookId: number): Promise<HardcoverSyncBookResult> {
|
||||
const token = await this.settingsService.getTokenForUser(userId);
|
||||
if (!token) return;
|
||||
if (!token) return 'skipped';
|
||||
|
||||
const book = await this.repo.findSyncableBook(userId, bookId);
|
||||
if (!book) return;
|
||||
if (!book) return 'skipped';
|
||||
|
||||
if (book.status === 'unread') return;
|
||||
if (book.status === 'unread') return 'skipped';
|
||||
|
||||
await this.syncSingleBook(userId, token, book);
|
||||
const state = await this.repo.findBookState(userId, book.bookId);
|
||||
if (!this.hasChanges(book, state)) return 'skipped';
|
||||
|
||||
return this.syncSingleBook(userId, token, book, state);
|
||||
}
|
||||
|
||||
async syncAll(userId: number): Promise<number> {
|
||||
const token = await this.settingsService.getTokenForUser(userId);
|
||||
if (!token) return 0;
|
||||
|
||||
const existing = this.activeSyncs.get(userId);
|
||||
if (existing) {
|
||||
this.logger.warn(`[hardcover.sync_all] userId=${userId} runId=${existing.runId} - sync already running`);
|
||||
@@ -132,6 +135,9 @@ export class HardcoverSyncService {
|
||||
return existing.runId;
|
||||
}
|
||||
|
||||
const token = await this.settingsService.getTokenForUser(userId);
|
||||
if (!token) return 0;
|
||||
|
||||
const books = await this.repo.findSyncableBooks(userId);
|
||||
|
||||
// Re-check: a concurrent syncAll may have won the race during findSyncableBooks
|
||||
@@ -247,7 +253,7 @@ export class HardcoverSyncService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.syncSingleBook(userId, token, book);
|
||||
const result = await this.syncSingleBook(userId, token, book, state);
|
||||
if (result === 'synced') synced++;
|
||||
else if (result === 'skipped') skipped++;
|
||||
else failed++;
|
||||
@@ -279,7 +285,12 @@ export class HardcoverSyncService {
|
||||
this.emitSyncStatus(userId, activeStatus ?? null);
|
||||
}
|
||||
|
||||
private async syncSingleBook(userId: number, token: string, book: BookSyncData): Promise<'synced' | 'skipped' | 'failed'> {
|
||||
private async syncSingleBook(
|
||||
userId: number,
|
||||
token: string,
|
||||
book: BookSyncData,
|
||||
initialState?: HardcoverBookStateSnapshot,
|
||||
): Promise<HardcoverSyncBookResult> {
|
||||
const startedAt = Date.now();
|
||||
const settings = await this.settingsService.getSettings(userId);
|
||||
|
||||
@@ -296,7 +307,7 @@ export class HardcoverSyncService {
|
||||
|
||||
const match = await this.matchService.matchBook(userId, token, book);
|
||||
if (!match) {
|
||||
const state = await this.repo.findBookState(userId, book.bookId);
|
||||
const state = initialState ?? (await this.repo.findBookState(userId, book.bookId));
|
||||
await this.repo.upsertBookState({
|
||||
userId,
|
||||
bookId: book.bookId,
|
||||
@@ -304,6 +315,9 @@ export class HardcoverSyncService {
|
||||
syncError: 'no_match',
|
||||
...this.buildAttemptSnapshot(book),
|
||||
});
|
||||
this.logger.warn(
|
||||
`[hardcover.sync_book] [fail] userId=${userId} bookId=${book.bookId} durationMs=${Date.now() - startedAt} errorClass=MatchError error="no_match" - Hardcover book match not found`,
|
||||
);
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
@@ -325,9 +339,10 @@ export class HardcoverSyncService {
|
||||
});
|
||||
|
||||
const state = await this.repo.findBookState(userId, book.bookId);
|
||||
const syncState = initialState ?? state;
|
||||
const startDate = toDateString(book.startedAt);
|
||||
const endDate = toDateString(book.finishedAt);
|
||||
let hardcoverReadId: number | null = state?.hardcoverReadId ?? null;
|
||||
let hardcoverReadId: number | null = syncState?.hardcoverReadId ?? null;
|
||||
let progressSynced = true;
|
||||
|
||||
if (startDate || endDate || book.progress != null) {
|
||||
@@ -355,6 +370,10 @@ export class HardcoverSyncService {
|
||||
progressPages,
|
||||
editionId: match.hardcoverEditionId ?? undefined,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[hardcover.sync_progress] [end] userId=${userId} bookId=${book.bookId} hardcoverBookId=${match.hardcoverBookId} hardcoverReadId=${hardcoverReadId} durationMs=${Date.now() - startedAt} progress=${book.progress} progressPages=${progressPages} - progress sent to Hardcover`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +563,9 @@ export class HardcoverSyncService {
|
||||
|
||||
private hasChanges(book: BookSyncData, state: Awaited<ReturnType<HardcoverRepository['findBookState']>>): boolean {
|
||||
if (!state?.lastSyncedAt) return true;
|
||||
if (book.hardcoverMetadataId && state.syncError === 'no_match') return true;
|
||||
const metadataHardcoverId = this.parseNumericHardcoverMetadataId(book.hardcoverMetadataId);
|
||||
if (metadataHardcoverId !== null && metadataHardcoverId !== state.hardcoverBookId) return true;
|
||||
if (book.status !== state.lastSyncedStatus) return true;
|
||||
if (book.progress !== state.lastSyncedProgress) return true;
|
||||
if (book.rating !== state.lastSyncedRating) return true;
|
||||
@@ -554,6 +576,12 @@ export class HardcoverSyncService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseNumericHardcoverMetadataId(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const id = parseInt(value, 10);
|
||||
return isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
private buildAttemptSnapshot(book: BookSyncData) {
|
||||
return {
|
||||
lastSyncedAt: new Date(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AchievementModule } from '../achievement/achievement.module';
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { HardcoverAdminController } from './hardcover-admin.controller';
|
||||
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
|
||||
import { HardcoverBookMatchService } from './hardcover-book-match.service';
|
||||
import { HardcoverClientService } from './hardcover-client.service';
|
||||
import { HardcoverController } from './hardcover.controller';
|
||||
@@ -13,8 +12,8 @@ import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
|
||||
@Module({
|
||||
imports: [AchievementModule, AppSettingsModule],
|
||||
controllers: [HardcoverController, HardcoverAdminController],
|
||||
imports: [AchievementModule],
|
||||
controllers: [HardcoverController],
|
||||
providers: [
|
||||
HardcoverQueueService,
|
||||
HardcoverClientService,
|
||||
@@ -22,6 +21,7 @@ import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
HardcoverSettingsService,
|
||||
HardcoverBookMatchService,
|
||||
HardcoverSyncService,
|
||||
HardcoverAutoSyncSchedulerService,
|
||||
HardcoverEventListener,
|
||||
],
|
||||
exports: [HardcoverSyncService, HardcoverSettingsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
|
||||
@@ -37,6 +38,10 @@ function makeRepository() {
|
||||
const bookIdLimit = vi.fn().mockResolvedValue([{ bookId: 42 }]);
|
||||
const bookIdWhere = vi.fn().mockReturnValue({ limit: bookIdLimit });
|
||||
const bookIdFrom = vi.fn().mockReturnValue({ where: bookIdWhere });
|
||||
const permissionLimit = vi.fn().mockResolvedValue([{ isSuperuser: false, permissionName: Permission.HardcoverSync }]);
|
||||
const permissionWhere = vi.fn().mockReturnValue({ limit: permissionLimit });
|
||||
const permissionLeftJoin = vi.fn().mockReturnValue({ where: permissionWhere });
|
||||
const permissionFrom = vi.fn().mockReturnValue({ leftJoin: permissionLeftJoin });
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
@@ -60,6 +65,8 @@ function makeRepository() {
|
||||
updateChain,
|
||||
bookIdLimit,
|
||||
bookIdWhere,
|
||||
permissionLimit,
|
||||
permissionFrom,
|
||||
settingsRow,
|
||||
bookStateRow,
|
||||
};
|
||||
@@ -137,10 +144,13 @@ describe('HardcoverRepository', () => {
|
||||
|
||||
it('findSyncableBook returns a book from findSyncableBooks', async () => {
|
||||
const { repo } = makeRepository();
|
||||
vi.spyOn(repo, 'findSyncableBooks').mockResolvedValue([{ bookId: 42 } as any]);
|
||||
const findSyncableBooksForUser = vi.spyOn(repo as any, 'findSyncableBooksForUser');
|
||||
findSyncableBooksForUser.mockResolvedValueOnce([{ bookId: 42 }]).mockResolvedValueOnce([]);
|
||||
|
||||
await expect(repo.findSyncableBook(7, 42)).resolves.toEqual({ bookId: 42 });
|
||||
await expect(repo.findSyncableBook(7, 99)).resolves.toBeNull();
|
||||
expect(findSyncableBooksForUser).toHaveBeenNthCalledWith(1, 7, 42);
|
||||
expect(findSyncableBooksForUser).toHaveBeenNthCalledWith(2, 7, 99);
|
||||
});
|
||||
|
||||
it('findBookIdByFileId returns the first matching book id', async () => {
|
||||
@@ -150,4 +160,27 @@ describe('HardcoverRepository', () => {
|
||||
expect(bookIdLimit).toHaveBeenCalledWith(1);
|
||||
expect(bookIdWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('userHasHardcoverSyncPermission returns true for a user with the permission', async () => {
|
||||
const { repo, db, permissionFrom } = makeRepository();
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('userHasHardcoverSyncPermission returns true for a superuser', async () => {
|
||||
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
|
||||
permissionLimit.mockResolvedValueOnce([{ isSuperuser: true, permissionName: null }]);
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('userHasHardcoverSyncPermission returns false without an active matching user permission row', async () => {
|
||||
const { repo, db, permissionFrom, permissionLimit } = makeRepository();
|
||||
permissionLimit.mockResolvedValueOnce([{ isSuperuser: false, permissionName: null }]);
|
||||
db.select.mockReturnValueOnce({ from: permissionFrom });
|
||||
|
||||
await expect(repo.userHasHardcoverSyncPermission(7)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { and, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
@@ -53,6 +54,23 @@ export class HardcoverRepository {
|
||||
await this.db.delete(schema.hardcoverUserSettings).where(eq(schema.hardcoverUserSettings.userId, userId));
|
||||
}
|
||||
|
||||
async userHasHardcoverSyncPermission(userId: number): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
isSuperuser: schema.users.isSuperuser,
|
||||
permissionName: schema.userPermissions.permissionName,
|
||||
})
|
||||
.from(schema.users)
|
||||
.leftJoin(
|
||||
schema.userPermissions,
|
||||
and(eq(schema.userPermissions.userId, schema.users.id), eq(schema.userPermissions.permissionName, Permission.HardcoverSync)),
|
||||
)
|
||||
.where(and(eq(schema.users.id, userId), eq(schema.users.active, true)))
|
||||
.limit(1);
|
||||
|
||||
return row?.isSuperuser === true || row?.permissionName === Permission.HardcoverSync;
|
||||
}
|
||||
|
||||
// ---- Book State ----
|
||||
|
||||
async findBookState(userId: number, bookId: number): Promise<HardcoverBookState | undefined> {
|
||||
@@ -89,6 +107,17 @@ export class HardcoverRepository {
|
||||
// ---- Books for sync ----
|
||||
|
||||
async findSyncableBooks(userId: number): Promise<BookSyncData[]> {
|
||||
return this.findSyncableBooksForUser(userId);
|
||||
}
|
||||
|
||||
async findSyncableBook(userId: number, bookId: number): Promise<BookSyncData | null> {
|
||||
const [row] = await this.findSyncableBooksForUser(userId, bookId);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private async findSyncableBooksForUser(userId: number, bookId?: number): Promise<BookSyncData[]> {
|
||||
const bookFilter = bookId !== undefined ? eq(schema.books.id, bookId) : undefined;
|
||||
|
||||
const maxProgressSq = this.db
|
||||
.select({
|
||||
bookId: schema.books.id,
|
||||
@@ -97,6 +126,7 @@ export class HardcoverRepository {
|
||||
.from(schema.books)
|
||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.bookId, schema.books.id))
|
||||
.innerJoin(schema.readingProgress, and(eq(schema.readingProgress.bookFileId, schema.bookFiles.id), eq(schema.readingProgress.userId, userId)))
|
||||
.where(bookFilter)
|
||||
.groupBy(schema.books.id)
|
||||
.as('max_progress_sq');
|
||||
|
||||
@@ -107,6 +137,7 @@ export class HardcoverRepository {
|
||||
})
|
||||
.from(schema.bookAuthors)
|
||||
.innerJoin(schema.authors, eq(schema.authors.id, schema.bookAuthors.authorId))
|
||||
.where(bookId !== undefined ? eq(schema.bookAuthors.bookId, bookId) : undefined)
|
||||
.groupBy(schema.bookAuthors.bookId)
|
||||
.as('first_author_sq');
|
||||
|
||||
@@ -132,16 +163,12 @@ export class HardcoverRepository {
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.leftJoin(schema.userBookRatings, and(eq(schema.userBookRatings.bookId, schema.books.id), eq(schema.userBookRatings.userId, userId)))
|
||||
.leftJoin(maxProgressSq, eq(maxProgressSq.bookId, schema.books.id))
|
||||
.leftJoin(firstAuthorSq, eq(firstAuthorSq.bookId, schema.books.id));
|
||||
.leftJoin(firstAuthorSq, eq(firstAuthorSq.bookId, schema.books.id))
|
||||
.where(bookFilter);
|
||||
|
||||
return rows as BookSyncData[];
|
||||
}
|
||||
|
||||
async findSyncableBook(userId: number, bookId: number): Promise<BookSyncData | null> {
|
||||
const rows = await this.findSyncableBooks(userId);
|
||||
return rows.find((r) => r.bookId === bookId) ?? null;
|
||||
}
|
||||
|
||||
async findBookIdByFileId(bookFileId: number): Promise<number | null> {
|
||||
const [row] = await this.db
|
||||
.select({ bookId: schema.bookFiles.bookId })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AchievementModule } from '../achievement/achievement.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
|
||||
import { KoreaderAuthGuard } from './koreader-auth.guard';
|
||||
@@ -11,7 +12,7 @@ import { KoreaderRepository } from './koreader.repository';
|
||||
import { KoreaderService } from './koreader.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonModule, UserModule, UserBookStatusModule],
|
||||
imports: [CommonModule, UserModule, UserBookStatusModule, AchievementModule],
|
||||
controllers: [KoreaderController],
|
||||
providers: [KoreaderService, KoreaderRepository, KoreaderAuthGuard, KoreaderChapterService, KoreaderChapterExtractorService],
|
||||
exports: [KoreaderService, KoreaderRepository],
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('crypto', () => ({
|
||||
createHash: createHashMock,
|
||||
}));
|
||||
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
|
||||
import { KoreaderChapterService } from './koreader-chapter.service';
|
||||
@@ -73,6 +74,9 @@ describe('KoreaderService', () => {
|
||||
let mockUserBookStatusService: {
|
||||
autoUpdate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockAchievementEvents: {
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -125,6 +129,10 @@ describe('KoreaderService', () => {
|
||||
autoUpdate: vi.fn(),
|
||||
};
|
||||
|
||||
mockAchievementEvents = {
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
mockRepo.deleteKoreaderUser.mockResolvedValue(undefined);
|
||||
mockRepo.updateKoreaderUser.mockResolvedValue(undefined);
|
||||
mockRepo.upsertDeviceProgress.mockResolvedValue(undefined);
|
||||
@@ -143,6 +151,7 @@ describe('KoreaderService', () => {
|
||||
mockChapterService as unknown as KoreaderChapterService,
|
||||
mockChapterExtractor as unknown as KoreaderChapterExtractorService,
|
||||
mockUserBookStatusService as unknown as UserBookStatusService,
|
||||
mockAchievementEvents as unknown as AchievementEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -320,6 +329,16 @@ describe('KoreaderService', () => {
|
||||
});
|
||||
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledWith(44, 12, 50);
|
||||
expect(mockUserBookStatusService.autoUpdate).toHaveBeenCalledWith(12, 55, 50);
|
||||
expect(mockAchievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
|
||||
userId: 12,
|
||||
bookId: 55,
|
||||
bookFileId: 44,
|
||||
progress: 50,
|
||||
source: 'koreader',
|
||||
});
|
||||
expect(mockAchievementEvents.emit.mock.invocationCallOrder[0]!).toBeGreaterThan(
|
||||
mockUserBookStatusService.autoUpdate.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
document: 'abcdef1234567890fedcba',
|
||||
timestamp: 1700000000,
|
||||
@@ -339,6 +358,7 @@ describe('KoreaderService', () => {
|
||||
expect(mockRepo.upsertDeviceProgress).not.toHaveBeenCalled();
|
||||
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
|
||||
expect(mockUserBookStatusService.autoUpdate).not.toHaveBeenCalled();
|
||||
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes empty accessible library lists to hash resolution', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { KoreaderRepository } from './koreader.repository';
|
||||
import { KoreaderChapterService } from './koreader-chapter.service';
|
||||
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
|
||||
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const SYNC_EVENT = 'koreader.sync';
|
||||
@@ -22,6 +23,7 @@ export class KoreaderService {
|
||||
private readonly chapterService: KoreaderChapterService,
|
||||
private readonly chapterExtractor: KoreaderChapterExtractorService,
|
||||
private readonly userBookStatusService: UserBookStatusService,
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
) {}
|
||||
|
||||
async createCredentials(userId: number, username: string, password: string) {
|
||||
@@ -134,6 +136,13 @@ export class KoreaderService {
|
||||
const bookorbitPercentage = toBookorbitPercentage(data.percentage);
|
||||
await this.repo.upsertReadingProgress(bookFile.id, userId, bookorbitPercentage);
|
||||
await this.userBookStatusService.autoUpdate(userId, bookFile.bookId, bookorbitPercentage);
|
||||
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
|
||||
userId,
|
||||
bookId: bookFile.bookId,
|
||||
bookFileId: bookFile.id,
|
||||
progress: bookorbitPercentage,
|
||||
source: 'koreader',
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`[${SYNC_EVENT}] [end] userId=${userId} bookFileId=${bookFile.id} device=${device} durationMs=${Date.now() - startedAt} percentage=${data.percentage} - save progress completed`,
|
||||
|
||||
@@ -40,7 +40,6 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
|
||||
{ key: APP_SETTING_KEYS.AUDIT_RETENTION_DAYS, value: String(DEFAULT_AUDIT_RETENTION_DAYS) },
|
||||
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
|
||||
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
|
||||
{ key: APP_SETTING_KEYS.HARDCOVER_ENABLED, value: 'false' },
|
||||
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user