refine uploader, components and their tests (#23)

* feat(uploader, auth_pane): refine uploader and add tests

* chore(package.json): remove unused deps

* test(uploader, components): add tests for uploader and components
This commit is contained in:
Hexxa 2020-12-20 12:03:33 +08:00 committed by GitHub
parent cc0b53eea7
commit e40878f7be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 526 additions and 127 deletions

View file

@ -90,5 +90,5 @@
</head>
<body>
<div id="content"><div id="mount"></div></div>
<script src="static/default-vendors-node_modules_axios_index_js-node_modules_css-loader_dist_runtime_api_js-node_-e9ca3b.bundle.js?910792307d026e16bec7"></script><script src="static/main.bundle.js?910792307d026e16bec7"></script></body>
<script src="static/default-vendors-node_modules_axios_index_js-node_modules_css-loader_dist_runtime_api_js-node_-e9ca3b.bundle.js?aa077616e258b24cc454"></script><script src="static/main.bundle.js?aa077616e258b24cc454"></script></body>
</html>

View file

@ -0,0 +1 @@
jest.setTimeout(15000);

View file

@ -77,6 +77,9 @@
"ts",
"tsx",
"js"
],
"setupFilesAfterEnv": [
"./jest.setup.js"
]
},
"autoBump": {}

View file

@ -0,0 +1,137 @@
import { FileUploader } from "../uploader";
import { FilesClient } from "../files_mock";
import { makePromise } from "../../test/helpers";
describe("Uploader", () => {
const content = ["123456"];
const filePath = "mock/file";
const blob = new Blob(content);
const fileSize = blob.size;
const file = new File(content, filePath);
const makeCreateResp = (status: number): Promise<any> => {
return makePromise({
status: status,
statusText: "",
data: {
path: filePath,
fileSize: fileSize,
},
});
};
const makeStatusResp = (status: number, uploaded: number): Promise<any> => {
return makePromise({
status: status,
statusText: "",
data: {
path: filePath,
isDir: false,
fileSize: fileSize,
uploaded: uploaded,
},
});
};
interface statusResp {
status: number;
uploaded: number;
}
interface TestCase {
createResps: Array<number>;
uploadChunkResps: Array<any>;
uploadStatusResps: Array<any>;
result: boolean;
}
test("test start and upload method", async () => {
const testCases: Array<TestCase> = [
{
// fail to create file 4 times
createResps: [500, 500, 500, 500],
uploadChunkResps: [],
uploadStatusResps: [],
result: false,
},
{
// fail to get status
createResps: [200],
uploadChunkResps: [{ status: 500, uploaded: 0 }],
uploadStatusResps: [{ status: 600, uploaded: 0 }],
result: false,
},
{
// upload ok
createResps: [200],
uploadChunkResps: [
{ status: 200, uploaded: 0 },
{ status: 200, uploaded: 1 },
{ status: 200, uploaded: fileSize },
],
uploadStatusResps: [],
result: true,
},
{
// fail once
createResps: [200],
uploadChunkResps: [
{ status: 200, uploaded: 0 },
{ status: 500, uploaded: 1 },
{ status: 200, uploaded: fileSize },
],
uploadStatusResps: [{ status: 200, uploaded: 1 }],
result: true,
},
{
// fail twice
createResps: [500, 500, 500, 200],
uploadChunkResps: [
{ status: 200, uploaded: 0 },
{ status: 500, uploaded: 1 },
{ status: 500, uploaded: 1 },
{ status: 200, uploaded: fileSize },
],
uploadStatusResps: [
{ status: 500, uploaded: 1 },
{ status: 500, uploaded: 1 },
],
result: true,
},
{
// other errors
createResps: [500, 200],
uploadChunkResps: [
{ status: 601, uploaded: 0 },
{ status: 408, uploaded: fileSize },
{ status: 200, uploaded: 1 },
{ status: 200, uploaded: fileSize },
],
uploadStatusResps: [
{ status: 500, uploaded: 1 },
{ status: 500, uploaded: 1 },
],
result: true,
},
];
for (let i = 0; i < testCases.length; i++) {
const tc = testCases[i];
const uploader = new FileUploader(file, filePath);
const mockClient = new FilesClient("");
const createResps = tc.createResps.map((resp) => makeCreateResp(resp));
mockClient.createMock(createResps);
const uploadChunkResps = tc.uploadChunkResps.map((resp) =>
makeStatusResp(resp.status, resp.uploaded)
);
mockClient.uploadChunkMock(uploadChunkResps);
const uploadStatusResps = tc.uploadStatusResps.map((resp) =>
makeStatusResp(resp.status, resp.uploaded)
);
mockClient.uploadStatusMock(uploadStatusResps);
uploader.setClient(mockClient);
const ret = await uploader.start();
expect(ret).toEqual(tc.result);
}
});
});

View file

@ -1,82 +1,102 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import {MetadataResp, UploadStatusResp, ListResp} from "./";
import {
Response,
UploadStatusResp,
ListResp,
} from "./";
export class FilesClient {
private url: string;
private createMockResp: number;
private deleteMockResp: number;
private metadataMockResp: MetadataResp | null;
private mkdirMockResp: number | null;
private moveMockResp: number;
private uploadChunkMockResp: UploadStatusResp | null;
private uploadStatusMockResp: UploadStatusResp | null;
private listMockResp: ListResp | null;
private createMockRespID: number = 0;
private createMockResps: Array<Promise<Response>>;
private deleteMockResp: Promise<Response>;
private metadataMockResp: Promise<Response>;
private mkdirMockResp: Promise<Response>;
private moveMockResp: Promise<Response>;
private uploadChunkMockResps: Array<Promise<Response<UploadStatusResp>>>;
private uploadChunkMockRespID: number = 0;
private uploadStatusMockResps: Array<Promise<Response<UploadStatusResp>>>;
private uploadStatusMockRespID: number = 0;
private listMockResp: Promise<Response<ListResp>>;
constructor(url: string) {
this.url = url;
}
createMock = (resp: number) => {
this.createMockResp = resp;
}
deleteMock = (resp: number) => {
createMock = (resps: Array<Promise<Response>>) => {
this.createMockResps = resps;
};
deleteMock = (resp: Promise<Response>) => {
this.deleteMockResp = resp;
}
metadataMock = (resp: MetadataResp | null) => {
};
metadataMock = (resp: Promise<Response>) => {
this.metadataMockResp = resp;
}
mkdirMock = (resp: number | null) => {
};
mkdirMock = (resp: Promise<Response>) => {
this.mkdirMockResp = resp;
}
moveMock = (resp: number) => {
};
moveMock = (resp: Promise<Response>) => {
this.moveMockResp = resp;
}
uploadChunkMock = (resp: UploadStatusResp | null) => {
this.uploadChunkMockResp = resp;
}
};
uploadStatusMock = (resp: UploadStatusResp | null) => {
this.uploadStatusMockResp = resp;
}
uploadChunkMock = (resps: Array<Promise<Response<UploadStatusResp>>>) => {
this.uploadChunkMockResps = resps;
};
listMock = (resp: ListResp | null) => {
uploadStatusMock = (resps: Array<Promise<Response<UploadStatusResp>>>) => {
this.uploadStatusMockResps = resps;
};
listMock = (resp: Promise<Response<ListResp>>) => {
this.listMockResp = resp;
}
};
async create(filePath: string, fileSize: number): Promise<number> {
return this.createMockResp;
create = (filePath: string, fileSize: number): Promise<Response> => {
if (this.createMockRespID < this.createMockResps.length) {
return this.createMockResps[this.createMockRespID++];
}
throw new Error(`this.createMockRespID (${this.createMockRespID}) out of bound: ${this.createMockResps.length}`);
};
async delete(filePath: string): Promise<number> {
delete = (filePath: string): Promise<Response> => {
return this.deleteMockResp;
}
};
async metadata(filePath: string): Promise<MetadataResp | null> {
metadata = (filePath: string): Promise<Response> => {
return this.metadataMockResp;
}
};
async mkdir(dirpath: string): Promise<number | null> {
mkdir = (dirpath: string): Promise<Response> => {
return this.mkdirMockResp;
}
};
async move(oldPath: string, newPath: string): Promise<number> {
move = (oldPath: string, newPath: string): Promise<Response> => {
return this.moveMockResp;
}
};
async uploadChunk(
uploadChunk = (
filePath: string,
content: string | ArrayBuffer,
offset: number
): Promise<UploadStatusResp | null> {
return this.uploadChunkMockResp;
): Promise<Response<UploadStatusResp>> => {
if (this.uploadChunkMockRespID < this.uploadChunkMockResps.length) {
return this.uploadChunkMockResps[this.uploadChunkMockRespID++];
}
throw new Error(`this.uploadChunkMockRespID (${this.uploadChunkMockRespID}) out of bound: ${this.uploadChunkMockResps.length}`);
};
async uploadStatus(filePath: string): Promise<UploadStatusResp | null> {
return this.uploadStatusMockResp;
uploadStatus = (filePath: string): Promise<Response<UploadStatusResp>> => {
if (this.uploadStatusMockRespID < this.uploadStatusMockResps.length) {
return this.uploadStatusMockResps[this.uploadStatusMockRespID++];
}
throw new Error(`this.uploadStatusMockRespID (${this.uploadStatusMockRespID}) out of bound: ${this.uploadStatusMockResps.length}`);
};
async list(dirPath: string): Promise<ListResp | null> {
list = (dirPath: string): Promise<Response<ListResp>> => {
return this.listMockResp;
}
};
}

View file

@ -84,7 +84,7 @@ export class BaseClient {
setTimeout(() => {
if (!returned) {
src.cancel("request timeout");
// resolve(TimeoutResp);
resolve(TimeoutResp);
}
}, defaultTimeout);
@ -95,11 +95,8 @@ export class BaseClient {
})
.catch((e) => {
const errMsg = e.toString();
console.log(e);
if (errMsg.includes("i/o timeput")) {
resolve(TimeoutResp);
} else if (errMsg.includes("ERR_EMPTY")) {
if (errMsg.includes("ERR_EMPTY")) {
// this means connection is eliminated by server because of timeout.
resolve(EmptyBodyResp);
} else {
resolve(UnknownErrResp(errMsg));

View file

@ -1,26 +1,27 @@
import { List } from "immutable";
import { Response, UnknownErrResp, UploadStatusResp } from "./";
import { IFilesClient } from "../client";
import { FilesClient } from "../client/files";
import { Response, UnknownErrResp, UploadStatusResp } from "./";
// TODO: get settings from server
const defaultChunkLen = 1024 * 1024 * 30; // 15MB/s
const speedDownRatio = 0.5;
const speedUpRatio = 1.1;
const retryLimit = 4;
export class FileUploader {
private reader = new FileReader();
private client = new FilesClient("");
private file: File;
private filePath: string;
private offset: number;
private client: IFilesClient = new FilesClient("");
private chunkLen: number = defaultChunkLen;
private progressCb: (filePath: string, progress: number) => void;
private file: File;
private offset: number;
private filePath: string;
private errMsg: string | null;
private progressCb: (filePath: string, progress: number) => void;
constructor(
file: File,
filePath: string,
progressCb: (filePath: string, progress: number) => void
progressCb?: (filePath: string, progress: number) => void
) {
this.file = file;
this.filePath = filePath;
@ -30,17 +31,41 @@ export class FileUploader {
err = (): string | null => {
return this.errMsg;
}
};
setClient = (client: IFilesClient) => {
this.client = client;
};
create = async (filePath: string, fileSize: number): Promise<Response> => {
return this.client.create(filePath, fileSize);
};
uploadChunk = async (
filePath: string,
base64Chunk: string,
offset: number
): Promise<Response<UploadStatusResp>> => {
return this.client.uploadChunk(filePath, base64Chunk, offset);
};
uploadStatus = async (
filePath: string
): Promise<Response<UploadStatusResp>> => {
return this.client.uploadStatus(filePath);
};
start = async (): Promise<boolean> => {
const resp = await this.client.create(this.filePath, this.file.size);
switch (resp.status) {
case 200:
let resp: Response;
for (let i = 0; i < retryLimit; i++) {
resp = await this.create(this.filePath, this.file.size);
if (resp.status === 200) {
return await this.upload();
default:
}
}
this.errMsg = `failed to create ${this.filePath}: status=${resp.statusText}`;
return false;
}
};
upload = async (): Promise<boolean> => {
@ -49,7 +74,7 @@ export class FileUploader {
this.offset >= 0 &&
this.offset < this.file.size
) {
let uploadPromise = new Promise<Response<UploadStatusResp>>(
const uploadPromise = new Promise<Response<UploadStatusResp>>(
(resolve: (resp: Response<UploadStatusResp>) => void) => {
this.reader.onerror = (ev: ProgressEvent<FileReader>) => {
resolve(UnknownErrResp(this.reader.error.toString()));
@ -58,8 +83,7 @@ export class FileUploader {
this.reader.onloadend = (ev: ProgressEvent<FileReader>) => {
const dataURL = ev.target.result as string; // readAsDataURL
const base64Chunk = dataURL.slice(dataURL.indexOf(",") + 1);
this.client
.uploadChunk(this.filePath, base64Chunk, this.offset)
this.uploadChunk(this.filePath, base64Chunk, this.offset)
.then((resp: Response<UploadStatusResp>) => {
resolve(resp);
})
@ -78,28 +102,29 @@ export class FileUploader {
this.reader.readAsDataURL(blob);
const uploadResp = await uploadPromise;
if (uploadResp.status === 200 && uploadResp.data != null) {
this.offset = uploadResp.data.uploaded;
this.chunkLen = Math.ceil(this.chunkLen * speedUpRatio);
} else {
this.errMsg = uploadResp.statusText;
this.chunkLen = Math.ceil(this.chunkLen * speedDownRatio);
const uploadStatusResp = await this.client.uploadStatus(this.filePath);
console.log(uploadStatusResp.status);
const uploadStatusResp = await this.uploadStatus(this.filePath);
if (uploadStatusResp.status === 200) {
this.offset = uploadStatusResp.data.uploaded;
} else if (uploadStatusResp.status === 600) {
this.errMsg = "unknown error";
break
break;
} else {
// do nothing and retry
}
}
if (this.progressCb != null) {
this.progressCb(this.filePath, Math.ceil(this.offset / this.file.size));
}
}
if (this.chunkLen === 0) {
this.errMsg = "network is bad, please retry later.";

View file

@ -1,45 +1,44 @@
// import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
// TODO: replace this with jest mocks
import { Response } from "./";
export class MockUsersClient {
private url: string;
private loginResp: number;
private logoutResp: number;
private isAuthedResp: number;
private setPwdResp: number;
private loginMockResp: Promise<Response>;
private logoutMockResp: Promise<Response>;
private isAuthedMockResp: Promise<Response>;
private setPwdMockResp: Promise<Response>;
constructor(url: string) {
this.url = url;
}
mockLoginResp(status: number) {
this.loginResp = status;
loginMock = (resp: Promise<Response>) => {
this.loginMockResp = resp;
}
mocklogoutResp(status: number) {
this.logoutResp = status;
logoutMock = (resp: Promise<Response>) => {
this.logoutMockResp = resp;
}
mockisAuthedResp(status: number) {
this.isAuthedResp = status;
isAuthedMock = (resp: Promise<Response>) => {
this.isAuthedMockResp = resp;
}
mocksetPwdResp(status: number) {
this.setPwdResp = status;
setPwdMock = (resp: Promise<Response>) => {
this.setPwdMockResp = resp;
}
async login(user: string, pwd: string): Promise<number> {
return this.loginResp
login = (user: string, pwd: string): Promise<Response> => {
return this.loginMockResp;
}
// token cookie is set by browser
async logout(): Promise<number> {
return this.logoutResp
logout = (): Promise<Response> => {
return this.logoutMockResp;
}
async isAuthed(): Promise<number> {
return this.isAuthedResp
isAuthed = (): Promise<Response> => {
return this.isAuthedMockResp;
}
// token cookie is set by browser
async setPwd(oldPwd: string, newPwd: string): Promise<number> {
return this.setPwdResp
setPwd = (oldPwd: string, newPwd: string): Promise<Response> => {
return this.setPwdMockResp;
}
}

View file

@ -1,32 +1,56 @@
import * as React from "react";
import { init } from "../core_state";
import { Updater } from "../auth_pane";
import { MockUsersClient } from "../../client/users_mock";
import { Response } from "../../client";
describe("AuthPane", () => {
test("Updater: initIsAuthed", () => {
const makePromise = (ret: any): Promise<any> => {
return new Promise<any>((resolve) => {
resolve(ret);
});
};
const makeNumberResponse = (status: number): Promise<Response> => {
return makePromise({
status: status,
statusText: "",
data: {},
});
};
test("Updater-initIsAuthed", async () => {
const tests = [
{
loginStatus: 200,
logoutStatus: 200,
isAuthedStatus: 200,
setPwdStatus: 200,
isAuthed: true,
},
{
loginStatus: 500,
loginStatus: 200,
logoutStatus: 200,
isAuthedStatus: 500,
setPwdStatus: 200,
isAuthed: false,
},
];
const client = new MockUsersClient("foobarurl");
Updater.setClient(client);
const coreState = init();
const client = new MockUsersClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
tests.forEach(async (tc) => {
client.mockisAuthedResp(tc.loginStatus);
await Updater.initIsAuthed().then(() => {
client.loginMock(makeNumberResponse(tc.loginStatus));
client.logoutMock(makeNumberResponse(tc.logoutStatus));
client.isAuthedMock(makeNumberResponse(tc.isAuthedStatus));
client.setPwdMock(makeNumberResponse(tc.setPwdStatus));
const coreState = init();
Updater.setClient(client);
Updater.init(coreState.panel.authPane);
await Updater.initIsAuthed();
const newState = Updater.setAuthPane(coreState);
expect(newState.panel.authPane.authed).toEqual(tc.isAuthed);
});
});
}
});
});

View file

@ -1,10 +1,188 @@
import * as React from "react";
import { List, Map } from "immutable";
import { init } from "../core_state";
import { makePromise, makeNumberResponse } from "../../test/helpers";
import { Updater } from "../browser";
import { MockUsersClient } from "../../client/users_mock";
import { FilesClient } from "../../client/files_mock";
import { MetadataResp } from "../../client";
describe("Browser", () => {
test("Updater: ", () => {
test("Updater: setPwd", async () => {
const tests = [
{
listResp: {
status: 200,
statusText: "",
data: {
metadatas: [
{
name: "file",
size: 1,
modTime: "1-1",
isDir: false,
},
{
name: "folder",
size: 0,
modTime: "1-1",
isDir: true,
},
],
},
},
filePath: "path/file",
},
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
filesClient.listMock(makePromise(tc.listResp));
Updater.setClients(usersClient, filesClient);
const coreState = init();
Updater.init(coreState.panel.browser);
await Updater.setItems(List<string>(tc.filePath.split("/")));
const newState = Updater.setBrowser(coreState);
newState.panel.browser.items.forEach((item, i) => {
expect(item.name).toEqual(tc.listResp.data.metadatas[i].name);
expect(item.size).toEqual(tc.listResp.data.metadatas[i].size);
expect(item.modTime).toEqual(tc.listResp.data.metadatas[i].modTime);
expect(item.isDir).toEqual(tc.listResp.data.metadatas[i].isDir);
});
}
});
test("Updater: delete", async () => {
const tests = [
{
dirPath: "path/path2",
items: [
{
name: "file",
size: 1,
modTime: "1-1",
isDir: false,
},
{
name: "folder",
size: 0,
modTime: "1-1",
isDir: true,
},
],
selected: {
file: true,
},
listResp: {
status: 200,
statusText: "",
data: {
metadatas: [
{
name: "folder",
size: 0,
modTime: "1-1",
isDir: true,
},
],
},
},
filePath: "path/file",
},
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
filesClient.listMock(makePromise(tc.listResp));
filesClient.deleteMock(makeNumberResponse(200));
Updater.setClients(usersClient, filesClient);
const coreState = init();
Updater.init(coreState.panel.browser);
await Updater.delete(
List<string>(tc.dirPath.split("/")),
List<MetadataResp>(tc.items),
Map<boolean>(tc.selected)
);
const newState = Updater.setBrowser(coreState);
// TODO: check inputs of delete
newState.panel.browser.items.forEach((item, i) => {
expect(item.name).toEqual(tc.listResp.data.metadatas[i].name);
expect(item.size).toEqual(tc.listResp.data.metadatas[i].size);
expect(item.modTime).toEqual(tc.listResp.data.metadatas[i].modTime);
expect(item.isDir).toEqual(tc.listResp.data.metadatas[i].isDir);
});
}
});
test("Updater: moveHere", async () => {
const tests = [
{
dirPath1: "path/path1",
dirPath2: "path/path2",
selected: {
file1: true,
file2: true,
},
listResp: {
status: 200,
statusText: "",
data: {
metadatas: [
{
name: "file1",
size: 1,
modTime: "1-1",
isDir: false,
},
{
name: "file2",
size: 2,
modTime: "1-1",
isDir: false,
},
],
},
},
},
];
const usersClient = new MockUsersClient("");
const filesClient = new FilesClient("");
for (let i = 0; i < tests.length; i++) {
const tc = tests[i];
filesClient.listMock(makePromise(tc.listResp));
filesClient.moveMock(makeNumberResponse(200));
Updater.setClients(usersClient, filesClient);
const coreState = init();
Updater.init(coreState.panel.browser);
await Updater.moveHere(
tc.dirPath1,
tc.dirPath2,
Map<boolean>(tc.selected)
);
// TODO: check inputs of move
const newState = Updater.setBrowser(coreState);
newState.panel.browser.items.forEach((item, i) => {
expect(item.name).toEqual(tc.listResp.data.metadatas[i].name);
expect(item.size).toEqual(tc.listResp.data.metadatas[i].size);
expect(item.modTime).toEqual(tc.listResp.data.metadatas[i].modTime);
expect(item.isDir).toEqual(tc.listResp.data.metadatas[i].isDir);
});
}
});
});

View file

@ -95,6 +95,7 @@ export class AuthPane extends React.Component<Props, State, {}> {
this.update(Updater.setAuthPane);
this.setState({ user: "", pwd: "" });
} else {
this.setState({ user: "", pwd: "" });
alert("Failed to login.");
}
});
@ -104,9 +105,8 @@ export class AuthPane extends React.Component<Props, State, {}> {
Updater.logout().then((ok: boolean) => {
if (ok) {
this.update(Updater.setAuthPane);
this.setState({ user: "", pwd: "" });
} else {
alert("Failed to login.");
alert("Failed to logout.");
}
});
};

View file

@ -18,6 +18,7 @@ export interface Item {
isDir: boolean;
selected: boolean;
}
export interface Props {
dirPath: List<string>;
items: List<MetadataResp>;
@ -51,7 +52,7 @@ export class Updater {
Updater.props.dirPath = dirParts;
Updater.props.items =
listResp != null
listResp.status === 200
? List<MetadataResp>(listResp.data.metadatas)
: Updater.props.items;
};
@ -98,7 +99,6 @@ export class Updater {
async (itemName: string): Promise<string> => {
const oldPath = getItemPath(srcDir, itemName);
const newPath = getItemPath(dstDir, itemName);
const resp = await Updater.filesClient.move(oldPath, newPath);
return resp.status === 200 ? "" : itemName;
}

View file

@ -0,0 +1,15 @@
import { Response } from "../client";
export const makePromise = (ret: any): Promise<any> => {
return new Promise<any>((resolve) => {
resolve(ret);
});
};
export const makeNumberResponse = (status: number): Promise<Response> => {
return makePromise({
status: status,
statusText: "",
data: {},
});
};