mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(library): support fractional finished thresholds
Allow 0.05% adjustments while preserving existing integer settings and the 98% default. Keep finish detection stable when progress is stored as PostgreSQL real.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber, formatPercent } from '@/i18n/formatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -19,9 +20,13 @@ function onReadingThresholdInput(e: Event) {
|
||||
}
|
||||
|
||||
function onFinishPercentInput(e: Event) {
|
||||
const val = parseInt((e.target as HTMLInputElement).value, 10)
|
||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||
if (!isNaN(val)) emit('update:markAsFinishedPercentComplete', val)
|
||||
}
|
||||
|
||||
function formatFinishedThreshold(value: number): string {
|
||||
return formatNumber(value / 100, { style: 'percent', maximumFractionDigits: 2 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,7 +40,7 @@ function onFinishPercentInput(e: Event) {
|
||||
<output for="reading-threshold" class="text-sm font-medium text-foreground tabular-nums">{{ readingThreshold }}%</output>
|
||||
</div>
|
||||
<p id="reading-threshold-help" class="text-xs text-muted-foreground mb-3">
|
||||
A book is marked as "reading" once this percentage of progress is reached.
|
||||
{{ t('library.creator.reading.readingStart.hint') }}
|
||||
</p>
|
||||
<input
|
||||
id="reading-threshold"
|
||||
@@ -60,10 +65,12 @@ function onFinishPercentInput(e: Event) {
|
||||
<label for="finished-threshold" class="text-[11px] font-semibold uppercase tracking-widest text-foreground">
|
||||
{{ t('library.creator.reading.markAsFinished.title') }}
|
||||
</label>
|
||||
<output for="finished-threshold" class="text-sm font-medium text-foreground tabular-nums"> {{ markAsFinishedPercentComplete }}% </output>
|
||||
<output for="finished-threshold" class="text-sm font-medium text-foreground tabular-nums">
|
||||
{{ formatFinishedThreshold(markAsFinishedPercentComplete) }}
|
||||
</output>
|
||||
</div>
|
||||
<p id="finished-threshold-help" class="text-xs text-muted-foreground mb-3">
|
||||
A book is automatically marked as "read" when this percentage of progress is reached.
|
||||
{{ t('library.creator.reading.markAsFinished.hint') }}
|
||||
</p>
|
||||
<input
|
||||
id="finished-threshold"
|
||||
@@ -71,14 +78,14 @@ function onFinishPercentInput(e: Event) {
|
||||
:value="markAsFinishedPercentComplete"
|
||||
min="90"
|
||||
max="100"
|
||||
step="1"
|
||||
step="0.05"
|
||||
class="w-full accent-primary"
|
||||
aria-describedby="finished-threshold-help"
|
||||
@input="onFinishPercentInput"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>90%</span>
|
||||
<span>100%</span>
|
||||
<span>{{ formatPercent(0.9) }}</span>
|
||||
<span>{{ formatPercent(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import LibraryCreatorReading from '../LibraryCreatorReading.vue'
|
||||
|
||||
describe('LibraryCreatorReading', () => {
|
||||
it('shows and emits a finished threshold at the requested 0.05% precision', async () => {
|
||||
const wrapper = mount(LibraryCreatorReading, {
|
||||
props: { readingThreshold: 0.25, markAsFinishedPercentComplete: 99.95 },
|
||||
})
|
||||
|
||||
const slider = wrapper.get<HTMLInputElement>('#finished-threshold')
|
||||
expect(slider.attributes('step')).toBe('0.05')
|
||||
expect(wrapper.get('output[for="finished-threshold"]').text()).toBe('99.95%')
|
||||
|
||||
await slider.setValue('98.05')
|
||||
expect(wrapper.emitted('update:markAsFinishedPercentComplete')).toContainEqual([98.05])
|
||||
})
|
||||
})
|
||||
@@ -125,6 +125,23 @@ describe('useLibraryCreator', () => {
|
||||
expect(creator.form.fileWriteAudioMaxFileSizeMb).toBe(500)
|
||||
})
|
||||
|
||||
it('accepts and sends a fractional finished threshold', async () => {
|
||||
const { useLibraryCreator } = await import('../useLibraryCreator')
|
||||
const creator = useLibraryCreator()
|
||||
apiMock.mockResolvedValue(jsonResponse(makeLibrary({ markAsFinishedPercentComplete: 99.95 }), 201))
|
||||
creator.form.name = 'Main Library'
|
||||
creator.form.icon = 'BookOpen'
|
||||
creator.form.folders = ['/books']
|
||||
creator.form.markAsFinishedPercentComplete = 99.95
|
||||
|
||||
expect(creator.validationErrors.value.reading).toBeUndefined()
|
||||
await creator.save()
|
||||
expect(JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string).markAsFinishedPercentComplete).toBe(99.95)
|
||||
|
||||
creator.form.markAsFinishedPercentComplete = 100.05
|
||||
expect(creator.validationErrors.value.reading).toBe('Finished progress must be between 90% and 100%.')
|
||||
})
|
||||
|
||||
it('hydrates file rename and audio write settings when editing a library', async () => {
|
||||
const { useLibraryCreator } = await import('../useLibraryCreator')
|
||||
const creator = useLibraryCreator()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
withReadAlongFormatPriority,
|
||||
} from '@bookorbit/types'
|
||||
import type { AddedAtSource, CoverAspectRatio, Library, LibraryType, OrganizationMode, PrescanResult } from '@bookorbit/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import { coveringFolderPath, normalizeFolderPath } from './folder-paths'
|
||||
|
||||
export { DEFAULT_FORMAT_PRIORITY }
|
||||
@@ -86,11 +87,11 @@ export function useLibraryCreator() {
|
||||
if (form.readingThreshold < 0.05 || form.readingThreshold > 5) {
|
||||
errors.reading = 'Reading start must be between 0.05% and 5%.'
|
||||
} else if (
|
||||
!Number.isInteger(form.markAsFinishedPercentComplete) ||
|
||||
!Number.isFinite(form.markAsFinishedPercentComplete) ||
|
||||
form.markAsFinishedPercentComplete < 90 ||
|
||||
form.markAsFinishedPercentComplete > 100
|
||||
) {
|
||||
errors.reading = 'Finished progress must be a whole number between 90% and 100%.'
|
||||
errors.reading = i18n.global.t('library.creator.reading.markAsFinished.invalidThreshold')
|
||||
}
|
||||
const fileSizes = [
|
||||
form.fileWriteEpubMaxFileSizeMb,
|
||||
|
||||
@@ -546,6 +546,20 @@ describe('LibrariesSettings ledger', () => {
|
||||
expect(finishedLabel?.element.nextElementSibling?.textContent?.trim()).toBe('98%')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ threshold: 90, expected: '90%' },
|
||||
{ threshold: 98.05, expected: '98.05%' },
|
||||
{ threshold: 99.95, expected: '99.95%' },
|
||||
{ threshold: 100, expected: '100%' },
|
||||
])('shows a $threshold% finished threshold as $expected', async ({ threshold, expected }) => {
|
||||
librariesRef.value = [makeLibrary({ id: 4, readingThreshold: 0.25, markAsFinishedPercentComplete: threshold })]
|
||||
const wrapper = await mountLoaded()
|
||||
await expandFirst(wrapper)
|
||||
const panel = wrapper.get('[id="library-detail-4"]')
|
||||
const finishedLabel = panel.findAll('dt').find((label) => label.text() === 'Counts as finished')
|
||||
expect(finishedLabel?.element.nextElementSibling?.textContent?.trim()).toBe(expected)
|
||||
})
|
||||
|
||||
it('renders the scan history with trigger and deltas', async () => {
|
||||
historyRef.value = [
|
||||
{ id: 9, ...makeScan({ triggeredBy: 'watcher', addedCount: 3, updatedCount: 1 }) },
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Library, LibraryScanHistoryEntry } from '@bookorbit/types'
|
||||
import { formatKeyName } from '@/features/book/lib/book-formats'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatDate, formatList, formatNumber, formatPercent } from '@/i18n/formatters'
|
||||
import { formatDate, formatList, formatNumber } from '@/i18n/formatters'
|
||||
import { METADATA_LABELS } from '@/features/library/composables/useLibraryCreator'
|
||||
import LibraryScanHistory from './LibraryScanHistory.vue'
|
||||
|
||||
@@ -42,6 +42,9 @@ const formatsLabel = computed(() =>
|
||||
)
|
||||
const accessLabel = computed(() => (props.accessCount === null ? '' : t('settings.admin.libraries.detail.peopleCount', { count: props.accessCount })))
|
||||
const readingThresholdLabel = computed(() => formatNumber(props.library.readingThreshold / 100, { style: 'percent', maximumFractionDigits: 2 }))
|
||||
const finishedThresholdLabel = computed(() =>
|
||||
formatNumber(props.library.markAsFinishedPercentComplete / 100, { style: 'percent', maximumFractionDigits: 2 }),
|
||||
)
|
||||
|
||||
function requestEdit() {
|
||||
emit('edit', props.library)
|
||||
@@ -121,7 +124,7 @@ function requestEdit() {
|
||||
<div class="flex items-center gap-3 border-t border-border py-1.5">
|
||||
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.countsAsFinished') }}</dt>
|
||||
<dd class="ms-auto text-[12.5px] font-medium tabular-nums text-foreground">
|
||||
{{ formatPercent(library.markAsFinishedPercentComplete / 100) }}
|
||||
{{ finishedThresholdLabel }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -8650,7 +8650,8 @@
|
||||
},
|
||||
"markAsFinished": {
|
||||
"title": "Mark as finished",
|
||||
"hint": "A book is automatically marked as \"read\" when this percentage of progress is reached."
|
||||
"hint": "A book is automatically marked as \"read\" when this percentage of progress is reached.",
|
||||
"invalidThreshold": "Finished progress must be between 90% and 100%."
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const REAL_PERCENTAGE_TOLERANCE = 0.00001;
|
||||
|
||||
export function hasReachedProgressThreshold(progress: number, threshold: number): boolean {
|
||||
if (progress >= threshold) return true;
|
||||
// PostgreSQL real can store 99.95 as 99.949996; whole-number thresholds retain exact comparisons.
|
||||
return !Number.isInteger(threshold) && progress + REAL_PERCENTAGE_TOLERANCE >= threshold;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "libraries" ALTER COLUMN "mark_as_finished_percent_complete" SET DATA TYPE double precision;--> statement-breakpoint
|
||||
ALTER TABLE "libraries" ALTER COLUMN "mark_as_finished_percent_complete" SET DEFAULT 98;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -694,6 +694,13 @@
|
||||
"when": 1790398181669,
|
||||
"tag": "0098_add_subtitle_search_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 99,
|
||||
"version": "7",
|
||||
"when": 1790446233976,
|
||||
"tag": "0099_fractional_finished_threshold",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const libraries = pgTable(
|
||||
|
||||
// Reading progress thresholds
|
||||
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
|
||||
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
|
||||
markAsFinishedPercentComplete: doublePrecision('mark_as_finished_percent_complete').notNull().default(98),
|
||||
|
||||
// File write-back settings
|
||||
fileWriteEnabled: boolean('file_write_enabled').notNull().default(false),
|
||||
|
||||
@@ -1548,6 +1548,9 @@ describe('BookRepository', () => {
|
||||
{ percentage: 100, threshold: 98, expected: 'Finished' },
|
||||
{ percentage: 95, threshold: 95, expected: 'Finished' },
|
||||
{ percentage: 99, threshold: 100, expected: 'Reading' },
|
||||
{ percentage: 99.94, threshold: 99.95, expected: 'Reading' },
|
||||
{ percentage: Math.fround(99.95), threshold: 99.95, expected: 'Finished' },
|
||||
{ percentage: 99.999995, threshold: 100, expected: 'Reading' },
|
||||
])('reports $expected at $percentage% with a $threshold% threshold', async ({ percentage, threshold, expected }) => {
|
||||
await expect(syncStatusFor(percentage, threshold)).resolves.toBe(expected);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { advanceIsoTimestamp } from '../../common/utils/iso-timestamp.utils';
|
||||
import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
|
||||
import { scanStateInvalidationPaths } from '../../common/utils/scan-state-paths.utils';
|
||||
import { seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
|
||||
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
|
||||
import { SeriesIdentityService } from '../../common/services/series-identity.service';
|
||||
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
@@ -2806,7 +2807,7 @@ export class BookRepository {
|
||||
*/
|
||||
private deriveKoboStatus(percentage: number, markAsFinishedPercentComplete: number): string {
|
||||
const threshold = Number.isFinite(markAsFinishedPercentComplete) ? Math.min(100, Math.max(1, markAsFinishedPercentComplete)) : 100;
|
||||
if (percentage >= threshold) return 'Finished';
|
||||
if (hasReachedProgressThreshold(percentage, threshold)) return 'Finished';
|
||||
return percentage > 0 ? 'Reading' : 'ReadyToRead';
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export class CreateLibraryDto {
|
||||
readingThreshold?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsNumber()
|
||||
@Min(LIBRARY_MARK_AS_FINISHED_MIN)
|
||||
@Max(LIBRARY_MARK_AS_FINISHED_MAX)
|
||||
markAsFinishedPercentComplete?: number;
|
||||
|
||||
@@ -120,7 +120,7 @@ export class UpdateLibraryDto {
|
||||
readingThreshold?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsNumber()
|
||||
@Min(LIBRARY_MARK_AS_FINISHED_MIN)
|
||||
@Max(LIBRARY_MARK_AS_FINISHED_MAX)
|
||||
markAsFinishedPercentComplete?: number;
|
||||
|
||||
@@ -123,7 +123,9 @@ describe('Library DTO validation', () => {
|
||||
const base = { name: 'x', icon: 'BookOpen', folders: ['/a'] };
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 89 }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 101 }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90.5 }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90.05 }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 99.95 }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 100.05 }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90 }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 100 }))).toBe(false);
|
||||
});
|
||||
@@ -133,6 +135,7 @@ describe('Library DTO validation', () => {
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { readingThreshold: 2.5 }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 89 }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 95 }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 99.95 }))).toBe(false);
|
||||
});
|
||||
|
||||
describe('file write settings validation', () => {
|
||||
|
||||
@@ -261,6 +261,25 @@ describe('ReadingAttemptService', () => {
|
||||
expect(fake.rows[0]).toMatchObject({ outcome: 'completed', endedOn: '2026-07-12' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ progress: 99.94, expectedStatus: 'reading', expectedOutcome: null },
|
||||
{ progress: Math.fround(99.95), expectedStatus: 'read', expectedOutcome: 'completed' },
|
||||
])('uses a fractional finish threshold at $progress% progress', async ({ progress, expectedStatus, expectedOutcome }) => {
|
||||
const result = await service.recordActivity({
|
||||
userId: 1,
|
||||
bookId: 10,
|
||||
occurredOn: '2026-07-12',
|
||||
origin: 'bookorbit',
|
||||
progress,
|
||||
finishThreshold: 99.95,
|
||||
strongRereadEvidence: false,
|
||||
meaningfulActivity: true,
|
||||
});
|
||||
|
||||
expect(result?.status).toBe(expectedStatus);
|
||||
expect(fake.rows[0]?.outcome).toBe(expectedOutcome);
|
||||
});
|
||||
|
||||
it('keeps repeated manual read operations idempotent', async () => {
|
||||
const first = await service.applyManualStatus(1, 10, 'read', '2026-01-01', '2026-01-10', '2026-07-12');
|
||||
const second = await service.applyManualStatus(1, 10, 'read', undefined, undefined, '2026-07-12');
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
|
||||
import { ReadingAttemptRepository } from './reading-attempt.repository';
|
||||
import { READING_DATE_ERROR_CODES } from './user-book-status.constants';
|
||||
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
|
||||
|
||||
function dateToUtcDate(value: string | null): Date | null {
|
||||
return value ? new Date(`${value}T00:00:00.000Z`) : null;
|
||||
@@ -259,7 +260,7 @@ export class ReadingAttemptService {
|
||||
let active: Awaited<ReturnType<ReadingAttemptRepository['findActive']>> | null = await this.repo.findActive(tx, input.userId, input.bookId);
|
||||
let latest = active ?? (await this.repo.findLatest(tx, input.userId, input.bookId));
|
||||
const hasCompleted = await this.repo.hasCompleted(tx, input.userId, input.bookId);
|
||||
const isFinished = input.progress >= input.finishThreshold;
|
||||
const isFinished = hasReachedProgressThreshold(input.progress, input.finishThreshold);
|
||||
|
||||
if (!active && hasCompleted && !input.strongRereadEvidence && !input.meaningfulActivity) return null;
|
||||
if (!active && isFinished && !hasCompleted) {
|
||||
|
||||
@@ -384,6 +384,9 @@ describe('autoUpdate with custom thresholds', () => {
|
||||
{ percentage: 0.5, readingThreshold: 1, finishThreshold: 90, existingStatus: 'reading', expectedStatus: 'unread' },
|
||||
{ percentage: 1, readingThreshold: 1, finishThreshold: 90, existingStatus: 'unread', expectedStatus: 'reading' },
|
||||
{ percentage: 90, readingThreshold: 1, finishThreshold: 90, existingStatus: 'reading', expectedStatus: 'read' },
|
||||
{ percentage: 99.94, readingThreshold: 1, finishThreshold: 99.95, existingStatus: 'unread', expectedStatus: 'reading' },
|
||||
{ percentage: Math.fround(99.95), readingThreshold: 1, finishThreshold: 99.95, existingStatus: 'reading', expectedStatus: 'read' },
|
||||
{ percentage: 99.999995, readingThreshold: 1, finishThreshold: 100, existingStatus: 'unread', expectedStatus: 'reading' },
|
||||
])(
|
||||
'derives expected status for percentage=$percentage',
|
||||
async ({ percentage, readingThreshold, finishThreshold, existingStatus, expectedStatus }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from
|
||||
import { KoboStatusProjectionRepository } from './kobo-status-projection.repository';
|
||||
import { ReadingAttemptService } from './reading-attempt.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
|
||||
import { toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
|
||||
|
||||
const DEFAULT_FINISH_THRESHOLD = 98;
|
||||
@@ -250,8 +251,11 @@ export class UserBookStatusService {
|
||||
|
||||
const normalizedPercentage = this.normalizePercentage(percentage);
|
||||
const { readThreshold, finishThreshold: normalizedFinishThreshold } = this.normalizeThresholds(readingThreshold, finishThreshold);
|
||||
const derived: ReadStatus =
|
||||
normalizedPercentage >= normalizedFinishThreshold ? 'read' : normalizedPercentage >= readThreshold ? 'reading' : 'unread';
|
||||
const derived: ReadStatus = hasReachedProgressThreshold(normalizedPercentage, normalizedFinishThreshold)
|
||||
? 'read'
|
||||
: normalizedPercentage >= readThreshold
|
||||
? 'reading'
|
||||
: 'unread';
|
||||
|
||||
if (this.attempts) {
|
||||
const timeZone = activity.timeZone ?? (await this.repo.findUserTimeZone(userId));
|
||||
|
||||
@@ -486,6 +486,31 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
|
||||
});
|
||||
|
||||
describe('create library and manage access', () => {
|
||||
it('persists fractional finished thresholds through create and update', async () => {
|
||||
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
|
||||
markAsFinishedPercentComplete: 99.95,
|
||||
});
|
||||
expect(createdLibrary.markAsFinishedPercentComplete).toBe(99.95);
|
||||
|
||||
await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner');
|
||||
const update = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: { markAsFinishedPercentComplete: 98.05 },
|
||||
});
|
||||
expect(update.statusCode, update.body).toBe(200);
|
||||
expect(update.json().markAsFinishedPercentComplete).toBe(98.05);
|
||||
|
||||
const read = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(read.statusCode, read.body).toBe(200);
|
||||
expect(read.json().markAsFinishedPercentComplete).toBe(98.05);
|
||||
});
|
||||
|
||||
it('creates a library with the requested settings, requires explicit access rows, and reflects grant/update/revoke changes', async () => {
|
||||
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
|
||||
name: `library-contract-${randomUUID()}`,
|
||||
|
||||
Reference in New Issue
Block a user