signal-desktop/ts/test-electron/state/ducks/username_test.ts

464 lines
13 KiB
TypeScript
Raw Normal View History

2022-10-18 17:12:02 +00:00
// 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
);
});
});
});