feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
273
apps/mobile-rn/__tests__/notifications.test.ts
Normal file
273
apps/mobile-rn/__tests__/notifications.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue