Newer
Older
navi-1 / webclient / tests / unit / composables / useNotificationPrompt.test.js
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('Не сейчас')
  })
})