mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 15:37:44 +02:00
Projects included: - maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing) - manacore (Expo mobile + SvelteKit web + Astro landing) - manadeck (NestJS backend + Expo mobile + SvelteKit web) - memoro (Expo mobile + SvelteKit web + Astro landing) This commit preserves the current state before monorepo restructuring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
749 lines
19 KiB
TypeScript
749 lines
19 KiB
TypeScript
/**
|
|
* Edge Case and Error Scenario Tests for Video Upload
|
|
* Tests boundary conditions, network failures, and timeout scenarios
|
|
*/
|
|
|
|
import { fileStorageService } from '~/features/storage/fileStorage.service';
|
|
import { AudioFile } from '~/features/storage/storage.types';
|
|
import NetInfo from '@react-native-community/netinfo';
|
|
|
|
// Mock dependencies
|
|
jest.mock('@react-native-community/netinfo');
|
|
jest.mock('~/features/auth/services/tokenManager');
|
|
jest.mock('~/features/credits/creditService');
|
|
|
|
describe('Video Upload Edge Cases and Error Scenarios', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Network failure scenarios', () => {
|
|
it('should handle network disconnection during upload', async () => {
|
|
const mockVideoFile: AudioFile = {
|
|
id: 'test-video-1',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'test-video.mp4',
|
|
duration: 180,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Mock network as disconnected
|
|
(NetInfo.fetch as jest.Mock).mockResolvedValue({
|
|
isConnected: false,
|
|
isInternetReachable: false
|
|
});
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockVideoFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false, // don't skip offline queue
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
status: 'pending',
|
|
message: expect.stringContaining('queued')
|
|
});
|
|
});
|
|
|
|
it('should retry on network timeout', async () => {
|
|
const mockVideoFile: AudioFile = {
|
|
id: 'test-video-2',
|
|
uri: 'file:///test/large-video.mp4',
|
|
filename: 'large-video.mp4',
|
|
duration: 600,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Mock fetch to timeout
|
|
global.fetch = jest.fn().mockImplementation(() => {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
throw new Error('Network timeout');
|
|
}, 600000); // Simulate 10-minute timeout
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockVideoFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should handle intermittent connectivity', async () => {
|
|
const mockVideoFile: AudioFile = {
|
|
id: 'test-video-3',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
let callCount = 0;
|
|
(NetInfo.fetch as jest.Mock).mockImplementation(() => {
|
|
callCount++;
|
|
return Promise.resolve({
|
|
isConnected: callCount % 2 === 0, // Alternate between connected and disconnected
|
|
isInternetReachable: callCount % 2 === 0
|
|
});
|
|
});
|
|
|
|
// Should eventually succeed or queue based on final state
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockVideoFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should handle DNS resolution failures', async () => {
|
|
const mockVideoFile: AudioFile = {
|
|
id: 'test-video-4',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
global.fetch = jest.fn().mockRejectedValue(new Error('ENOTFOUND: DNS lookup failed'));
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockVideoFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('File corruption scenarios', () => {
|
|
it('should detect corrupted video file header', async () => {
|
|
const mockCorruptedFile: AudioFile = {
|
|
id: 'corrupted-video',
|
|
uri: 'file:///test/corrupted.mp4',
|
|
filename: 'corrupted.mp4',
|
|
duration: 0, // Zero duration indicates corruption
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Should fail validation before upload
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockCorruptedFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should handle partially downloaded video files', async () => {
|
|
const mockPartialFile: AudioFile = {
|
|
id: 'partial-video',
|
|
uri: 'file:///test/partial.mp4',
|
|
filename: 'partial.mp4.part',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Service should validate file completeness
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockPartialFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should handle video files with missing codec', async () => {
|
|
const mockInvalidCodec: AudioFile = {
|
|
id: 'invalid-codec',
|
|
uri: 'file:///test/invalid-codec.mp4',
|
|
filename: 'invalid-codec.mp4',
|
|
duration: 180,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// The audio microservice should detect and handle this
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
text: () => Promise.resolve('Unsupported codec')
|
|
} as Response);
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockInvalidCodec,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('File size boundary tests', () => {
|
|
it('should handle extremely small video files', async () => {
|
|
const mockTinyFile: AudioFile = {
|
|
id: 'tiny-video',
|
|
uri: 'file:///test/tiny.mp4',
|
|
filename: 'tiny.mp4',
|
|
duration: 1, // 1 second
|
|
createdAt: new Date(),
|
|
size: 1024 // 1KB
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockTinyFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should handle extremely large video files', async () => {
|
|
const mockLargeFile: AudioFile = {
|
|
id: 'huge-video',
|
|
uri: 'file:///test/huge.mp4',
|
|
filename: 'huge.mp4',
|
|
duration: 86400, // 24 hours
|
|
createdAt: new Date(),
|
|
size: 10 * 1024 * 1024 * 1024 // 10GB
|
|
};
|
|
|
|
// Should use batch processing for large files
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockLargeFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
// Should trigger batch processing path
|
|
});
|
|
|
|
it('should handle file at exact size limit', async () => {
|
|
const mockBoundaryFile: AudioFile = {
|
|
id: 'boundary-video',
|
|
uri: 'file:///test/boundary.mp4',
|
|
filename: 'boundary.mp4',
|
|
duration: 6900, // 115 minutes (boundary for fast/batch)
|
|
createdAt: new Date(),
|
|
size: 100 * 1024 * 1024 // 100MB
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockBoundaryFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Special character handling', () => {
|
|
it('should handle filenames with unicode characters', async () => {
|
|
const mockUnicodeFile: AudioFile = {
|
|
id: 'unicode-video',
|
|
uri: 'file:///test/视频文件.mp4',
|
|
filename: '视频文件.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockUnicodeFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should handle filenames with special characters', async () => {
|
|
const specialNames = [
|
|
'video (1).mp4',
|
|
'my-video_test[HD].mp4',
|
|
'video@2025-01-01.mp4',
|
|
'test & demo.mp4'
|
|
];
|
|
|
|
for (const filename of specialNames) {
|
|
const mockFile: AudioFile = {
|
|
id: `special-${filename}`,
|
|
uri: `file:///test/${filename}`,
|
|
filename: filename,
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should sanitize path traversal attempts', async () => {
|
|
const maliciousPaths = [
|
|
'../../../etc/passwd.mp4',
|
|
'..\\..\\..\\windows\\system32\\video.mp4',
|
|
'test/../../other-user/video.mp4'
|
|
];
|
|
|
|
for (const path of maliciousPaths) {
|
|
const mockFile: AudioFile = {
|
|
id: 'malicious',
|
|
uri: path,
|
|
filename: path,
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Should be rejected by service layer
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Concurrent upload scenarios', () => {
|
|
it('should handle multiple simultaneous video uploads', async () => {
|
|
const uploadPromises = Array(10).fill(null).map((_, index) => {
|
|
const mockFile: AudioFile = {
|
|
id: `concurrent-video-${index}`,
|
|
uri: `file:///test/video-${index}.mp4`,
|
|
filename: `video-${index}.mp4`,
|
|
duration: 120 + (index * 10),
|
|
createdAt: new Date()
|
|
};
|
|
|
|
return fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
});
|
|
|
|
const results = await Promise.allSettled(uploadPromises);
|
|
|
|
// All uploads should either succeed or fail gracefully
|
|
expect(results).toHaveLength(10);
|
|
results.forEach(result => {
|
|
expect(result.status).toMatch(/fulfilled|rejected/);
|
|
});
|
|
});
|
|
|
|
it('should handle upload queue overflow', async () => {
|
|
// Simulate many uploads queued offline
|
|
const queuePromises = Array(100).fill(null).map((_, index) => {
|
|
const mockFile: AudioFile = {
|
|
id: `queue-video-${index}`,
|
|
uri: `file:///test/video-${index}.mp4`,
|
|
filename: `video-${index}.mp4`,
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Mock network as offline
|
|
(NetInfo.fetch as jest.Mock).mockResolvedValue({
|
|
isConnected: false,
|
|
isInternetReachable: false
|
|
});
|
|
|
|
return fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
false, // don't skip queue
|
|
false,
|
|
'video'
|
|
);
|
|
});
|
|
|
|
const results = await Promise.allSettled(queuePromises);
|
|
|
|
// All should be queued
|
|
results.forEach(result => {
|
|
if (result.status === 'fulfilled') {
|
|
expect(result.value.status).toBe('pending');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Memory and resource constraints', () => {
|
|
it('should handle low memory conditions', async () => {
|
|
const mockLargeFile: AudioFile = {
|
|
id: 'memory-test',
|
|
uri: 'file:///test/large-video.mp4',
|
|
filename: 'large-video.mp4',
|
|
duration: 3600,
|
|
createdAt: new Date(),
|
|
size: 2 * 1024 * 1024 * 1024 // 2GB
|
|
};
|
|
|
|
// Monitor memory usage during upload
|
|
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0;
|
|
|
|
await fileStorageService.uploadForTranscription(
|
|
mockLargeFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0;
|
|
const memoryIncrease = finalMemory - initialMemory;
|
|
|
|
// Memory increase should be reasonable (less than 100MB for the upload logic)
|
|
expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024);
|
|
});
|
|
|
|
it('should clean up resources after upload failure', async () => {
|
|
const mockFile: AudioFile = {
|
|
id: 'cleanup-test',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
global.fetch = jest.fn().mockRejectedValue(new Error('Upload failed'));
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
|
|
// Verify resources are cleaned up (no hanging promises, file handles, etc.)
|
|
});
|
|
});
|
|
|
|
describe('Timeout scenarios', () => {
|
|
it('should timeout after configured duration', async () => {
|
|
const mockFile: AudioFile = {
|
|
id: 'timeout-test',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Mock very slow upload
|
|
global.fetch = jest.fn().mockImplementation(() => {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true })
|
|
} as Response);
|
|
}, 700000); // 11+ minutes - should timeout at 10 minutes
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow(/timeout/i);
|
|
});
|
|
|
|
it('should handle slow network gracefully', async () => {
|
|
const mockFile: AudioFile = {
|
|
id: 'slow-network',
|
|
uri: 'file:///test/video.mp4',
|
|
filename: 'video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Simulate slow but successful upload
|
|
global.fetch = jest.fn().mockImplementation(() => {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve({ success: true })
|
|
} as Response);
|
|
}, 5000); // 5 seconds - should succeed
|
|
});
|
|
});
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Video format edge cases', () => {
|
|
it('should handle videos with no audio track', async () => {
|
|
const mockSilentVideo: AudioFile = {
|
|
id: 'silent-video',
|
|
uri: 'file:///test/silent-video.mp4',
|
|
filename: 'silent-video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Should fail at audio microservice level
|
|
global.fetch = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
text: () => Promise.resolve('No audio track found')
|
|
} as Response);
|
|
|
|
await expect(
|
|
fileStorageService.uploadForTranscription(
|
|
mockSilentVideo,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
)
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should handle videos with multiple audio tracks', async () => {
|
|
const mockMultiAudioVideo: AudioFile = {
|
|
id: 'multi-audio',
|
|
uri: 'file:///test/multi-audio.mp4',
|
|
filename: 'multi-audio.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
// Should use first audio track
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockMultiAudioVideo,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should handle videos with variable frame rate', async () => {
|
|
const mockVfrVideo: AudioFile = {
|
|
id: 'vfr-video',
|
|
uri: 'file:///test/vfr-video.mp4',
|
|
filename: 'vfr-video.mp4',
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockVfrVideo,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it('should handle videos with unusual aspect ratios', async () => {
|
|
const aspectRatios = [
|
|
'portrait-9-16.mp4',
|
|
'square-1-1.mp4',
|
|
'ultrawide-21-9.mp4',
|
|
'vertical-4-5.mp4'
|
|
];
|
|
|
|
for (const filename of aspectRatios) {
|
|
const mockFile: AudioFile = {
|
|
id: `aspect-ratio-${filename}`,
|
|
uri: `file:///test/${filename}`,
|
|
filename: filename,
|
|
duration: 120,
|
|
createdAt: new Date()
|
|
};
|
|
|
|
const result = await fileStorageService.uploadForTranscription(
|
|
mockFile,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
'video'
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
});
|