feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,281 @@
import { FileSystem } from 'react-native-file-access';
import {
buildAudioStorageKey,
countTranscriptWords,
decodeBase64,
detectAudioMimeType,
localPathFromFileUri,
normalizeAudioMimeType,
sanitizeAudioFileName,
validateLocalAudio,
} from '../src/features/import/audio-validation';
import {
AudioPipelineError,
MAX_IMPORT_BYTES,
} from '../src/features/import/audio-import-types';
import { createMultipartAudioBody } from '../src/features/import/multipart-audio';
function bytes(...values: number[]): Uint8Array {
return Uint8Array.from(values);
}
function ascii(value: string): number[] {
return [...value].map(character => character.charCodeAt(0));
}
function base64(value: Uint8Array): string {
return Buffer.from(value).toString('base64');
}
describe('audio import validation', () => {
test('decodes padded and unpadded-content base64 exactly', () => {
expect([...decodeBase64('AQIDBA==')]).toEqual([1, 2, 3, 4]);
expect([...decodeBase64('AAEC')]).toEqual([0, 1, 2]);
expect([...decodeBase64('')]).toEqual([]);
});
test.each(['abc', 'AA=A', 'AA?=', '===='])(
'rejects malformed base64 %s',
value => {
expect(() => decodeBase64(value)).toThrow(AudioPipelineError);
},
);
test.each([
['audio/vnd.wave', 'audio/wav'],
['Audio/MP3; charset=binary', 'audio/mpeg'],
['audio/m4a', 'audio/mp4'],
['audio/x-flac', 'audio/flac'],
['application/pdf', null],
[null, null],
])('normalizes MIME %s to %s', (input, expected) => {
expect(normalizeAudioMimeType(input)).toBe(expected);
});
test.each([
[bytes(...ascii('RIFF'), 1, 2, 3, 4, ...ascii('WAVE')), 'audio/wav'],
[bytes(...ascii('fLaC')), 'audio/flac'],
[bytes(...ascii('OggS')), 'audio/ogg'],
[bytes(0x1a, 0x45, 0xdf, 0xa3), 'audio/webm'],
[bytes(0, 0, 0, 20, ...ascii('ftyp'), 0, 0, 0, 0), 'audio/mp4'],
[bytes(...ascii('ID3')), 'audio/mpeg'],
[bytes(0xff, 0xf1), 'audio/aac'],
[bytes(0xff, 0xfb), 'audio/mpeg'],
[bytes(...ascii('PK\u0003\u0004')), null],
])('detects audio header as %s', (header, expected) => {
expect(detectAudioMimeType(header)).toBe(expected);
});
test('sanitizes file names and supplies a MIME extension', () => {
expect(sanitizeAudioFileName('../회의 녹음?.m4a', 'audio/mp4')).toBe('회의-녹음-.m4a');
expect(sanitizeAudioFileName(null, 'audio/wav')).toBe('imported-audio.wav');
expect(sanitizeAudioFileName('voice', 'audio/mpeg')).toBe('voice.mp3');
});
test('builds an owned deterministic storage key', () => {
const userId = '123e4567-e89b-42d3-a456-426614174000';
const checksum = 'a'.repeat(64);
expect(buildAudioStorageKey(userId, checksum, '회의 녹음.m4a')).toBe(
`${userId}/imports/${checksum}/audio.m4a`,
);
});
test('rejects invalid ownership and checksum data', () => {
expect(() => buildAudioStorageKey('other-user', 'a'.repeat(64), 'a.wav')).toThrow(
AudioPipelineError,
);
expect(() => buildAudioStorageKey('123e4567-e89b-42d3-a456-426614174000', 'a', 'a.wav')).toThrow(
AudioPipelineError,
);
});
test('counts whitespace-separated transcript words without inventing content', () => {
expect(countTranscriptWords(' 안녕 세상\nhello ')).toBe(3);
expect(countTranscriptWords('')).toBe(0);
});
test('builds an exact multipart body without changing audio bytes', () => {
const boundary = '----D3ROVoiceMobileBoundary12345';
const audio = bytes(0, 1, 2, 13, 10, 255);
const multipart = createMultipartAudioBody(
{ fileName: '회의.wav', mimeType: 'audio/wav' },
audio,
'ko',
boundary,
);
const body = new Uint8Array(multipart.body);
const prefix = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="__.wav"\r\nContent-Type: audio/wav\r\n\r\n`,
'ascii',
);
expect([...body.slice(prefix.length, prefix.length + audio.length)]).toEqual([...audio]);
expect(Buffer.from(body).toString('latin1')).toContain(
`name="language_code"\r\n\r\nko\r\n--${boundary}--\r\n`,
);
});
test('rejects multipart header injection and invalid language codes', () => {
expect(() => createMultipartAudioBody(
{ fileName: 'a.wav', mimeType: 'text/plain' },
bytes(1),
'ko',
'----D3ROVoiceMobileBoundary12345',
)).toThrow(AudioPipelineError);
expect(() => createMultipartAudioBody(
{ fileName: 'a.wav', mimeType: 'audio/wav' },
bytes(1),
'ko\r\nadmin',
'----D3ROVoiceMobileBoundary12345',
)).toThrow(AudioPipelineError);
});
test('accepts every meeting language supported by the recording queue', () => {
for (const languageCode of ['ko', 'en', 'ja', 'zh-cn']) {
expect(() => createMultipartAudioBody(
{ fileName: 'voice.wav', mimeType: 'audio/wav' },
Uint8Array.from([1, 2, 3]),
languageCode,
'--------------------d3ro-language',
)).not.toThrow();
}
});
test('accepts only absolute file URIs', () => {
expect(localPathFromFileUri('file:///data/user/0/cache/a%20b.wav')).toBe(
'/data/user/0/cache/a b.wav',
);
expect(() => localPathFromFileUri('content://downloads/1')).toThrow(
AudioPipelineError,
);
});
describe('local file inspection', () => {
const mockedFileSystem = FileSystem as unknown as {
stat: jest.Mock;
readFileChunk: jest.Mock;
};
const dispose = jest.fn(async () => undefined);
beforeEach(() => {
jest.clearAllMocks();
mockedFileSystem.stat = jest.fn(async () => ({
filename: 'voice.wav',
lastModified: 1,
path: '/cache/voice.wav',
size: 12,
type: 'file',
}));
mockedFileSystem.readFileChunk = jest.fn(async () => base64(
bytes(...ascii('RIFF'), 1, 2, 3, 4, ...ascii('WAVE')),
));
});
test('returns authoritative stat and normalized MIME data', async () => {
await expect(validateLocalAudio({
path: '/cache/voice.wav',
uri: 'file:///cache/voice.wav',
fileName: 'voice.wav',
reportedMimeType: 'audio/vnd.wave',
reportedSize: 12,
durationMs: null,
source: 'file-picker',
dispose,
})).resolves.toMatchObject({
sizeBytes: 12,
mimeType: 'audio/wav',
fileName: 'voice.wav',
});
});
test('rejects an empty file', async () => {
mockedFileSystem.stat.mockResolvedValue({ type: 'file', size: 0 });
await expect(validateLocalAudio({
path: '/cache/empty.wav',
uri: 'file:///cache/empty.wav',
fileName: 'empty.wav',
reportedMimeType: 'audio/wav',
reportedSize: 0,
durationMs: null,
source: 'file-picker',
dispose,
})).rejects.toMatchObject({ code: 'empty-file' });
});
test('rejects a file above the mobile memory-safe limit', async () => {
mockedFileSystem.stat.mockResolvedValue({
type: 'file',
size: MAX_IMPORT_BYTES + 1,
});
await expect(validateLocalAudio({
path: '/cache/large.wav',
uri: 'file:///cache/large.wav',
fileName: 'large.wav',
reportedMimeType: 'audio/wav',
reportedSize: MAX_IMPORT_BYTES + 1,
durationMs: null,
source: 'file-picker',
dispose,
})).rejects.toMatchObject({ code: 'file-too-large' });
});
test('rejects metadata changes during copy', async () => {
await expect(validateLocalAudio({
path: '/cache/changed.wav',
uri: 'file:///cache/changed.wav',
fileName: 'changed.wav',
reportedMimeType: 'audio/wav',
reportedSize: 99,
durationMs: null,
source: 'file-picker',
dispose,
})).rejects.toMatchObject({ code: 'file-read' });
});
test('rejects a renamed non-audio payload', async () => {
mockedFileSystem.readFileChunk.mockResolvedValue(base64(bytes(...ascii('PK\u0003\u0004'))));
await expect(validateLocalAudio({
path: '/cache/fake.wav',
uri: 'file:///cache/fake.wav',
fileName: 'fake.wav',
reportedMimeType: 'audio/wav',
reportedSize: 12,
durationMs: null,
source: 'file-picker',
dispose,
})).rejects.toMatchObject({ code: 'unsupported-mime' });
});
test('rejects a conflicting provider MIME', async () => {
await expect(validateLocalAudio({
path: '/cache/voice.wav',
uri: 'file:///cache/voice.wav',
fileName: 'voice.wav',
reportedMimeType: 'audio/mpeg',
reportedSize: 12,
durationMs: null,
source: 'file-picker',
dispose,
})).rejects.toMatchObject({ code: 'unsupported-mime' });
});
test('accepts Android audio/mpeg metadata for a magic-verified M4A container', async () => {
mockedFileSystem.readFileChunk.mockResolvedValue(base64(
bytes(0, 0, 0, 20, ...ascii('ftyp'), 0, 0, 0, 0),
));
await expect(validateLocalAudio({
path: '/cache/voice.m4a',
uri: 'file:///cache/voice.m4a',
fileName: 'voice.m4a',
reportedMimeType: 'audio/mpeg',
reportedSize: 12,
durationMs: null,
source: 'file-picker',
dispose,
})).resolves.toMatchObject({
sizeBytes: 12,
mimeType: 'audio/mp4',
fileName: 'voice.m4a',
});
});
});
});