169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
jest.mock('react-native', () => {
|
|
const nativeModule = {
|
|
peekPendingImport: jest.fn(),
|
|
consumePendingImport: jest.fn(),
|
|
addListener: jest.fn(),
|
|
removeListeners: jest.fn(),
|
|
};
|
|
let incomingListener: (() => void) | null = null;
|
|
class NativeEventEmitter {
|
|
addListener(_eventName: string, listener: () => void): { remove: jest.Mock } {
|
|
incomingListener = listener;
|
|
return { remove: jest.fn() };
|
|
}
|
|
}
|
|
return {
|
|
NativeEventEmitter,
|
|
NativeModules: { D3ROIncomingMedia: nativeModule },
|
|
Platform: { OS: 'android' },
|
|
__incomingMediaTestDoubles: {
|
|
nativeModule,
|
|
emitIncoming: () => incomingListener?.(),
|
|
clearIncoming: () => { incomingListener = null; },
|
|
},
|
|
};
|
|
});
|
|
|
|
jest.mock('react-native-file-access', () => {
|
|
const fileSystem = {
|
|
exists: jest.fn(),
|
|
unlink: jest.fn(),
|
|
stat: jest.fn(),
|
|
readFileChunk: jest.fn(),
|
|
};
|
|
return {
|
|
Dirs: { CacheDir: '/data/user/0/com.d3ro.voice/cache' },
|
|
FileSystem: fileSystem,
|
|
__incomingMediaFileSystem: fileSystem,
|
|
};
|
|
});
|
|
|
|
import {
|
|
consumeIncomingMediaAudio,
|
|
getPendingIncomingMediaId,
|
|
onIncomingMediaAvailable,
|
|
} from '../src/features/import/incoming-media-intent';
|
|
import { AudioPipelineError } from '../src/features/import/audio-import-types';
|
|
|
|
const testDoubles = (jest.requireMock('react-native') as {
|
|
__incomingMediaTestDoubles: {
|
|
nativeModule: { peekPendingImport: jest.Mock; consumePendingImport: jest.Mock };
|
|
emitIncoming: () => void;
|
|
clearIncoming: () => void;
|
|
};
|
|
}).__incomingMediaTestDoubles;
|
|
const mockFileSystem = (jest.requireMock('react-native-file-access') as {
|
|
__incomingMediaFileSystem: {
|
|
exists: jest.Mock; unlink: jest.Mock; stat: jest.Mock; readFileChunk: jest.Mock;
|
|
};
|
|
}).__incomingMediaFileSystem;
|
|
|
|
const PATH = '/data/user/0/com.d3ro.voice/cache/d3ro-incoming-media/incoming-123e4567-e89b-42d3-a456-426614174000.m4a';
|
|
|
|
function wavHeader(): string {
|
|
return Buffer.from('RIFF\u0001\u0002\u0003\u0004WAVE', 'binary').toString('base64');
|
|
}
|
|
|
|
describe('incoming Android media intent bridge', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
testDoubles.clearIncoming();
|
|
testDoubles.nativeModule.peekPendingImport.mockResolvedValue(null);
|
|
testDoubles.nativeModule.consumePendingImport.mockResolvedValue(null);
|
|
mockFileSystem.exists.mockResolvedValue(false);
|
|
mockFileSystem.unlink.mockResolvedValue(undefined);
|
|
mockFileSystem.stat.mockResolvedValue({ type: 'file', size: 12 });
|
|
mockFileSystem.readFileChunk.mockResolvedValue(wavHeader());
|
|
});
|
|
|
|
test('routes a valid app-owned Korean filename through the normal audio validation contract', async () => {
|
|
testDoubles.nativeModule.consumePendingImport.mockResolvedValue({
|
|
kind: 'media',
|
|
id: '123e4567-e89b-42d3-a456-426614174000',
|
|
path: PATH,
|
|
uri: `file://${PATH}`,
|
|
fileName: '회의 녹음 원본.m4a',
|
|
mimeType: 'audio/wav',
|
|
sizeBytes: 12,
|
|
createdAtMs: Date.now(),
|
|
});
|
|
|
|
const input = await consumeIncomingMediaAudio();
|
|
|
|
expect(input).not.toBeNull();
|
|
expect(input?.source).toBe('share-intent');
|
|
expect(input?.fileName).toBe('회의-녹음-원본.m4a');
|
|
expect(mockFileSystem.stat).toHaveBeenCalledWith(PATH);
|
|
});
|
|
|
|
test('refuses a malformed path before it can be read or deleted', async () => {
|
|
testDoubles.nativeModule.consumePendingImport.mockResolvedValue({
|
|
kind: 'media',
|
|
id: '123e4567-e89b-42d3-a456-426614174000',
|
|
path: '/sdcard/Download/voice.m4a',
|
|
uri: 'file:///sdcard/Download/voice.m4a',
|
|
fileName: 'voice.m4a',
|
|
mimeType: 'audio/mp4',
|
|
sizeBytes: 12,
|
|
createdAtMs: Date.now(),
|
|
});
|
|
|
|
await expect(consumeIncomingMediaAudio()).rejects.toBeInstanceOf(AudioPipelineError);
|
|
expect(mockFileSystem.stat).not.toHaveBeenCalled();
|
|
expect(mockFileSystem.unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('uses a one-way native pending id and subscribes to warm deliveries', async () => {
|
|
testDoubles.nativeModule.peekPendingImport.mockResolvedValue('123e4567-e89b-42d3-a456-426614174000');
|
|
await expect(getPendingIncomingMediaId()).resolves.toBe('123e4567-e89b-42d3-a456-426614174000');
|
|
|
|
const listener = jest.fn();
|
|
const subscription = onIncomingMediaAvailable(listener);
|
|
testDoubles.emitIncoming();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
subscription.remove();
|
|
});
|
|
|
|
test('fails closed when a native pending id has the wrong shape', async () => {
|
|
testDoubles.nativeModule.peekPendingImport.mockResolvedValue('../../replay');
|
|
await expect(getPendingIncomingMediaId()).resolves.toBeNull();
|
|
});
|
|
|
|
test.each([
|
|
['provider-unavailable', 'file-read', 'revoked access'],
|
|
['video-no-audio', 'unsupported-mime', 'does not contain an audio track'],
|
|
['video-audio-unsupported', 'unsupported-mime', 'format is not supported'],
|
|
['file-too-large', 'file-too-large', 'too large'],
|
|
])('turns native one-shot failure %s into a visible public pipeline error', async (
|
|
errorCode,
|
|
expectedCode,
|
|
message,
|
|
) => {
|
|
testDoubles.nativeModule.consumePendingImport.mockResolvedValue({
|
|
kind: 'error',
|
|
id: '123e4567-e89b-42d3-a456-426614174000',
|
|
errorCode,
|
|
createdAtMs: Date.now(),
|
|
});
|
|
|
|
await expect(consumeIncomingMediaAudio()).rejects.toMatchObject({
|
|
code: expectedCode,
|
|
message: expect.stringContaining(message),
|
|
});
|
|
expect(mockFileSystem.stat).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects unknown native failure codes instead of exposing native details', async () => {
|
|
testDoubles.nativeModule.consumePendingImport.mockResolvedValue({
|
|
kind: 'error',
|
|
id: '123e4567-e89b-42d3-a456-426614174000',
|
|
errorCode: 'java.lang.SecurityException: secret-provider',
|
|
createdAtMs: Date.now(),
|
|
});
|
|
|
|
await expect(consumeIncomingMediaAudio()).rejects.toMatchObject({
|
|
code: 'file-read',
|
|
message: 'Shared media failure metadata is invalid',
|
|
});
|
|
});
|
|
});
|