Skip to content

Commit ae9685c

Browse files
authored
TT-7315 handle upload busy state (#316)
- Introduced isFirstFile flag to determine when to wait for external import/export during uploads. - Updated getImportExportBusy logic to account for the busy state of previous imports, improving upload handling for subsequent files. - Ensured busy state is set correctly based on the import/export status when starting uploads. - Changed the import statement for MediaFileAttributes to use TypeScript's type import syntax, improving clarity and consistency in the test file.
1 parent a7aa97d commit ae9685c

2 files changed

Lines changed: 186 additions & 2 deletions

File tree

src/renderer/src/components/Uploader.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export const Uploader = (props: IProps) => {
131131
const [errMsgs] = useState<string[]>([]);
132132
const { localizedArtifactTypeFromId } = useArtifactType();
133133
const getGlobal = useGetGlobal();
134+
/** True if import/export was busy when the current batch started (before we set busy). */
135+
const importWasBusyRef = useRef(false);
134136

135137
const handleSpeakerChange = (speaker: string) => {
136138
onSpeakerChange && onSpeakerChange(speaker);
@@ -318,6 +320,7 @@ export const Uploader = (props: IProps) => {
318320
recordedbyUserId: string;
319321
sourceMediaId: string;
320322
};
323+
const isFirstFile = currentlyLoading === 0;
321324
nextUpload({
322325
record: mediafile,
323326
files: uploadList,
@@ -327,7 +330,13 @@ export const Uploader = (props: IProps) => {
327330
errorReporter,
328331
uploadType: uploadType ?? UploadType.Media,
329332
cb: itemComplete,
330-
getImportExportBusy: () => Boolean(getGlobal('importexportBusy')),
333+
// First file waits only for import/export that was already in progress at batch
334+
// start; later files skip the wait while importexportBusy stays true until
335+
// finishMessage (set in uploadMedia).
336+
getImportExportBusy:
337+
isFirstFile && importWasBusyRef.current
338+
? () => Boolean(getGlobal('importexportBusy'))
339+
: undefined,
331340
onTerminalFailure: (info) => {
332341
showMessage(
333342
formatUploadTerminalFailureMessage(t, info),
@@ -377,7 +386,6 @@ export const Uploader = (props: IProps) => {
377386
showMessage(t.selectFiles);
378387
return;
379388
}
380-
if (!noBusy) setBusy(true);
381389
if (
382390
uploadType &&
383391
![
@@ -392,6 +400,8 @@ export const Uploader = (props: IProps) => {
392400
fileList.current = files;
393401
mediaIdRef.current = new Array<string>();
394402
artifactTypeRef.current = artifactState?.id || '';
403+
importWasBusyRef.current = Boolean(getGlobal('importexportBusy'));
404+
if (!noBusy) setBusy(true);
395405
doUpload(0);
396406
};
397407

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
/* eslint-disable @typescript-eslint/no-require-imports */
3+
import Axios from 'axios';
4+
import { UploadType } from '../../components/UploadType';
5+
import { type MediaFileAttributes } from '../../model';
6+
import { waitForImportExportIdle } from './uploadRetry';
7+
8+
jest.mock('../../../api-variable', () => ({
9+
API_CONFIG: { host: 'https://api.test', sizeLimit: '500' },
10+
}));
11+
jest.mock('../../components/MediaUpload', () => ({
12+
SIZELIMIT: () => 500,
13+
}));
14+
jest.mock('axios');
15+
jest.mock('../../auth/bugsnagClient', () => ({}));
16+
jest.mock('./pendingMediaUploads', () => ({
17+
appendPendingMediaUpload: jest.fn(),
18+
removePendingMediaUpload: jest.fn(),
19+
}));
20+
jest.mock('../../utils', () => ({
21+
dataPath: jest.fn(),
22+
infoMsg: jest.fn((e: Error) => e.message),
23+
logError: jest.fn(),
24+
PathType: { MEDIA: 'media' },
25+
Severity: { error: 'error' },
26+
createPathFolder: jest.fn(),
27+
removeExtension: jest.fn((name: string) => ({
28+
name,
29+
ext: 'mp3',
30+
})),
31+
}));
32+
33+
jest.mock('./uploadRetry', () => {
34+
const actual = jest.requireActual('./uploadRetry');
35+
return {
36+
...actual,
37+
waitForImportExportIdle: jest.fn(actual.waitForImportExportIdle),
38+
sleepMs: jest.fn(() => Promise.resolve()),
39+
};
40+
});
41+
42+
const { nextUpload } = require('./actions') as typeof import('./actions');
43+
44+
const mockedAxios = Axios as jest.Mocked<typeof Axios>;
45+
const mockedWait = waitForImportExportIdle as jest.MockedFunction<
46+
typeof waitForImportExportIdle
47+
>;
48+
49+
const baseRecord = {
50+
planId: '1',
51+
versionNumber: 1,
52+
originalFile: 'test.mp3',
53+
contentType: 'audio/mpeg',
54+
artifactTypeId: '',
55+
passageId: '',
56+
userId: '1',
57+
recordedbyUserId: '1',
58+
sourceMediaId: '',
59+
sourceSegments: '{}',
60+
performedBy: null,
61+
topic: '',
62+
eafUrl: '',
63+
transcription: '',
64+
} as MediaFileAttributes & {
65+
planId: string;
66+
artifactTypeId: string;
67+
passageId: string;
68+
userId: string;
69+
recordedbyUserId: string;
70+
sourceMediaId: string;
71+
};
72+
73+
const makeFile = () =>
74+
new File([new Uint8Array([1, 2, 3])], 'test.mp3', { type: 'audio/mpeg' });
75+
76+
const vndResponse = {
77+
data: {
78+
data: {
79+
id: '42',
80+
type: 'mediafiles',
81+
attributes: {
82+
'version-number': 1,
83+
'original-file': 'test.mp3',
84+
'content-type': 'audio/mpeg',
85+
'audio-url': 'https://s3.example/presigned',
86+
'eaf-url': '',
87+
'date-created': '2026-01-01T00:00:00.000Z',
88+
'source-segments': '{}',
89+
'performed-by': null,
90+
topic: '',
91+
transcription: '',
92+
},
93+
},
94+
},
95+
};
96+
97+
describe('nextUpload import/export busy handling', () => {
98+
let dispatch: jest.Mock;
99+
100+
beforeEach(() => {
101+
jest.clearAllMocks();
102+
dispatch = jest.fn();
103+
mockedAxios.post.mockResolvedValue(vndResponse as never);
104+
mockedWait.mockImplementation(async (getBusy) => {
105+
while (getBusy()) {
106+
await Promise.resolve();
107+
}
108+
});
109+
// uploadFile uses XHR; skip PUT by using text/plain non-downloadable - no, mp3 is downloadable
110+
// Mock uploadFile path via successful PUT - need to mock XMLHttpRequest
111+
const xhrProto = XMLHttpRequest.prototype;
112+
jest.spyOn(xhrProto, 'open').mockImplementation(function (
113+
this: XMLHttpRequest,
114+
_method: string,
115+
_url: string | URL
116+
) {
117+
return undefined;
118+
});
119+
jest.spyOn(xhrProto, 'send').mockImplementation(function (
120+
this: XMLHttpRequest
121+
) {
122+
Object.defineProperty(this, 'status', { value: 200, configurable: true });
123+
if (this.onload) this.onload(new ProgressEvent('load'));
124+
});
125+
jest
126+
.spyOn(xhrProto, 'setRequestHeader')
127+
.mockImplementation(() => undefined);
128+
});
129+
130+
afterEach(() => {
131+
jest.restoreAllMocks();
132+
});
133+
134+
const flushPromises = async (times = 12) => {
135+
for (let i = 0; i < times; i += 1) {
136+
await Promise.resolve();
137+
}
138+
};
139+
140+
const runNextUpload = async (
141+
overrides: Partial<Parameters<typeof nextUpload>[0]>
142+
) => {
143+
const action = nextUpload({
144+
record: baseRecord,
145+
files: [makeFile()],
146+
n: 0,
147+
token: 'token',
148+
offline: false,
149+
errorReporter: {} as never,
150+
uploadType: UploadType.Media,
151+
cb: jest.fn(),
152+
...overrides,
153+
});
154+
action(dispatch);
155+
await flushPromises();
156+
};
157+
158+
it('proceeds when import is not busy', async () => {
159+
await runNextUpload({
160+
getImportExportBusy: () => false,
161+
});
162+
163+
expect(mockedWait).toHaveBeenCalled();
164+
expect(mockedAxios.post).toHaveBeenCalled();
165+
});
166+
167+
it('skips import wait when getImportExportBusy is omitted (subsequent batch files)', async () => {
168+
mockedWait.mockClear();
169+
await runNextUpload({});
170+
171+
expect(mockedWait).not.toHaveBeenCalled();
172+
expect(mockedAxios.post).toHaveBeenCalled();
173+
});
174+
});

0 commit comments

Comments
 (0)