071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
315 lines
8.6 KiB
TypeScript
315 lines
8.6 KiB
TypeScript
import * as Notifications from 'expo-notifications';
|
|
import { Platform } from 'react-native';
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
HOST_BRIDGE_BADGE_COUNT_MAX,
|
|
HOST_BRIDGE_PROTOCOL,
|
|
HOST_BRIDGE_VERSION,
|
|
type HostBridgeRequest,
|
|
} from '../../../../packages/shared/src/contracts/hostBridge';
|
|
import { setMobileAppBadgeCount } from './badge';
|
|
|
|
type NotificationPermissionStatus = Awaited<
|
|
ReturnType<typeof Notifications.getPermissionsAsync>
|
|
>;
|
|
|
|
const BADGE_GRANTED_PERMISSION = {
|
|
status: 'granted',
|
|
granted: true,
|
|
canAskAgain: true,
|
|
expires: 'never',
|
|
ios: {
|
|
status: 2,
|
|
allowsBadge: true,
|
|
},
|
|
} as unknown as NotificationPermissionStatus;
|
|
|
|
const BADGE_DENIED_PERMISSION = {
|
|
status: 'denied',
|
|
granted: false,
|
|
canAskAgain: false,
|
|
expires: 'never',
|
|
ios: {
|
|
status: 1,
|
|
allowsBadge: false,
|
|
},
|
|
} as unknown as NotificationPermissionStatus;
|
|
|
|
vi.mock('expo-notifications', () => ({
|
|
getPermissionsAsync: vi.fn(),
|
|
requestPermissionsAsync: vi.fn(),
|
|
setBadgeCountAsync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('react-native', () => ({
|
|
Platform: {
|
|
OS: 'ios',
|
|
},
|
|
}));
|
|
|
|
function request(payload?: unknown): HostBridgeRequest {
|
|
return {
|
|
bridge: HOST_BRIDGE_PROTOCOL,
|
|
version: HOST_BRIDGE_VERSION,
|
|
id: 'badge-request',
|
|
method: 'app.setBadgeCount',
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function setPlatformOS(os: 'ios' | 'android') {
|
|
(Platform as { OS: 'ios' | 'android' }).OS = os;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setPlatformOS('ios');
|
|
vi.mocked(Notifications.getPermissionsAsync).mockReset();
|
|
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
|
BADGE_GRANTED_PERMISSION,
|
|
);
|
|
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
|
|
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
|
BADGE_GRANTED_PERMISSION,
|
|
);
|
|
vi.mocked(Notifications.setBadgeCountAsync).mockReset();
|
|
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(true);
|
|
});
|
|
|
|
describe('mobile app badge helper', () => {
|
|
test('sets and clears the iOS app badge count through Expo Notifications', async () => {
|
|
expect(
|
|
await setMobileAppBadgeCount(
|
|
request({
|
|
count: 12,
|
|
}),
|
|
),
|
|
).toEqual({
|
|
bridge: HOST_BRIDGE_PROTOCOL,
|
|
version: HOST_BRIDGE_VERSION,
|
|
id: 'badge-request',
|
|
ok: true,
|
|
result: true,
|
|
});
|
|
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(12);
|
|
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
|
|
|
expect(
|
|
await setMobileAppBadgeCount(
|
|
request({
|
|
count: 0,
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
ok: true,
|
|
result: true,
|
|
});
|
|
expect(Notifications.setBadgeCountAsync).toHaveBeenLastCalledWith(0);
|
|
});
|
|
|
|
test('requests iOS badge permission before updating when it is missing', async () => {
|
|
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
|
BADGE_DENIED_PERMISSION,
|
|
);
|
|
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
|
BADGE_GRANTED_PERMISSION,
|
|
);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 3,
|
|
}),
|
|
),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
result: true,
|
|
});
|
|
|
|
expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({
|
|
ios: {
|
|
allowAlert: false,
|
|
allowBadge: true,
|
|
allowSound: false,
|
|
},
|
|
});
|
|
expect(Notifications.setBadgeCountAsync).toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
test('rejects denied iOS badge permission before touching the system badge', async () => {
|
|
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
|
BADGE_DENIED_PERMISSION,
|
|
);
|
|
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
|
BADGE_DENIED_PERMISSION,
|
|
);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'host_error',
|
|
message: 'app badge permission denied',
|
|
});
|
|
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('maps current badge permission lookup failures to a stable HostBridge error', async () => {
|
|
const error = new Error('native badge permission failed');
|
|
const warnSpy = vi
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(error);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'host_error',
|
|
message: 'app badge permission unavailable',
|
|
});
|
|
|
|
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'mobile app badge failed for permission.current',
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
test('maps badge permission request failures to a stable HostBridge error', async () => {
|
|
const error = new Error('native badge permission request failed');
|
|
const warnSpy = vi
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
|
BADGE_DENIED_PERMISSION,
|
|
);
|
|
vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(
|
|
error,
|
|
);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'host_error',
|
|
message: 'app badge permission unavailable',
|
|
});
|
|
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'mobile app badge failed for permission.request',
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
test('maps native badge update false results to a stable HostBridge error', async () => {
|
|
const warnSpy = vi
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
vi.mocked(Notifications.setBadgeCountAsync).mockResolvedValue(false);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'host_error',
|
|
message: 'app badge update unavailable',
|
|
});
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'mobile app badge failed for update.rejected',
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
test('maps native badge update rejections to a stable HostBridge error', async () => {
|
|
const error = new Error('native badge failed');
|
|
const warnSpy = vi
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
vi.mocked(Notifications.setBadgeCountAsync).mockRejectedValue(error);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'host_error',
|
|
message: 'app badge update unavailable',
|
|
});
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'mobile app badge failed for update.set_count',
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
test('rejects invalid badge counts before touching the system badge', async () => {
|
|
for (const count of [-1, 1.5, HOST_BRIDGE_BADGE_COUNT_MAX + 1]) {
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count,
|
|
}),
|
|
),
|
|
).rejects.toThrowError(
|
|
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
|
|
);
|
|
}
|
|
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects missing badge payload before touching the system badge', async () => {
|
|
await expect(setMobileAppBadgeCount(request({}))).rejects.toThrowError(
|
|
`count must be an integer between 0 and ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
|
|
);
|
|
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns unsupported on Android before validating payload or touching badge APIs', async () => {
|
|
setPlatformOS('android');
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: 1,
|
|
}),
|
|
),
|
|
).rejects.toThrowError(
|
|
'app badge count is only supported on iOS mobile shell',
|
|
);
|
|
|
|
await expect(
|
|
setMobileAppBadgeCount(
|
|
request({
|
|
count: HOST_BRIDGE_BADGE_COUNT_MAX + 1,
|
|
}),
|
|
),
|
|
).rejects.toThrowError(
|
|
'app badge count is only supported on iOS mobile shell',
|
|
);
|
|
|
|
expect(Notifications.setBadgeCountAsync).not.toHaveBeenCalled();
|
|
expect(Notifications.getPermissionsAsync).not.toHaveBeenCalled();
|
|
});
|
|
});
|