464 lines
13 KiB
TypeScript
464 lines
13 KiB
TypeScript
|
// Copyright 2022 Signal Messenger, LLC
|
||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||
|
|
||
|
import * as sinon from 'sinon';
|
||
|
import { assert } from 'chai';
|
||
|
|
||
|
import type { UsernameStateType } from '../../../state/ducks/username';
|
||
|
import {
|
||
|
getUsernameEditState,
|
||
|
getUsernameReservationState,
|
||
|
getUsernameReservationError,
|
||
|
getUsernameReservationObject,
|
||
|
} from '../../../state/selectors/username';
|
||
|
import {
|
||
|
UsernameEditState,
|
||
|
UsernameReservationState,
|
||
|
UsernameReservationError,
|
||
|
} from '../../../state/ducks/usernameEnums';
|
||
|
import { actions } from '../../../state/ducks/username';
|
||
|
import { ToastType } from '../../../state/ducks/toast';
|
||
|
import { noopAction } from '../../../state/ducks/noop';
|
||
|
import { reducer } from '../../../state/reducer';
|
||
|
import { ReserveUsernameError } from '../../../types/Username';
|
||
|
|
||
|
const DEFAULT_RESERVATION = {
|
||
|
username: 'abc.12',
|
||
|
previousUsername: undefined,
|
||
|
reservationToken: 'def',
|
||
|
};
|
||
|
|
||
|
describe('electron/state/ducks/username', () => {
|
||
|
const emptyState = reducer(undefined, noopAction());
|
||
|
const stateWithReservation = {
|
||
|
...emptyState,
|
||
|
username: {
|
||
|
...emptyState.username,
|
||
|
usernameReservation: {
|
||
|
...emptyState.username.usernameReservation,
|
||
|
state: UsernameReservationState.Open,
|
||
|
reservation: DEFAULT_RESERVATION,
|
||
|
},
|
||
|
} as UsernameStateType,
|
||
|
};
|
||
|
|
||
|
let sandbox: sinon.SinonSandbox;
|
||
|
beforeEach(() => {
|
||
|
sandbox = sinon.createSandbox();
|
||
|
});
|
||
|
|
||
|
afterEach(() => {
|
||
|
sandbox.restore();
|
||
|
});
|
||
|
|
||
|
describe('setUsernameEditState', () => {
|
||
|
it('should update username edit state', () => {
|
||
|
const updatedState = reducer(
|
||
|
emptyState,
|
||
|
actions.setUsernameEditState(UsernameEditState.ConfirmingDelete)
|
||
|
);
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameEditState(updatedState),
|
||
|
UsernameEditState.ConfirmingDelete
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('openUsernameReservationModal/closeUsernameReservationModal', () => {
|
||
|
it('should update reservation state', () => {
|
||
|
const updatedState = reducer(
|
||
|
emptyState,
|
||
|
actions.openUsernameReservationModal()
|
||
|
);
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(updatedState),
|
||
|
UsernameReservationState.Open
|
||
|
);
|
||
|
|
||
|
const finalState = reducer(
|
||
|
emptyState,
|
||
|
actions.closeUsernameReservationModal()
|
||
|
);
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(finalState),
|
||
|
UsernameReservationState.Closed
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('setUsernameReservationError', () => {
|
||
|
it('should update error and reset reservation', () => {
|
||
|
const updatedState = reducer(
|
||
|
stateWithReservation,
|
||
|
actions.setUsernameReservationError(UsernameReservationError.General)
|
||
|
);
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationError(updatedState),
|
||
|
UsernameReservationError.General
|
||
|
);
|
||
|
assert.strictEqual(getUsernameReservationObject(updatedState), undefined);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('reserveUsername', () => {
|
||
|
it('should dispatch correct actions after delay', async () => {
|
||
|
const clock = sandbox.useFakeTimers({
|
||
|
now: 0,
|
||
|
});
|
||
|
|
||
|
const doReserveUsername = sinon.stub().resolves(DEFAULT_RESERVATION);
|
||
|
const dispatch = sinon.spy();
|
||
|
|
||
|
actions.reserveUsername('test', {
|
||
|
doReserveUsername,
|
||
|
delay: 1000,
|
||
|
})(dispatch, () => emptyState, null);
|
||
|
|
||
|
await clock.runToLastAsync();
|
||
|
assert.strictEqual(clock.now, 1000);
|
||
|
|
||
|
sinon.assert.calledOnce(dispatch);
|
||
|
sinon.assert.calledWith(
|
||
|
dispatch,
|
||
|
sinon.match.has('type', 'username/RESERVE_USERNAME')
|
||
|
);
|
||
|
});
|
||
|
|
||
|
const NICKNAME_ERROR_COMBOS = [
|
||
|
['x', UsernameReservationError.NotEnoughCharacters],
|
||
|
['x'.repeat(128), UsernameReservationError.TooManyCharacters],
|
||
|
['#$&^$)(', UsernameReservationError.CheckCharacters],
|
||
|
['1abcdefg', UsernameReservationError.CheckStartingCharacter],
|
||
|
];
|
||
|
for (const [nickname, error] of NICKNAME_ERROR_COMBOS) {
|
||
|
// eslint-disable-next-line no-loop-func
|
||
|
it(`should dispatch ${error} error for "${nickname}"`, async () => {
|
||
|
const clock = sandbox.useFakeTimers();
|
||
|
|
||
|
const doReserveUsername = sinon.stub().resolves(DEFAULT_RESERVATION);
|
||
|
const dispatch = sinon.spy();
|
||
|
|
||
|
actions.reserveUsername(nickname, {
|
||
|
doReserveUsername,
|
||
|
})(dispatch, () => emptyState, null);
|
||
|
|
||
|
await clock.runToLastAsync();
|
||
|
|
||
|
sinon.assert.calledOnce(dispatch);
|
||
|
sinon.assert.calledWith(dispatch, {
|
||
|
type: 'username/SET_RESERVATION_ERROR',
|
||
|
payload: {
|
||
|
error,
|
||
|
},
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
it('should update reservation on success', () => {
|
||
|
let state = emptyState;
|
||
|
|
||
|
const abortController = new AbortController();
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Reserving
|
||
|
);
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||
|
payload: {
|
||
|
ok: true,
|
||
|
reservation: DEFAULT_RESERVATION,
|
||
|
},
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Open
|
||
|
);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationObject(state),
|
||
|
DEFAULT_RESERVATION
|
||
|
);
|
||
|
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||
|
});
|
||
|
|
||
|
const REMOTE_ERRORS: Array<
|
||
|
[ReserveUsernameError, UsernameReservationError]
|
||
|
> = [
|
||
|
[
|
||
|
ReserveUsernameError.Unprocessable,
|
||
|
UsernameReservationError.CheckCharacters,
|
||
|
],
|
||
|
[
|
||
|
ReserveUsernameError.Conflict,
|
||
|
UsernameReservationError.UsernameNotAvailable,
|
||
|
],
|
||
|
];
|
||
|
for (const [error, mapping] of REMOTE_ERRORS) {
|
||
|
it(`should update error on ${error}`, () => {
|
||
|
let state = emptyState;
|
||
|
|
||
|
const abortController = new AbortController();
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||
|
payload: {
|
||
|
ok: false,
|
||
|
error,
|
||
|
},
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
|
||
|
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||
|
assert.strictEqual(getUsernameReservationError(state), mapping);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Open
|
||
|
);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
it('should update error on rejection', () => {
|
||
|
let state = emptyState;
|
||
|
|
||
|
const abortController = new AbortController();
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_REJECTED',
|
||
|
error: true,
|
||
|
payload: new Error(),
|
||
|
meta: { abortController },
|
||
|
});
|
||
|
|
||
|
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationError(state),
|
||
|
UsernameReservationError.General
|
||
|
);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Open
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should abort previous AbortController', () => {
|
||
|
const firstController = new AbortController();
|
||
|
const firstAbort = sinon.stub(firstController, 'abort');
|
||
|
|
||
|
const updatedState = reducer(emptyState, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController: firstController },
|
||
|
});
|
||
|
|
||
|
reducer(updatedState, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController: new AbortController() },
|
||
|
});
|
||
|
|
||
|
sinon.assert.calledOnce(firstAbort);
|
||
|
});
|
||
|
|
||
|
it('should ignore resolve/reject with different AbortController', () => {
|
||
|
const firstController = new AbortController();
|
||
|
const secondController = new AbortController();
|
||
|
|
||
|
let state = emptyState;
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_PENDING',
|
||
|
meta: { abortController: firstController },
|
||
|
});
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_FULFILLED',
|
||
|
payload: {
|
||
|
ok: true,
|
||
|
reservation: DEFAULT_RESERVATION,
|
||
|
},
|
||
|
meta: { abortController: secondController },
|
||
|
});
|
||
|
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/RESERVE_USERNAME_REJECTED',
|
||
|
error: true,
|
||
|
payload: new Error(),
|
||
|
meta: { abortController: secondController },
|
||
|
});
|
||
|
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Reserving
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('confirmUsername', () => {
|
||
|
it('should dispatch promise when reservation is present', () => {
|
||
|
const doConfirmUsername = sinon.stub().resolves();
|
||
|
const dispatch = sinon.spy();
|
||
|
|
||
|
actions.confirmUsername({
|
||
|
doConfirmUsername,
|
||
|
})(dispatch, () => stateWithReservation, null);
|
||
|
|
||
|
sinon.assert.calledOnce(dispatch);
|
||
|
sinon.assert.calledWith(
|
||
|
dispatch,
|
||
|
sinon.match.has('type', 'username/CONFIRM_USERNAME')
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should close modal on resolution', () => {
|
||
|
let state = stateWithReservation;
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/CONFIRM_USERNAME_PENDING',
|
||
|
meta: undefined,
|
||
|
});
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Confirming
|
||
|
);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationObject(state),
|
||
|
DEFAULT_RESERVATION
|
||
|
);
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/CONFIRM_USERNAME_FULFILLED',
|
||
|
payload: undefined,
|
||
|
meta: undefined,
|
||
|
});
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Closed
|
||
|
);
|
||
|
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||
|
assert.strictEqual(getUsernameReservationError(state), undefined);
|
||
|
});
|
||
|
|
||
|
it('should not close modal on error', () => {
|
||
|
let state = stateWithReservation;
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/CONFIRM_USERNAME_PENDING',
|
||
|
meta: undefined,
|
||
|
});
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Confirming
|
||
|
);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationObject(state),
|
||
|
DEFAULT_RESERVATION
|
||
|
);
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/CONFIRM_USERNAME_REJECTED',
|
||
|
error: true,
|
||
|
payload: new Error(),
|
||
|
meta: undefined,
|
||
|
});
|
||
|
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationState(state),
|
||
|
UsernameReservationState.Open
|
||
|
);
|
||
|
assert.strictEqual(getUsernameReservationObject(state), undefined);
|
||
|
assert.strictEqual(
|
||
|
getUsernameReservationError(state),
|
||
|
UsernameReservationError.General
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('deleteUsername', () => {
|
||
|
it('should dispatch once on success', () => {
|
||
|
const doDeleteUsername = sinon.stub().resolves();
|
||
|
const dispatch = sinon.spy();
|
||
|
|
||
|
actions.deleteUsername({
|
||
|
doDeleteUsername,
|
||
|
username: 'test',
|
||
|
})(dispatch, () => emptyState, null);
|
||
|
|
||
|
sinon.assert.calledOnce(dispatch);
|
||
|
sinon.assert.calledWith(
|
||
|
dispatch,
|
||
|
sinon.match.has('type', 'username/DELETE_USERNAME')
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should dispatch twice on failure', async () => {
|
||
|
const clock = sandbox.useFakeTimers({
|
||
|
now: 0,
|
||
|
});
|
||
|
|
||
|
const doDeleteUsername = sinon.stub().rejects(new Error());
|
||
|
const dispatch = sinon.spy();
|
||
|
|
||
|
actions.deleteUsername({
|
||
|
doDeleteUsername,
|
||
|
username: 'test',
|
||
|
})(dispatch, () => emptyState, null);
|
||
|
|
||
|
await clock.runToLastAsync();
|
||
|
|
||
|
sinon.assert.calledTwice(dispatch);
|
||
|
sinon.assert.calledWith(
|
||
|
dispatch,
|
||
|
sinon.match.has('type', 'username/DELETE_USERNAME')
|
||
|
);
|
||
|
sinon.assert.calledWith(dispatch, {
|
||
|
type: 'toast/SHOW_TOAST',
|
||
|
payload: {
|
||
|
toastType: ToastType.FailedToDeleteUsername,
|
||
|
parameters: undefined,
|
||
|
},
|
||
|
});
|
||
|
});
|
||
|
|
||
|
it('should update editState', () => {
|
||
|
let state = stateWithReservation;
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/DELETE_USERNAME_PENDING',
|
||
|
meta: undefined,
|
||
|
});
|
||
|
assert.strictEqual(
|
||
|
getUsernameEditState(state),
|
||
|
UsernameEditState.Deleting
|
||
|
);
|
||
|
|
||
|
state = reducer(state, {
|
||
|
type: 'username/DELETE_USERNAME_FULFILLED',
|
||
|
payload: undefined,
|
||
|
meta: undefined,
|
||
|
});
|
||
|
assert.strictEqual(
|
||
|
getUsernameEditState(state),
|
||
|
UsernameEditState.Editing
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
});
|