feat(smart-scope): allow sharing smart scope after creation

Expose the "Visible to all users" toggle in the smart scope editor panel and allow updating the isPublic flag on existing smart scopes via the backend API.

Closes #805
This commit is contained in:
neon
2026-07-30 19:43:34 +00:00
parent 1f4420b454
commit 624dce34a8
17 changed files with 500 additions and 13 deletions
@@ -86,7 +86,7 @@ async function submit() {
<label class="flex items-center gap-2.5 cursor-pointer select-none">
<input type="checkbox" v-model="isPublic" class="h-4 w-4 rounded border border-input accent-primary" />
<span class="text-sm text-foreground">{{ t('smartScope.createDialog.visibleToAll') }}</span>
<span class="text-sm text-foreground">{{ t('smartScope.visibleToAll') }}</span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer select-none">
@@ -52,6 +52,7 @@ const draftName = ref('')
const draftIcon = ref('')
const draftFilter = ref<GroupRule | undefined>(undefined)
const draftSort = ref<SortSpec[]>([])
const draftIsPublic = ref(false)
const draftSyncToKobo = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
@@ -70,6 +71,7 @@ watch(
draftIcon.value = props.smartScope.icon ?? ''
draftFilter.value = props.smartScope.filter ?? undefined
draftSort.value = props.smartScope.defaultSort ? [...props.smartScope.defaultSort] : []
draftIsPublic.value = props.smartScope.isPublic
draftSyncToKobo.value = props.smartScope.syncToKobo
schedulePreview()
}
@@ -116,6 +118,14 @@ function hasCompleteRules(group: GroupRule | undefined): boolean {
return !!group?.rules.some((rule) => rule.type === 'rule' || hasCompleteRules(rule as GroupRule))
}
function toggleIsPublic() {
draftIsPublic.value = !draftIsPublic.value
}
function toggleSyncToKobo() {
draftSyncToKobo.value = !draftSyncToKobo.value
}
async function save() {
if (!props.smartScope) return
if (!trimmedDraftName.value) {
@@ -134,6 +144,7 @@ async function save() {
icon: trimmedDraftIcon.value,
filter: hasCompleteRules(draftFilter.value) ? draftFilter.value : undefined,
defaultSort: draftSort.value,
isPublic: draftIsPublic.value,
syncToKobo: draftSyncToKobo.value,
})
emit('saved')
@@ -201,6 +212,27 @@ async function save() {
</div>
</div>
<div class="flex items-center justify-between py-1">
<div>
<p class="text-sm font-medium text-foreground">{{ t('smartScope.visibleToAll') }}</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ t('smartScope.visibleToAllHint') }}</p>
</div>
<button
type="button"
role="switch"
:aria-label="t('smartScope.visibleToAll')"
:aria-checked="draftIsPublic"
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="draftIsPublic ? 'bg-primary' : 'bg-muted'"
@click="toggleIsPublic"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="draftIsPublic ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between py-1">
<div>
<p class="text-sm font-medium text-foreground">{{ t('smartScope.syncToKobo') }}</p>
@@ -209,10 +241,11 @@ async function save() {
<button
type="button"
role="switch"
:aria-label="t('smartScope.syncToKobo')"
:aria-checked="draftSyncToKobo"
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="draftSyncToKobo ? 'bg-primary' : 'bg-muted'"
@click="draftSyncToKobo = !draftSyncToKobo"
@click="toggleSyncToKobo"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
@@ -0,0 +1,107 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import type { SmartScope } from '@bookorbit/types'
import CreateSmartScopeDialog from '../CreateSmartScopeDialog.vue'
const mockState = vi.hoisted(() => ({
createSmartScope: vi.fn<(payload: Record<string, unknown>) => Promise<SmartScope>>(),
push: vi.fn<() => void>(),
}))
vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
useSmartScopes: () => ({ createSmartScope: mockState.createSmartScope }),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockState.push }),
}))
function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
return {
id: 11,
userId: 3,
name: 'Unread Sci-Fi',
icon: 'Aperture',
filter: null,
defaultSort: [],
isPublic: false,
syncToKobo: false,
koboSyncEnabled: false,
isOwner: true,
displayOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
const IconPickerStub = defineComponent({
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('input', {
class: 'icon-picker',
value: props.modelValue,
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
function mountDialog() {
return mount(CreateSmartScopeDialog, {
props: { open: true },
global: {
stubs: {
Teleport: true,
IconPicker: IconPickerStub,
},
},
})
}
async function fillRequiredFields(wrapper: ReturnType<typeof mountDialog>, name: string): Promise<void> {
await wrapper.find('input[type="text"]').setValue(name)
await wrapper.find('.icon-picker').setValue('Aperture')
}
describe('CreateSmartScopeDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.createSmartScope.mockResolvedValue(makeSmartScope())
})
it('labels the sharing checkbox with the message the editor panel also uses', () => {
const wrapper = mountDialog()
expect(wrapper.text()).toContain('Visible to all users')
})
it('creates a shared scope when the sharing checkbox is ticked', async () => {
const wrapper = mountDialog()
await fillRequiredFields(wrapper, 'Shared Sci-Fi')
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockState.createSmartScope).toHaveBeenCalledWith({
name: 'Shared Sci-Fi',
icon: 'Aperture',
defaultSort: [],
isPublic: true,
syncToKobo: false,
})
})
it('creates a private scope by default', async () => {
const wrapper = mountDialog()
await fillRequiredFields(wrapper, 'Private Sci-Fi')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mockState.createSmartScope).toHaveBeenCalledWith(expect.objectContaining({ isPublic: false, syncToKobo: false }))
})
})
@@ -0,0 +1,208 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SmartScope } from '@bookorbit/types'
import SmartScopeEditorPanel from '../SmartScopeEditorPanel.vue'
const mockState = vi.hoisted(() => ({
updateSmartScope: vi.fn<(id: number, payload: Record<string, unknown>) => Promise<unknown>>(),
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
useSmartScopes: () => ({ updateSmartScope: mockState.updateSmartScope }),
}))
vi.mock('@/lib/api', () => ({
api: mockState.api,
}))
const SHARING_SWITCH = 'button[aria-label="Visible to all users"]'
const KOBO_SWITCH = 'button[aria-label="Sync to Kobo"]'
function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
return {
id: 11,
userId: 3,
name: 'Unread Sci-Fi',
icon: 'Aperture',
filter: null,
defaultSort: [],
isPublic: false,
syncToKobo: false,
koboSyncEnabled: false,
isOwner: true,
displayOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function mountPanel(smartScope: SmartScope) {
return mount(SmartScopeEditorPanel, {
props: { open: false, smartScope },
global: {
stubs: {
Teleport: true,
BookFilterBuilder: true,
BookSortBuilder: true,
IconPicker: true,
},
},
})
}
type Panel = ReturnType<typeof mountPanel>
async function openPanel(smartScope: SmartScope): Promise<Panel> {
const wrapper = mountPanel(smartScope)
await wrapper.setProps({ open: true })
return wrapper
}
async function save(wrapper: Panel): Promise<void> {
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Save changes')
await saveButton!.trigger('click')
await flushPromises()
}
function savedPayload(): Record<string, unknown> {
return mockState.updateSmartScope.mock.calls[0]![1]
}
describe('SmartScopeEditorPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.updateSmartScope.mockResolvedValue(makeSmartScope())
mockState.api.mockResolvedValue({ ok: true, json: async () => ({ total: 0 }) } as Response)
})
it('seeds the sharing toggle from the scope when the panel opens', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: true, syncToKobo: false }))
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('true')
expect(wrapper.find(KOBO_SWITCH).attributes('aria-checked')).toBe('false')
})
it('shows a private scope as not shared', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false, syncToKobo: true }))
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('false')
expect(wrapper.find(KOBO_SWITCH).attributes('aria-checked')).toBe('true')
})
it('shares an existing scope instead of requiring it to be recreated', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false }))
await wrapper.find(SHARING_SWITCH).trigger('click')
await save(wrapper)
expect(mockState.updateSmartScope).toHaveBeenCalledTimes(1)
expect(mockState.updateSmartScope).toHaveBeenCalledWith(11, {
name: 'Unread Sci-Fi',
icon: 'Aperture',
filter: undefined,
defaultSort: [],
isPublic: true,
syncToKobo: false,
})
expect(wrapper.emitted('saved')).toEqual([[]])
expect(wrapper.emitted('close')).toEqual([[]])
})
it('unshares a scope that was previously visible to all users', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: true }))
await wrapper.find(SHARING_SWITCH).trigger('click')
await save(wrapper)
expect(savedPayload()).toMatchObject({ isPublic: false })
})
it('keeps a shared scope shared when only unrelated fields are edited', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: true, syncToKobo: true }))
await wrapper.find('input[type="text"]').setValue('Shared Sci-Fi')
await save(wrapper)
expect(savedPayload()).toMatchObject({ name: 'Shared Sci-Fi', isPublic: true, syncToKobo: true })
})
it('keeps sharing and Kobo sync independent of each other', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false, syncToKobo: false }))
await wrapper.find(KOBO_SWITCH).trigger('click')
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('false')
await save(wrapper)
expect(savedPayload()).toMatchObject({ isPublic: false, syncToKobo: true })
})
it('trims the name and keeps the sharing choice when saving', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false }))
await wrapper.find('input[type="text"]').setValue(' Padded Name ')
await wrapper.find(SHARING_SWITCH).trigger('click')
await save(wrapper)
expect(savedPayload()).toMatchObject({ name: 'Padded Name', isPublic: true })
})
it('discards an unsaved sharing change when the panel is reopened', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false }))
await wrapper.find(SHARING_SWITCH).trigger('click')
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('true')
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('false')
})
it('reseeds the sharing toggle when the panel reopens for a different scope', async () => {
const wrapper = await openPanel(makeSmartScope({ id: 11, isPublic: true }))
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('true')
await wrapper.setProps({ open: false })
await wrapper.setProps({ smartScope: makeSmartScope({ id: 12, name: 'Private Scope', isPublic: false }), open: true })
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('false')
await save(wrapper)
expect(mockState.updateSmartScope).toHaveBeenCalledWith(12, expect.objectContaining({ isPublic: false }))
})
it('blocks the save when the name is emptied so the sharing change is never sent', async () => {
const wrapper = await openPanel(makeSmartScope({ isPublic: false }))
await wrapper.find(SHARING_SWITCH).trigger('click')
await wrapper.find('input[type="text"]').setValue(' ')
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Save changes')
expect(saveButton!.attributes('disabled')).toBeDefined()
await save(wrapper)
expect(mockState.updateSmartScope).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
})
it('keeps the panel open and reports the failure when saving the sharing change fails', async () => {
mockState.updateSmartScope.mockRejectedValueOnce(new Error('HTTP 500'))
const wrapper = await openPanel(makeSmartScope({ isPublic: false }))
await wrapper.find(SHARING_SWITCH).trigger('click')
await save(wrapper)
expect(savedPayload()).toMatchObject({ isPublic: true })
expect(wrapper.text()).toContain('Failed to save')
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('close')).toBeUndefined()
expect(wrapper.find(SHARING_SWITCH).attributes('aria-checked')).toBe('true')
})
})
@@ -69,6 +69,25 @@ describe('useSmartScopes', () => {
expect(smartScopes.value).not.toBe(previous)
})
it('patches the sharing flag and reflects the shared scope returned by the server', async () => {
const created = makeSmartScope({ isPublic: false })
const shared = makeSmartScope({ isPublic: true })
apiMock.mockResolvedValueOnce(makeResponse(created)).mockResolvedValueOnce(makeResponse(shared))
const { useSmartScopes } = await import('../useSmartScopes')
const { smartScopes, createSmartScope, updateSmartScope } = useSmartScopes()
await createSmartScope({ name: created.name, icon: 'Aperture', defaultSort: [] })
await updateSmartScope(created.id, { name: created.name, icon: 'Aperture', defaultSort: [], isPublic: true, syncToKobo: false })
expect(apiMock).toHaveBeenLastCalledWith('/api/v1/smart-scopes/11', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: created.name, icon: 'Aperture', defaultSort: [], isPublic: true, syncToKobo: false }),
})
expect(smartScopes.value).toEqual([shared])
})
it('sends the Kobo sync opt-in and merges the response without dropping the cached book count', async () => {
const shared = { ...makeSmartScope({ id: 11, userId: 4, isOwner: false, isPublic: true }), bookCount: 42 }
apiMock
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Mit Kobo synchronisieren",
"syncToKoboHint": "Bücher, die diesem Bereich entsprechen, werden auf deinem Kobo-Gerät angezeigt",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Name",
"namePlaceholder": "z. B. Ungelesene Science-Fiction",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Neuer SmartScope",
"visibleToAll": "Für alle Benutzer sichtbar",
"createFailed": "SmartScope konnte nicht erstellt werden"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Sync to Kobo",
"syncToKoboHint": "Books matching this scope will appear on your Kobo device",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Name",
"namePlaceholder": "e.g. Unread Sci-Fi",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "New Smart Scope",
"visibleToAll": "Visible to all users",
"createFailed": "Failed to create Smart Scope"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Sincronizar con Kobo",
"syncToKoboHint": "Los libros que coincidan con este alcance aparecerán en su dispositivo Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Nombre",
"namePlaceholder": "por ej. Ciencia ficción no leída",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nuevo alcance inteligente",
"visibleToAll": "Visible para todos los usuarios",
"createFailed": "No se pudo crear Smart Scope"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Synchroniser avec Kobo",
"syncToKoboHint": "Les livres correspondant à cette portée apparaîtront sur votre appareil Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Nom",
"namePlaceholder": "par ex. Science-fiction non lue",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nouvelle lunette intelligente",
"visibleToAll": "Visible par tous les utilisateurs",
"createFailed": "Échec de la création de Smart Scope"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Sincronizza con Kobo",
"syncToKoboHint": "I libri corrispondenti a questo ambito verranno visualizzati sul tuo dispositivo Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Nome",
"namePlaceholder": "ad es. Fantascienza non letta",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nuovo ambito intelligente",
"visibleToAll": "Visibile a tutti gli utenti",
"createFailed": "Impossibile creare Smart Scope"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Synchroniseren met Kobo",
"syncToKoboHint": "Boeken die aan dit bereik voldoen, verschijnen op uw Kobo-apparaat",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Naam",
"namePlaceholder": "bijv. Ongelezen sci-fi",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nieuwe Smart Scope",
"visibleToAll": "Zichtbaar voor alle gebruikers",
"createFailed": "Smart Scope aanmaken mislukt"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Synchronizuj z Kobo",
"syncToKoboHint": "Książki pasujące do tego zakresu pojawią się na Twoim urządzeniu Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Imię",
"namePlaceholder": "np. Nieprzeczytane science-fiction",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nowy inteligentny zakres",
"visibleToAll": "Widoczne dla wszystkich użytkowników",
"createFailed": "Nie udało się utworzyć inteligentnego zakresu"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Sincronizar com o Kobo",
"syncToKoboHint": "Os livros que correspondem a este escopo aparecerão no seu dispositivo Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Nome",
"namePlaceholder": "ex. Ficção Científica Não Lida",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Novo SmartScope",
"visibleToAll": "Visível a todos os usuários",
"createFailed": "Falha ao criar o SmartScope"
},
"saveAsDialog": {
+2 -1
View File
@@ -6078,6 +6078,8 @@
"smartScope": {
"syncToKobo": "Sinhroniziraj s Kobo",
"syncToKoboHint": "Knjige, ki ustrezajo temu obsegu, bodo prikazane v napravi Kobo",
"visibleToAll": "Visible to all users",
"visibleToAllHint": "Every user can see and use this smart scope",
"dialog": {
"name": "Ime",
"namePlaceholder": "npr. Neprebrana znanstvena fantastika",
@@ -6091,7 +6093,6 @@
},
"createDialog": {
"title": "Nov pametni obseg",
"visibleToAll": "Vidno vsem uporabnikom",
"createFailed": "Ustvarjanje pametnega obsega ni uspelo"
},
"saveAsDialog": {
@@ -30,11 +30,11 @@ export class UpdateSmartScopeDto {
@Type(() => SortSpecDto)
defaultSort?: SortSpecDto[];
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsBoolean()
isPublic?: boolean;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsBoolean()
syncToKobo?: boolean;
}
@@ -51,6 +51,29 @@ describe('SmartScope DTO validation', () => {
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { icon: 'Aperture' }))).toBe(false);
});
it('UpdateSmartScopeDto accepts both boolean sharing states and rejects non-boolean values', async () => {
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { isPublic: true }))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { isPublic: false }))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, {}))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { isPublic: 'yes' }))).toBe(true);
// A null would otherwise reach the NOT NULL column as a 500 instead of a 400.
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { isPublic: null }))).toBe(true);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { syncToKobo: null }))).toBe(true);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { syncToKobo: true }))).toBe(false);
});
it('UpdateSmartScopeDto accepts the full sharing payload the editor panel sends', async () => {
const dto = plainToInstance(UpdateSmartScopeDto, {
name: 'Shared Sci-Fi',
icon: 'Aperture',
defaultSort: [{ field: SORT_FIELDS[0], dir: 'asc' }],
isPublic: true,
syncToKobo: false,
});
expect(await hasErrors(dto)).toBe(false);
});
it('UpdateSmartScopeDto accepts null filter to clear filters and rejects non-object filters', async () => {
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { filter: null }))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateSmartScopeDto, { filter: 'bad' }))).toBe(true);
@@ -372,6 +372,94 @@ describe('SmartScopeService', () => {
expect(result).toEqual({ ...updated, isOwner: false, koboSyncEnabled: false });
});
describe('sharing an existing smartScope (issue #805)', () => {
it('update shares a private smartScope without recreating it', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 3, userId: 12, isPublic: false });
const shared = { ...existing, isPublic: true };
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([shared]);
const result = await service.update(3, { isPublic: true }, makeUser({ id: 12 }));
expect(smartScopeRepo.update).toHaveBeenCalledWith(3, 12, expect.objectContaining({ isPublic: true }));
expect(result).toEqual({ ...shared, isOwner: true, koboSyncEnabled: false });
});
it('update unshares a public smartScope', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 3, userId: 12, isPublic: true });
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([{ ...existing, isPublic: false }]);
const result = await service.update(3, { isPublic: false }, makeUser({ id: 12 }));
expect(smartScopeRepo.update).toHaveBeenCalledWith(3, 12, expect.objectContaining({ isPublic: false }));
expect(result).toEqual(expect.objectContaining({ isPublic: false }));
});
it('update leaves the sharing flag alone when the payload omits it', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 3, userId: 12, isPublic: true });
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([existing]);
await service.update(3, { name: 'Renamed' }, makeUser({ id: 12 }));
const [, , values] = smartScopeRepo.update.mock.calls[0] as [number, number, Record<string, unknown>];
expect(values.isPublic).toBeUndefined();
});
it('update carries sharing and Kobo sync together without cross-writing either flag', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 3, userId: 12, isPublic: false, syncToKobo: false });
const updated = { ...existing, isPublic: true, syncToKobo: true };
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([updated]);
const result = await service.update(3, { isPublic: true, syncToKobo: true }, makeUser({ id: 12 }));
expect(smartScopeRepo.update).toHaveBeenCalledWith(3, 12, expect.objectContaining({ isPublic: true, syncToKobo: true }));
expect(result).toEqual(expect.objectContaining({ isPublic: true, syncToKobo: true, koboSyncEnabled: true }));
});
it('update lets a superuser share another user smartScope against the owner row', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 4, userId: 77, isPublic: false });
const shared = { ...existing, isPublic: true };
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([shared]);
smartScopeRepo.findKoboSubscribedScopeIds.mockResolvedValue([]);
const result = await service.update(4, { isPublic: true }, makeUser({ id: 1, isSuperuser: true }));
expect(smartScopeRepo.update).toHaveBeenCalledWith(4, 77, expect.objectContaining({ isPublic: true }));
// The superuser is not the owner, so the owner Kobo flag must not leak into their own sync state.
expect(result).toEqual(expect.objectContaining({ isOwner: false, koboSyncEnabled: false }));
});
it('update rejects a non-owner sharing someone else smartScope', async () => {
const { service, smartScopeRepo } = makeService();
smartScopeRepo.findById.mockResolvedValue([makeSmartScope({ id: 5, userId: 77, isPublic: false })]);
await expect(service.update(5, { isPublic: true }, makeUser({ id: 12, isSuperuser: false }))).rejects.toThrow(ForbiddenException);
expect(smartScopeRepo.update).not.toHaveBeenCalled();
});
it('update reports the shared scope Kobo opt-in of the caller, not the owner flag', async () => {
const { service, smartScopeRepo } = makeService();
const existing = makeSmartScope({ id: 6, userId: 77, isPublic: true, syncToKobo: true });
smartScopeRepo.findById.mockResolvedValue([existing]);
smartScopeRepo.update.mockResolvedValue([existing]);
smartScopeRepo.findKoboSubscribedScopeIds.mockResolvedValue([6]);
const result = await service.update(6, { name: 'Renamed by admin' }, makeUser({ id: 1, isSuperuser: true }));
expect(smartScopeRepo.findKoboSubscribedScopeIds).toHaveBeenCalledWith(1, [6]);
expect(result).toEqual(expect.objectContaining({ isOwner: false, koboSyncEnabled: true }));
});
});
it('update rejects changes that would leave a smartScope without an icon', async () => {
const { service, smartScopeRepo } = makeService();
smartScopeRepo.findById.mockResolvedValue([makeSmartScope({ icon: null })]);