d3ro-voice/apps/mobile-rn/__tests__/notifications.test.ts
2026-08-29 18:33:45 +09:00

273 lines
9.4 KiB
TypeScript

jest.mock('../src/lib/supabase', () => ({
supabase: { rpc: jest.fn() },
}))
jest.mock('../src/features/notifications/notification-native', () => ({
deleteNativePushRegistration: jest.fn(),
getNativeNotificationStatus: jest.fn(),
getNativePushRegistrationId: jest.fn(),
}))
const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as {
rpc: jest.Mock
}
const mockedNative = jest.requireMock('../src/features/notifications/notification-native') as {
deleteNativePushRegistration: jest.Mock
getNativeNotificationStatus: jest.Mock
getNativePushRegistrationId: jest.Mock
}
import type { RegisteredDevice } from '../src/features/devices/device-service'
import {
notificationNavigationTarget,
parseNotificationPayload,
NotificationContractError,
} from '../src/features/notifications/notification-contract'
import {
detachPushRegistrationForLogout,
registerPushRegistration,
synchronizePushRegistration,
} from '../src/features/notifications/notification-service'
const USER_ID = '11111111-1111-4111-8111-111111111111'
const DEVICE_ID = '22222222-2222-4222-8222-222222222222'
const HISTORY_ID = '33333333-3333-4333-8333-333333333333'
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
const INVITE_ID = '55555555-5555-4555-8555-555555555555'
const SUBSCRIPTION_ID = '66666666-6666-4666-8666-666666666666'
const INVITE_TOKEN = 'Abcdefghijklmnopqrstuvwxyz_12345'
const REGISTRATION_ID = `fcm_${'a'.repeat(128)}`
const device: RegisteredDevice = {
id: DEVICE_ID,
userId: USER_ID,
installationId: '77777777-7777-4777-8777-777777777777',
platform: 'android',
deviceName: 'Pixel 7',
appVersion: '1.0.0',
osVersion: 'Android 14',
pushToken: null,
lastSeenAt: '2026-08-21T00:00:00.000Z',
revokedAt: null,
createdAt: '2026-08-21T00:00:00.000Z',
updatedAt: '2026-08-21T00:00:00.000Z',
}
describe('notification payload allowlist', () => {
test('maps transcription completion only to its matching history row', () => {
const parsed = parseNotificationPayload({
schema_version: '1',
event_type: 'transcription.completed',
resource_id: HISTORY_ID,
route: 'HistoryDetail',
history_id: HISTORY_ID,
})
expect(notificationNavigationTarget(parsed)).toEqual({
name: 'HistoryDetail',
params: { historyId: HISTORY_ID },
})
})
test('maps invitation and billing events to fixed internal routes', () => {
const invitation = parseNotificationPayload({
schema_version: '1',
event_type: 'team.invite.created',
resource_id: INVITE_ID,
route: 'InviteAccept',
team_id: TEAM_ID,
invite_token: INVITE_TOKEN,
})
expect(notificationNavigationTarget(invitation)).toEqual({
name: 'InviteAccept',
params: { token: INVITE_TOKEN },
})
const billing = parseNotificationPayload({
schema_version: '1',
event_type: 'billing.status.changed',
resource_id: SUBSCRIPTION_ID,
route: 'ProPaywall',
subscription_id: SUBSCRIPTION_ID,
})
expect(notificationNavigationTarget(billing)).toEqual({ name: 'ProPaywall' })
})
test('rejects caller-authored text, unknown routes, and cross-resource ids', () => {
expect(() => parseNotificationPayload({
schema_version: '1',
event_type: 'transcription.completed',
resource_id: HISTORY_ID,
route: 'HistoryDetail',
history_id: HISTORY_ID,
body: 'arbitrary phishing text',
})).toThrow(NotificationContractError)
expect(() => parseNotificationPayload({
schema_version: '1',
event_type: 'transcription.completed',
resource_id: HISTORY_ID,
route: 'ExternalWebView',
history_id: HISTORY_ID,
})).toThrow('invalid-payload')
expect(() => parseNotificationPayload({
schema_version: '1',
event_type: 'transcription.completed',
resource_id: HISTORY_ID,
route: 'HistoryDetail',
history_id: TEAM_ID,
})).toThrow('invalid-payload')
})
test('rejects unsupported event types instead of inventing a route', () => {
expect(() => parseNotificationPayload({
schema_version: '1',
event_type: 'meeting.comment.created',
resource_id: HISTORY_ID,
route: 'MeetingDetail',
})).toThrow('unsupported-event')
})
})
describe('device-bound push registration', () => {
beforeEach(() => {
mockedSupabase.rpc.mockReset()
mockedNative.deleteNativePushRegistration.mockReset()
mockedNative.getNativeNotificationStatus.mockReset()
mockedNative.getNativePushRegistrationId.mockReset()
})
test('does not fabricate or upload a token when Firebase config is absent', async () => {
mockedNative.getNativeNotificationStatus.mockResolvedValue({
configured: false,
provider: 'fcm',
reason: 'firebase-config-missing',
permission: 'granted',
registrationId: null,
fullSyncRequired: false,
})
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
status: 'configuration-required',
registeredAt: null,
})
expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled()
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
})
test('waits for Android notification permission before requesting a registration', async () => {
mockedNative.getNativeNotificationStatus.mockResolvedValue({
configured: true,
provider: 'fcm',
reason: 'configured',
permission: 'denied',
registrationId: null,
fullSyncRequired: false,
})
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
status: 'permission-required',
})
expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled()
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
})
test('registers the real FCM identifier only through the device-owned RPC', async () => {
mockedNative.getNativeNotificationStatus.mockResolvedValue({
configured: true,
provider: 'fcm',
reason: 'configured',
permission: 'granted',
registrationId: null,
fullSyncRequired: false,
})
mockedNative.getNativePushRegistrationId.mockResolvedValue(REGISTRATION_ID)
mockedSupabase.rpc.mockResolvedValue({
data: {
device_id: DEVICE_ID,
provider: 'fcm',
registered_at: '2026-08-21T00:00:00.000Z',
},
error: null,
})
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
status: 'registered',
registeredAt: '2026-08-21T00:00:00.000Z',
})
expect(mockedSupabase.rpc).toHaveBeenCalledWith('register_push_registration', {
push_device_id: DEVICE_ID,
push_provider: 'fcm',
registration_id: REGISTRATION_ID,
})
})
test('rejects revoked and cross-user devices before writing', async () => {
await expect(registerPushRegistration(
USER_ID,
{ ...device, revokedAt: '2026-08-21T00:00:00.000Z' },
REGISTRATION_ID,
)).rejects.toMatchObject({ code: 'invalid-device' })
await expect(registerPushRegistration(
'88888888-8888-4888-8888-888888888888',
device,
REGISTRATION_ID,
)).rejects.toMatchObject({ code: 'invalid-device' })
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
})
test('detaches the server registration and deletes the native token before logout', async () => {
mockedSupabase.rpc.mockResolvedValue({
data: { device_id: DEVICE_ID, removed: true },
error: null,
})
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
expect(mockedSupabase.rpc).toHaveBeenCalledWith('unregister_push_registration', {
push_device_id: DEVICE_ID,
})
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
})
test('allows logout after native invalidation when server cleanup fails', async () => {
mockedSupabase.rpc.mockResolvedValue({
data: null,
error: { code: '503', message: 'network unavailable' },
})
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
})
test('allows logout after server cleanup when native invalidation fails', async () => {
mockedSupabase.rpc.mockResolvedValue({
data: { device_id: DEVICE_ID, removed: true },
error: null,
})
mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable'))
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
})
test('fails closed when both push cleanup boundaries fail', async () => {
mockedSupabase.rpc.mockResolvedValue({
data: null,
error: { code: '503', message: 'network unavailable' },
})
mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable'))
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).rejects.toMatchObject({
code: 'native',
})
})
test('invalidates the native token while device registration is unavailable', async () => {
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
await expect(detachPushRegistrationForLogout(USER_ID, null)).resolves.toBeUndefined()
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
})
})