import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
// The capability layer is mocked outright: what this file is about is the
// policy on top of it, and usePush keeps module-level refs that would leak
// from one test to the next.
let push
vi.mock('@/composables/usePush.js', () => ({ usePush: () => push }))
import {
PROMPT_BLOCKED_KEY,
PROMPT_SHOWN_KEY,
useNotificationPrompt,
} from '@/composables/useNotificationPrompt.js'
import { setLocale } from '@/i18n/index.js'
function fakeStorage() {
const entries = {}
return {
getItem: key => (key in entries ? entries[key] : null),
setItem: (key, value) => { entries[key] = String(value) },
removeItem: key => { delete entries[key] },
entries,
}
}
let blockedStore
let sessionStore
beforeEach(() => {
blockedStore = fakeStorage()
sessionStore = fakeStorage()
vi.stubGlobal('localStorage', blockedStore)
vi.stubGlobal('sessionStorage', sessionStore)
push = {
supported: ref(true),
bridgeMode: ref(false),
permission: ref('default'),
subscribed: ref(false),
busy: ref(false),
enable: vi.fn(),
syncState: vi.fn(),
}
})
afterEach(() => {
vi.unstubAllGlobals()
setLocale('en')
})
describe('offering the banner', () => {
it('offers it to an eligible device and remembers the session', async () => {
const prompt = useNotificationPrompt()
expect(await prompt.maybeShow()).toBe(true)
expect(prompt.visible.value).toBe(true)
expect(push.syncState).toHaveBeenCalled()
// Written when it appears, not when it is answered: a reload with the
// banner open must not bring it back.
expect(sessionStore.getItem(PROMPT_SHOWN_KEY)).toBe('1')
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
})
it('stays quiet for the rest of the session', async () => {
await useNotificationPrompt().maybeShow()
// A reload: fresh state, the same storages.
const afterReload = useNotificationPrompt()
expect(await afterReload.maybeShow()).toBe(false)
expect(afterReload.visible.value).toBe(false)
})
it('leaves a switch the user turned off alone', async () => {
// Permission granted, subscription off: the user turned the switch off in
// Settings. Asking again would re-subscribe them behind their back.
push.permission.value = 'granted'
push.subscribed.value = false
const prompt = useNotificationPrompt()
expect(prompt.canPrompt()).toBe(false)
expect(await prompt.maybeShow()).toBe(false)
expect(push.enable).not.toHaveBeenCalled()
expect(sessionStore.getItem(PROMPT_SHOWN_KEY)).toBeNull()
})
it('remembers a block made in the browser itself', async () => {
push.permission.value = 'denied'
const prompt = useNotificationPrompt()
expect(await prompt.maybeShow()).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBe('1')
expect(sessionStore.getItem(PROMPT_SHOWN_KEY)).toBeNull()
})
it('takes an unsupported browser for what it is, not for a refusal', async () => {
// usePush reports 'denied' when the Notification API is missing entirely.
push.supported.value = false
push.permission.value = 'denied'
expect(await useNotificationPrompt().maybeShow()).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
})
it('does not ask a device that already blocked', async () => {
blockedStore.setItem(PROMPT_BLOCKED_KEY, '1')
const prompt = useNotificationPrompt()
expect(prompt.canPrompt()).toBe(false)
expect(await prompt.maybeShow()).toBe(false)
})
it('does not offer a subscription that is already in place', async () => {
push.permission.value = 'granted'
push.subscribed.value = true
expect(await useNotificationPrompt().maybeShow()).toBe(false)
})
it('survives the push state being unavailable', async () => {
push.syncState = vi.fn(() => { throw new Error('no service worker') })
// Whatever syncState could not tell us, the gates still decide.
await expect(useNotificationPrompt().maybeShow()).resolves.toBe(true)
})
})
describe('answering it', () => {
async function offered() {
const prompt = useNotificationPrompt()
await prompt.maybeShow()
return prompt
}
it('closes on a granted permission without writing anything down', async () => {
push.enable.mockImplementation(async () => {
push.permission.value = 'granted'
push.subscribed.value = true
return true
})
const prompt = await offered()
await prompt.accept()
expect(prompt.visible.value).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
})
it('remembers an explicit block', async () => {
push.enable.mockImplementation(async () => {
push.permission.value = 'denied'
return false
})
const prompt = await offered()
await prompt.accept()
expect(prompt.visible.value).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBe('1')
})
it('treats a dismissed system dialog as "later", not as a refusal', async () => {
// The browser leaves the permission at 'default' when its own prompt is
// closed without an answer.
push.enable.mockImplementation(async () => false)
const prompt = await offered()
await prompt.accept()
expect(prompt.visible.value).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
})
it('remembers a refusal through the Android bridge, which cannot report denied', async () => {
push.bridgeMode.value = true
push.enable.mockImplementation(async () => false)
const prompt = await offered()
await prompt.accept()
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBe('1')
})
it('writes nothing when enable() refuses to run at all', async () => {
// enable() returns undefined when it is unsupported or already busy.
push.enable.mockImplementation(async () => undefined)
const prompt = await offered()
await prompt.accept()
expect(prompt.visible.value).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
})
it('writes nothing on "not now"', async () => {
const prompt = await offered()
prompt.decline()
expect(prompt.visible.value).toBe(false)
expect(blockedStore.getItem(PROMPT_BLOCKED_KEY)).toBeNull()
expect(sessionStore.getItem(PROMPT_SHOWN_KEY)).toBe('1')
})
})
describe('storage that refuses to work', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: () => { throw new Error('blocked') },
setItem: () => { throw new Error('blocked') },
removeItem: () => { throw new Error('blocked') },
})
vi.stubGlobal('sessionStorage', {
getItem: () => { throw new Error('blocked') },
setItem: () => { throw new Error('blocked') },
removeItem: () => { throw new Error('blocked') },
})
})
it('offers the banner anyway and does not take the app down', async () => {
const prompt = useNotificationPrompt()
// Fail-open: a private window sees it once per page load, which is the
// price of having nowhere to remember the answer.
await expect(prompt.maybeShow()).resolves.toBe(true)
await expect(prompt.accept()).resolves.toBeUndefined()
expect(() => prompt.reset()).not.toThrow()
})
})
describe('the labels', () => {
it('follow the interface language', () => {
setLocale('ru')
const prompt = useNotificationPrompt()
expect(prompt.title.value).toBe('Включить уведомления')
expect(prompt.acceptText.value).toBe('Включить')
expect(prompt.dismissText.value).toBe('Не сейчас')
})
})