From f5d10683a1edeba4dabe148ff7aa682c044f7496 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 26 Jul 2020 15:02:55 +0800 Subject: Merge front end repo --- Timeline/ClientApp/src/app/http/mock/common.ts | 48 ++ .../ClientApp/src/app/http/mock/default-avatar.png | Bin 0 -> 26442 bytes Timeline/ClientApp/src/app/http/mock/install.ts | 11 + Timeline/ClientApp/src/app/http/mock/timeline.ts | 600 +++++++++++++++++++++ Timeline/ClientApp/src/app/http/mock/token.ts | 53 ++ Timeline/ClientApp/src/app/http/mock/user.ts | 132 +++++ 6 files changed, 844 insertions(+) create mode 100644 Timeline/ClientApp/src/app/http/mock/common.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/default-avatar.png create mode 100644 Timeline/ClientApp/src/app/http/mock/install.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/timeline.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/token.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/user.ts (limited to 'Timeline/ClientApp/src/app/http/mock') diff --git a/Timeline/ClientApp/src/app/http/mock/common.ts b/Timeline/ClientApp/src/app/http/mock/common.ts new file mode 100644 index 00000000..11939c2b --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/common.ts @@ -0,0 +1,48 @@ +import localforage from 'localforage'; +import { SHA1 } from 'crypto-js'; + +import { HttpNetworkError } from '../common'; + +export const mockStorage = localforage.createInstance({ + name: 'mock-backend', + description: 'Database for mock back end.', + driver: localforage.INDEXEDDB, +}); + +export async function sha1(data: Blob): Promise { + const s = await new Promise((resolve) => { + const fileReader = new FileReader(); + fileReader.readAsBinaryString(data); + fileReader.onload = () => { + resolve(fileReader.result as string); + }; + }); + + return SHA1(s).toString(); +} + +const disableNetworkKey = 'mockServer.disableNetwork'; + +let disableNetwork: boolean = + localStorage.getItem(disableNetworkKey) === 'true' ? true : false; + +Object.defineProperty(window, 'disableNetwork', { + get: () => disableNetwork, + set: (value) => { + if (value) { + disableNetwork = true; + localStorage.setItem(disableNetworkKey, 'true'); + } else { + disableNetwork = false; + localStorage.setItem(disableNetworkKey, 'false'); + } + }, +}); + +export async function mockPrepare(): Promise { + if (disableNetwork) { + console.warn('Network is disabled for mock server.'); + throw new HttpNetworkError(); + } + await Promise.resolve(); +} diff --git a/Timeline/ClientApp/src/app/http/mock/default-avatar.png b/Timeline/ClientApp/src/app/http/mock/default-avatar.png new file mode 100644 index 00000000..4086e1d2 Binary files /dev/null and b/Timeline/ClientApp/src/app/http/mock/default-avatar.png differ diff --git a/Timeline/ClientApp/src/app/http/mock/install.ts b/Timeline/ClientApp/src/app/http/mock/install.ts new file mode 100644 index 00000000..66174d41 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/install.ts @@ -0,0 +1,11 @@ +import { setHttpTokenClient } from '../token'; +import { setHttpUserClient } from '../user'; +import { setHttpTimelineClient } from '../timeline'; + +import { MockHttpTokenClient } from './token'; +import { MockHttpUserClient } from './user'; +import { MockHttpTimelineClient } from './timeline'; + +setHttpTokenClient(new MockHttpTokenClient()); +setHttpUserClient(new MockHttpUserClient()); +setHttpTimelineClient(new MockHttpTimelineClient()); diff --git a/Timeline/ClientApp/src/app/http/mock/timeline.ts b/Timeline/ClientApp/src/app/http/mock/timeline.ts new file mode 100644 index 00000000..2a34ef10 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/timeline.ts @@ -0,0 +1,600 @@ +import { random, without, range } from 'lodash'; + +import { BlobWithEtag, NotModified } from '../common'; +import { + IHttpTimelineClient, + HttpTimelineInfo, + TimelineVisibility, + HttpTimelineListQuery, + HttpTimelineNotExistError, + HttpTimelinePostRequest, + HttpTimelineNameConflictError, + HttpTimelinePatchRequest, + HttpTimelinePostInfo, + HttpTimelinePostContent, + HttpTimelinePostPostRequest, + HttpTimelinePostNotExistError, + HttpTimelineGenericPostInfo, +} from '../timeline'; +import { HttpUser } from '../user'; + +import { mockStorage, sha1, mockPrepare } from './common'; +import { getUser, MockUserNotExistError, checkToken } from './user'; + +async function getTimelineNameList(): Promise { + return (await mockStorage.getItem('timelines')) ?? []; +} + +async function setTimelineNameList(newOne: string[]): Promise { + await mockStorage.setItem('timelines', newOne); +} + +type TimelinePropertyKey = + | 'uniqueId' + | 'owner' + | 'description' + | 'visibility' + | 'members' + | 'currentPostId'; + +function getTimelinePropertyKey( + name: string, + property: TimelinePropertyKey +): string { + return `timeline.${name}.${property}`; +} + +function getTimelinePropertyValue( + name: string, + property: TimelinePropertyKey +): Promise { + return mockStorage.getItem(getTimelinePropertyKey(name, property)); +} + +function setTimelinePropertyValue( + name: string, + property: TimelinePropertyKey, + value: T +): Promise { + return mockStorage + .setItem(getTimelinePropertyKey(name, property), value) + .then(); +} + +interface HttpTimelineInfoEx extends HttpTimelineInfo { + memberUsernames: string[]; +} + +function createUniqueId(): string { + const s = 'abcdefghijklmnopqrstuvwxz0123456789'; + let result = ''; + for (let i = 0; i < 16; i++) { + result += s[random(0, s.length - 1)]; + } + return result; +} + +class MockTimelineNotExistError extends Error { + constructor() { + super('Timeline not exist.'); + } +} + +class MockTimelineAlreadyExistError extends Error { + constructor() { + super('Timeline already exist.'); + } +} + +async function getTimelineInfo(name: string): Promise { + let owner: HttpUser; + if (name.startsWith('@')) { + const ownerUsername = name.substr(1); + owner = await getUser(ownerUsername); + const optionalUniqueId = await getTimelinePropertyValue( + name, + 'uniqueId' + ); + if (optionalUniqueId == null) { + await setTimelineNameList([...(await getTimelineNameList()), name]); + await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + } + } else { + const optionalOwnerUsername = await getTimelinePropertyValue( + name, + 'owner' + ); + if (optionalOwnerUsername == null) { + throw new MockTimelineNotExistError(); + } else { + owner = await getUser(optionalOwnerUsername); + } + } + + const memberUsernames = + (await getTimelinePropertyValue(name, 'members')) ?? []; + const members = await Promise.all( + memberUsernames.map(async (username) => { + return await getUser(username); + }) + ); + + return { + name, + uniqueId: await getTimelinePropertyValue(name, 'uniqueId'), + owner, + description: + (await getTimelinePropertyValue(name, 'description')) ?? + '', + visibility: + (await getTimelinePropertyValue( + name, + 'visibility' + )) ?? 'Register', + members, + memberUsernames, + }; +} + +async function createTimeline(name: string, owner: string): Promise { + const optionalOwnerUsername = await getTimelinePropertyValue( + name, + 'owner' + ); + if (optionalOwnerUsername != null) { + throw new MockTimelineAlreadyExistError(); + } + + await setTimelineNameList([...(await getTimelineNameList()), name]); + await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + await setTimelinePropertyValue(name, 'owner', owner); +} + +type TimelinePostPropertyKey = + | 'type' + | 'data' + | 'etag' + | 'author' + | 'time' + | 'lastUpdated'; + +function getTimelinePostPropertyKey( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): string { + return `timeline.${timelineName}.posts.${id}.${propertyKey}`; +} + +function getTimelinePostPropertyValue( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.getItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +function setTimelinePostPropertyValue( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey, + value: T +): Promise { + return mockStorage.setItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey), + value + ); +} + +function removeTimelinePostProperty( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.removeItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +async function getTimelinePostInfo( + timelineName: string, + id: number +): Promise { + const currentPostId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + if (currentPostId == null || id > currentPostId) { + throw new HttpTimelinePostNotExistError(); + } + + const type = await getTimelinePostPropertyValue( + timelineName, + id, + 'type' + ); + + if (type == null) { + return { + id, + author: await getUser( + await getTimelinePostPropertyValue(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated' + ) + ), + deleted: true, + }; + } else { + let content: HttpTimelinePostContent; + if (type === 'text') { + content = { + type: 'text', + text: await getTimelinePostPropertyValue(timelineName, id, 'data'), + }; + } else { + content = { + type: 'image', + }; + } + + return { + id, + author: await getUser( + await getTimelinePostPropertyValue(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated' + ) + ), + content, + deleted: false, + }; + } +} + +export class MockHttpTimelineClient implements IHttpTimelineClient { + async listTimeline( + query: HttpTimelineListQuery + ): Promise { + await mockPrepare(); + return ( + await Promise.all( + (await getTimelineNameList()).map((name) => getTimelineInfo(name)) + ) + ).filter((timeline) => { + if ( + query.visibility != null && + query.visibility !== timeline.visibility + ) { + return false; + } + if (query.relate != null) { + if (query.relateType === 'own') { + if (timeline.owner.username !== query.relate) { + return false; + } + } else if (query.relateType === 'join') { + if (!timeline.memberUsernames.includes(query.relate)) { + return false; + } + } else if ( + timeline.owner.username !== query.relate && + !timeline.memberUsernames.includes(query.relate) + ) { + return false; + } + } + return true; + }); + } + + async getTimeline(timelineName: string): Promise { + await mockPrepare(); + try { + return await getTimelineInfo(timelineName); + } catch (e) { + if ( + e instanceof MockTimelineNotExistError || + e instanceof MockUserNotExistError + ) { + throw new HttpTimelineNotExistError(); + } + throw e; + } + } + + async postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise { + await mockPrepare(); + const user = checkToken(token); + try { + await createTimeline(req.name, user); + } catch (e) { + if (e instanceof MockTimelineAlreadyExistError) { + throw new HttpTimelineNameConflictError(); + } + throw e; + } + return await getTimelineInfo(req.name); + } + + async patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + _token: string + ): Promise { + await mockPrepare(); + if (req.description != null) { + await setTimelinePropertyValue( + timelineName, + 'description', + req.description + ); + } + if (req.visibility != null) { + await setTimelinePropertyValue( + timelineName, + 'visibility', + req.visibility + ); + } + return await getTimelineInfo(timelineName); + } + + async deleteTimeline(timelineName: string, _token: string): Promise { + await mockPrepare(); + await setTimelineNameList( + without(await getTimelineNameList(), timelineName) + ); + await mockStorage.removeItem( + getTimelinePropertyKey(timelineName, 'uniqueId') + ); + + // TODO: remove other things + } + + async memberPut( + timelineName: string, + username: string, + _token: string + ): Promise { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (!oldMembers.includes(username)) { + await setTimelinePropertyValue(timelineName, 'members', [ + ...oldMembers, + username, + ]); + } + } + + async memberDelete( + timelineName: string, + username: string, + _token: string + ): Promise { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (oldMembers.includes(username)) { + await setTimelinePropertyValue( + timelineName, + 'members', + without(oldMembers, username) + ); + } + } + + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + async listPost( + timelineName: string, + _token?: string, + query?: { + modifiedSince?: Date; + includeDeleted?: boolean; + } + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const currentPostId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + + return ( + await Promise.all( + range(1, currentPostId == null ? 1 : currentPostId + 1).map( + async (id) => { + return await getTimelinePostInfo(timelineName, id); + } + ) + ) + ) + .filter((post) => { + if (query?.includeDeleted !== true && post.deleted) { + return false; + } + return true; + }) + .filter((post) => { + if (query?.modifiedSince != null) { + return post.lastUpdated >= query.modifiedSince; + } + return true; + }); + } + + getPostData( + timelineName: string, + postId: number, + token: string + ): Promise; + async getPostData( + timelineName: string, + postId: number, + _token?: string, + etag?: string + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const optionalSavedEtag = await getTimelinePostPropertyValue( + timelineName, + postId, + 'etag' + ); + + if (optionalSavedEtag == null) { + const optionalType = await getTimelinePostPropertyValue( + timelineName, + postId, + 'type' + ); + + if (optionalType != null) { + throw new Error('Post of this type has no data.'); + } else { + throw new HttpTimelinePostNotExistError(); + } + } + + if (etag === optionalSavedEtag) { + return new NotModified(); + } + + return { + data: await getTimelinePostPropertyValue( + timelineName, + postId, + 'data' + ), + etag: optionalSavedEtag, + }; + } + + async postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise { + await mockPrepare(); + const user = checkToken(token); + + const savedId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + const id = savedId ? savedId + 1 : 1; + await setTimelinePropertyValue(timelineName, 'currentPostId', id); + + await setTimelinePostPropertyValue(timelineName, id, 'author', user); + + const currentTimeString = new Date().toISOString(); + await setTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated', + currentTimeString + ); + + await setTimelinePostPropertyValue( + timelineName, + id, + 'time', + req.time != null ? req.time.toISOString() : currentTimeString + ); + + const { content } = req; + if (content.type === 'text') { + await setTimelinePostPropertyValue(timelineName, id, 'type', 'text'); + await setTimelinePostPropertyValue( + timelineName, + id, + 'data', + content.text + ); + } else { + await setTimelinePostPropertyValue(timelineName, id, 'type', 'image'); + await setTimelinePostPropertyValue( + timelineName, + id, + 'data', + content.data + ); + await setTimelinePostPropertyValue( + timelineName, + id, + 'etag', + await sha1(content.data) + ); + } + + return (await getTimelinePostInfo( + timelineName, + id + )) as HttpTimelinePostInfo; + } + + async deletePost( + timelineName: string, + postId: number, + _token: string + ): Promise { + await mockPrepare(); + // TODO: permission check + await removeTimelinePostProperty(timelineName, postId, 'type'); + await removeTimelinePostProperty(timelineName, postId, 'data'); + await removeTimelinePostProperty(timelineName, postId, 'etag'); + await setTimelinePostPropertyValue( + timelineName, + postId, + 'lastUpdated', + new Date().toISOString() + ); + } +} diff --git a/Timeline/ClientApp/src/app/http/mock/token.ts b/Timeline/ClientApp/src/app/http/mock/token.ts new file mode 100644 index 00000000..6924e7d7 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/token.ts @@ -0,0 +1,53 @@ +import { AxiosError } from 'axios'; + +import { + IHttpTokenClient, + HttpCreateTokenRequest, + HttpCreateTokenResponse, + HttpVerifyTokenRequest, + HttpVerifyTokenResponse, +} from '../token'; + +import { mockPrepare } from './common'; +import { getUser, MockUserNotExistError, checkToken } from './user'; + +export class MockHttpTokenClient implements IHttpTokenClient { + // TODO: Mock bad credentials error. + async create(req: HttpCreateTokenRequest): Promise { + await mockPrepare(); + try { + const user = await getUser(req.username); + return { + user, + token: `token-${req.username}`, + }; + } catch (e) { + if (e instanceof MockUserNotExistError) { + throw { + isAxiosError: true, + response: { + status: 400, + }, + } as Partial; + } + throw e; + } + } + + async verify(req: HttpVerifyTokenRequest): Promise { + await mockPrepare(); + try { + const user = await getUser(checkToken(req.token)); + return { + user, + }; + } catch (e) { + throw { + isAxiosError: true, + response: { + status: 400, + }, + } as Partial; + } + } +} diff --git a/Timeline/ClientApp/src/app/http/mock/user.ts b/Timeline/ClientApp/src/app/http/mock/user.ts new file mode 100644 index 00000000..d16302d4 --- /dev/null +++ b/Timeline/ClientApp/src/app/http/mock/user.ts @@ -0,0 +1,132 @@ +import axios from 'axios'; + +import { BlobWithEtag, NotModified } from '../common'; +import { + IHttpUserClient, + HttpUser, + HttpUserNotExistError, + HttpUserPatchRequest, + HttpChangePasswordRequest, +} from '../user'; + +import { mockStorage, sha1, mockPrepare } from './common'; + +import defaultAvatarUrl from './default-avatar.png'; + +let _defaultAvatar: BlobWithEtag | undefined = undefined; + +async function getDefaultAvatar(): Promise { + if (_defaultAvatar == null) { + const blob = ( + await axios.get(defaultAvatarUrl, { + responseType: 'blob', + }) + ).data; + const etag = await sha1(blob); + _defaultAvatar = { + data: blob, + etag, + }; + } + return _defaultAvatar; +} + +export class MockTokenError extends Error { + constructor() { + super('Token bad format.'); + } +} + +export class MockUserNotExistError extends Error { + constructor() { + super('Only two user "user" and "admin".'); + } +} + +export function checkUsername(username: string): void { + if (!['user', 'admin'].includes(username)) throw new MockUserNotExistError(); +} + +export function checkToken(token: string): string { + if (!token.startsWith('token-')) { + throw new MockTokenError(); + } + return token.substr(6); +} + +export async function getUser( + username: 'user' | 'admin' | string +): Promise { + checkUsername(username); + const savedNickname = await mockStorage.getItem( + `user.${username}.nickname` + ); + return { + username: username, + nickname: + savedNickname == null || savedNickname === '' ? username : savedNickname, + administrator: username === 'admin', + }; +} + +export class MockHttpUserClient implements IHttpUserClient { + async get(username: string): Promise { + await mockPrepare(); + return await getUser(username).catch((e) => { + if (e instanceof MockUserNotExistError) { + throw new HttpUserNotExistError(); + } else { + throw e; + } + }); + } + + async patch( + username: string, + req: HttpUserPatchRequest, + _token: string + ): Promise { + await mockPrepare(); + if (req.nickname != null) { + await mockStorage.setItem(`user.${username}.nickname`, req.nickname); + } + return await getUser(username); + } + + getAvatar(username: string): Promise; + async getAvatar( + username: string, + etag?: string + ): Promise { + await mockPrepare(); + + const savedEtag = await mockStorage.getItem(`user.${username}.avatar.etag`); + if (savedEtag == null) { + return await getDefaultAvatar(); + } + + if (savedEtag === etag) { + return new NotModified(); + } + + return { + data: await mockStorage.getItem(`user.${username}.avatar.data`), + etag: await mockStorage.getItem(`user.${username}.avatar.etag`), + }; + } + + async putAvatar(username: string, data: Blob, _token: string): Promise { + await mockPrepare(); + const etag = await sha1(data); + await mockStorage.setItem(`user.${username}.avatar.data`, data); + await mockStorage.setItem(`user.${username}.avatar.etag`, etag); + } + + async changePassword( + _req: HttpChangePasswordRequest, + _token: string + ): Promise { + await mockPrepare(); + throw new Error('Not Implemented.'); + } +} -- cgit v1.2.3