UNPKG

boardgame.io

Version:
1,069 lines (952 loc) 32.6 kB
/* * Copyright 2018 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ import * as ActionCreators from '../core/action-creators'; import { InitializeGame } from '../core/initialize'; import { InMemory } from '../server/db/inmemory'; import { Master } from './master'; import { error } from '../core/logger'; import type { Game, Server, State, LogEntry } from '../types'; import { Auth } from '../server/auth'; import * as StorageAPI from '../server/db/base'; import * as dateMock from 'jest-date-mock'; import { PlayerView } from '../core/player-view'; import { INVALID_MOVE } from '../core/constants'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); beforeEach(() => { dateMock.clear(); }); class InMemoryAsync extends StorageAPI.Async { db: InMemory; constructor() { super(); this.db = new InMemory(); } async connect() { await this.sleep(); } private sleep(): Promise<void> { const interval = Math.round(Math.random() * 50 + 50); return new Promise((resolve) => void setTimeout(resolve, interval)); } async createMatch(id: string, opts: StorageAPI.CreateMatchOpts) { await this.sleep(); this.db.createMatch(id, opts); } async setMetadata(matchID: string, metadata: Server.MatchData) { await this.sleep(); this.db.setMetadata(matchID, metadata); } async setState(matchID: string, state: State, deltalog?: LogEntry[]) { await this.sleep(); this.db.setState(matchID, state, deltalog); } async fetch<O extends StorageAPI.FetchOpts>( matchID: string, opts: O ): Promise<StorageAPI.FetchResult<O>> { await this.sleep(); return this.db.fetch(matchID, opts); } async wipe(matchID: string) { await this.sleep(); this.db.wipe(matchID); } async listMatches(opts?: StorageAPI.ListMatchesOpts): Promise<string[]> { await this.sleep(); return this.db.listMatches(opts); } } const game: Game = { seed: 0 }; function TransportAPI(send = jest.fn(), sendAll = jest.fn()) { return { send, sendAll }; } function validateNotTransientState(state: any) { expect(state).toEqual( expect.not.objectContaining({ transients: expect.anything() }) ); } describe('sync', () => { const send = jest.fn(); const db = new InMemory(); const master = new Master(game, db, TransportAPI(send)); beforeEach(() => { jest.clearAllMocks(); }); test('causes server to respond', async () => { await master.onSync('matchID', '0', undefined, 2); expect(send).toHaveBeenCalledWith( expect.objectContaining({ type: 'sync', }) ); }); test('sync a second time does not create a game', async () => { const fetchResult = db.fetch('matchID', { metadata: true }); await master.onSync('matchID', '0', undefined, 2); expect(db.fetch('matchID', { metadata: true })).toMatchObject(fetchResult); }); test('should not have metadata', async () => { db.setState('oldGameID', {} as State); await master.onSync('oldGameID', '0'); // [0][0] = first call, first argument expect(send.mock.calls[0][0].args[1].filteredMetadata).toBeUndefined(); }); test('should have metadata', async () => { const db = new InMemory(); const metadata = { gameName: 'tic-tac-toe', setupData: {}, players: { '0': { id: 0, credentials: 'qS2m4Ujb_', name: 'Alice', }, '1': { id: 1, credentials: 'nIQtXFybDD', name: 'Bob', }, }, createdAt: 0, updatedAt: 0, }; db.createMatch('matchID', { metadata, initialState: {} as State }); const masterWithMetadata = new Master(game, db, TransportAPI(send)); await masterWithMetadata.onSync('matchID', '0', undefined, 2); const expectedMetadata = [ { id: 0, name: 'Alice' }, { id: 1, name: 'Bob' }, ]; expect(send.mock.calls[0][0].args[1].filteredMetadata).toMatchObject( expectedMetadata ); }); test('should not create match for games that require setupData', async () => { const game: Game = { validateSetupData: () => 'requires setupData', }; const db = new InMemory(); const master = new Master(game, db, TransportAPI(send)); const matchID = 'matchID'; const res = await master.onSync(matchID, '0', undefined, 2); expect(res).toEqual({ error: 'game requires setupData' }); expect(send).not.toHaveBeenCalled(); expect(db.fetch(matchID, { state: true })).toEqual({ state: undefined }); }); }); describe('update', () => { const send = jest.fn(); const sendAll = jest.fn(); const game: Game = { moves: { A: ({ G }) => G, }, }; let db; let master; const action = ActionCreators.gameEvent('endTurn'); beforeEach(async () => { db = new InMemory(); master = new Master(game, db, TransportAPI(send, sendAll)); await master.onSync('matchID', '0', undefined, 2); jest.clearAllMocks(); }); test('basic', async () => { await master.onUpdate(action, 0, 'matchID', '0'); expect(sendAll).toBeCalled(); const value = sendAll.mock.calls[0][0]; expect(value.type).toBe('update'); expect(value.args[0]).toBe('matchID'); expect(value.args[1]).toMatchObject({ G: {}, _stateID: 1, ctx: { currentPlayer: '1', numPlayers: 2, phase: null, playOrder: ['0', '1'], playOrderPos: 1, turn: 2, }, }); }); test('missing action', async () => { const { error } = await master.onUpdate(null, 0, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toBe('missing action or action payload'); }); test('missing action payload', async () => { const { error } = await master.onUpdate({}, 0, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toBe('missing action or action payload'); }); test('invalid matchID', async () => { await master.onUpdate(action, 0, 'default:unknown', '1'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `game not found, matchID=[default:unknown]` ); }); test('invalid stateID', async () => { await master.onUpdate(action, 100, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `invalid stateID, was=[100], expected=[0] - playerID=[0] - action[endTurn]` ); }); test('invalid playerID', async () => { await master.onUpdate(action, 0, 'matchID', '100'); await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '100'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `player not active - playerID=[100] - action[move]` ); }); test('invalid move', async () => { await master.onUpdate(ActionCreators.makeMove('move'), 0, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `move not processed - canPlayerMakeMove=false - playerID=[0] - action[move]` ); }); test('valid matchID / stateID / playerID', async () => { await master.onUpdate(action, 0, 'matchID', '0'); expect(sendAll).toHaveBeenCalled(); }); test('allow execution of moves with ignoreStaleStateID truthy', async () => { const game: Game = { setup: () => { const G = { players: { '0': { cards: ['card3'], }, '1': { cards: [], }, }, cards: ['card0', 'card1', 'card2'], discardedCards: [], }; return G; }, playerView: PlayerView.STRIP_SECRETS, turn: { activePlayers: { currentPlayer: { stage: 'A' } }, stages: { A: { moves: { A: ({ G, playerID }) => { const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, B: { move: ({ G, playerID }) => { const card = G.cards.pop(); G.players[playerID].cards.push(card); }, ignoreStaleStateID: true, }, }, }, }, }, }; const send = jest.fn(); const master = new Master( game, new InMemory(), TransportAPI(send, sendAll) ); const setActivePlayers = ActionCreators.gameEvent( 'setActivePlayers', [{ all: 'A' }], '0' ); const actionA = ActionCreators.makeMove('A', null, '0'); const actionB = ActionCreators.makeMove('B', null, '1'); const actionC = ActionCreators.makeMove('B', null, '0'); // test: simultaneous moves await master.onSync('matchID', '0', undefined, 2); await master.onUpdate(actionA, 0, 'matchID', '0'); await master.onUpdate(setActivePlayers, 1, 'matchID', '0'); await Promise.all([ master.onUpdate(actionB, 2, 'matchID', '1'), master.onUpdate(actionC, 2, 'matchID', '0'), ]); await Promise.all([ master.onSync('matchID', '0', undefined, 2), master.onSync('matchID', '1', undefined, 2), ]); const G = sendAll.mock.calls[sendAll.mock.calls.length - 1][0].args[1].G; expect(G).toMatchObject({ players: { '0': { cards: ['card1'], }, }, cards: ['card0'], discardedCards: ['card3'], }); }); describe('undo / redo', () => { test('player 0 can undo', async () => { const move = ActionCreators.makeMove('A', null, '0'); await master.onUpdate(move, 0, 'matchID', '0'); expect(error).not.toHaveBeenCalled(); await master.onUpdate(ActionCreators.undo(), 1, 'matchID', '0'); expect(error).not.toHaveBeenCalled(); // Negative case: All moves already undone. await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0'); expect(error).toHaveBeenCalledWith(`No moves to undo`); }); test('player 1 can’t undo', async () => { await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '1'); expect(error).toHaveBeenCalledWith( `playerID=[1] cannot undo / redo right now` ); }); test('player can’t undo with multiple active players', async () => { const setActivePlayers = ActionCreators.gameEvent( 'setActivePlayers', [{ all: 'A' }], '0' ); await master.onUpdate(setActivePlayers, 0, 'matchID', '0'); await master.onUpdate(ActionCreators.undo('0'), 1, 'matchID', '0'); expect(error).toHaveBeenCalledWith( `playerID=[0] cannot undo / redo right now` ); }); test('player can undo if they are the only active player', async () => { const move = ActionCreators.makeMove('A', null, '0'); await master.onUpdate(move, 0, 'matchID', '0'); expect(error).not.toHaveBeenCalled(); const endStage = ActionCreators.gameEvent('endStage', undefined, '0'); await master.onUpdate(endStage, 1, 'matchID', '0'); expect(error).not.toBeCalled(); await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0'); expect(error).not.toBeCalled(); // Clean-up active players. const endStage2 = ActionCreators.gameEvent('endStage', undefined, '1'); await master.onUpdate(endStage2, 3, 'matchID', '1'); }); }); test('game over', async () => { let event = ActionCreators.gameEvent('endGame'); await master.onUpdate(event, 0, 'matchID', '0'); event = ActionCreators.gameEvent('endTurn'); await master.onUpdate(event, 1, 'matchID', '0'); expect(error).toHaveBeenCalledWith( `game over - matchID=[matchID] - playerID=[0] - action[endTurn]` ); }); test('writes gameover to metadata', async () => { const id = 'gameWithMetadata'; const db = new InMemory(); const dbMetadata = { gameName: 'tic-tac-toe', setupData: {}, players: { '0': { id: 0 }, '1': { id: 1 } }, createdAt: 0, updatedAt: 0, }; db.setMetadata(id, dbMetadata); const masterWithMetadata = new Master(game, db, TransportAPI(send)); await masterWithMetadata.onSync(id, '0', undefined, 2); const gameOverArg = 'gameOverArg'; const event = ActionCreators.gameEvent('endGame', gameOverArg); await masterWithMetadata.onUpdate(event, 0, id, '0'); const { metadata } = db.fetch(id, { metadata: true }); expect(metadata.gameover).toEqual(gameOverArg); }); test('writes gameover to metadata with null gameover', async () => { const id = 'gameWithMetadataNullGameover'; const db = new InMemory(); const dbMetadata = { gameName: 'tic-tac-toe', gameover: null, setupData: {}, players: { '0': { id: 0 }, '1': { id: 1 } }, createdAt: 0, updatedAt: 0, }; const masterWithMetadata = new Master(game, db, TransportAPI(send)); await masterWithMetadata.onSync(id, '0', undefined, 2); db.setMetadata(id, dbMetadata); const gameOverArg = 'gameOverArg'; const event = ActionCreators.gameEvent('endGame', gameOverArg); await masterWithMetadata.onUpdate(event, 0, id, '0'); const { metadata } = db.fetch(id, { metadata: true }); expect(metadata.gameover).toEqual(gameOverArg); }); test('writes gameover to metadata with async storage API', async () => { const id = 'gameWithMetadata'; const db = new InMemoryAsync(); const dbMetadata = { gameName: 'tic-tac-toe', setupData: {}, players: { '0': { id: 0 }, '1': { id: 1 } }, createdAt: 0, updatedAt: 0, }; await db.setMetadata(id, dbMetadata); const masterWithMetadata = new Master(game, db, TransportAPI(send)); await masterWithMetadata.onSync(id, '0', undefined, 2); const gameOverArg = 'gameOverArg'; const event = ActionCreators.gameEvent('endGame', gameOverArg); await masterWithMetadata.onUpdate(event, 0, id, '0'); const { metadata } = await db.fetch(id, { metadata: true }); expect(metadata.gameover).toEqual(gameOverArg); }); test('writes updatedAt to metadata with async storage API', async () => { const id = 'gameWithMetadata'; const db = new InMemoryAsync(); const dbMetadata = { gameName: 'tic-tac-toe', setupData: {}, players: { '0': { id: 0 }, '1': { id: 1 } }, createdAt: 0, updatedAt: 0, }; await db.setMetadata(id, dbMetadata); const masterWithMetadata = new Master(game, db, TransportAPI(send)); await masterWithMetadata.onSync(id, '0', undefined, 2); const updatedAt = new Date(2020, 3, 4, 5, 6, 7); dateMock.advanceTo(updatedAt); const event = ActionCreators.gameEvent('endTurn', null, '0'); await masterWithMetadata.onUpdate(event, 0, id, '0'); const { metadata } = await db.fetch(id, { metadata: true }); expect(metadata.updatedAt).toEqual(updatedAt.getTime()); }); test('processes update if there is no metadata', async () => { const id = 'gameWithoutMetadata'; const db = new InMemory(); const masterWithoutMetadata = new Master(game, db, TransportAPI(send)); // Store state manually to bypass automatic metadata initialization on sync. let state = InitializeGame({ game }); expect(state.ctx.turn).toBe(1); db.setState(id, state); // Dispatch update to end the turn. const event = ActionCreators.gameEvent('endTurn', null, '0'); await masterWithoutMetadata.onUpdate(event, 0, id, '0'); // Confirm the turn ended. let metadata: undefined | Server.MatchData; ({ state, metadata } = db.fetch(id, { state: true, metadata: true })); expect(state.ctx.turn).toBe(2); expect(metadata).toBeUndefined(); }); test('processes update if there is no metadata with async DB', async () => { const id = 'gameWithoutMetadata'; const db = new InMemoryAsync(); const masterWithoutMetadata = new Master(game, db, TransportAPI(send)); // Store state manually to bypass automatic metadata initialization on sync. let state = InitializeGame({ game }); expect(state.ctx.turn).toBe(1); await db.setState(id, state); // Dispatch update to end the turn. const event = ActionCreators.gameEvent('endTurn', null, '0'); await masterWithoutMetadata.onUpdate(event, 0, id, '0'); // Confirm the turn ended. let metadata: undefined | Server.MatchData; ({ state, metadata } = await db.fetch(id, { state: true, metadata: true })); expect(state.ctx.turn).toBe(2); expect(metadata).toBeUndefined(); }); }); describe('patch', () => { const send = jest.fn(); const sendAll = jest.fn(); const db = new InMemory(); const master = new Master( { seed: 0, deltaState: true, setup: () => { return { players: { '0': { cards: ['card3'], }, '1': { cards: [], }, }, cards: ['card0', 'card1', 'card2'], discardedCards: [], }; }, playerView: PlayerView.STRIP_SECRETS, turn: { activePlayers: { currentPlayer: { stage: 'A' } }, stages: { A: { moves: { Invalid: () => { return INVALID_MOVE; }, A: { client: false, move: ({ G, playerID }) => { const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, }, B: { client: false, ignoreStaleStateID: true, move: ({ G, playerID }) => { const card = G.cards.pop(); G.players[playerID].cards.push(card); }, }, }, }, }, }, }, db, TransportAPI(send, sendAll) ); const move = ActionCreators.makeMove('A', null, '0'); const action = ActionCreators.gameEvent('endTurn'); beforeAll(async () => { master.subscribe(({ state }) => { validateNotTransientState(state); }); await master.onSync('matchID', '0', undefined, 2); }); beforeEach(() => { jest.clearAllMocks(); }); test('basic', async () => { await master.onUpdate(move, 0, 'matchID', '0'); expect(sendAll).toBeCalled(); const value = sendAll.mock.calls[0][0]; expect(value.type).toBe('patch'); expect(value.args[0]).toBe('matchID'); expect(value.args[1]).toBe(0); // prevState -- had a card expect(value.args[2].G.players[0].cards).toEqual(['card3']); // state -- doesnt have a card anymore expect(value.args[3].G.players[0].cards).toEqual([]); }); test('invalid matchID', async () => { await master.onUpdate(action, 1, 'default:unknown', '1'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `game not found, matchID=[default:unknown]` ); }); test('invalid stateID', async () => { await master.onUpdate(action, 100, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `invalid stateID, was=[100], expected=[1] - playerID=[0] - action[endTurn]` ); }); test('invalid playerID', async () => { await master.onUpdate(action, 1, 'matchID', '102'); await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '102'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `player not active - playerID=[102] - action[move]` ); }); test('disallowed move', async () => { await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '0'); expect(sendAll).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( `move not processed - canPlayerMakeMove=false - playerID=[0] - action[move]` ); }); test('invalid move', async () => { await master.onUpdate( ActionCreators.makeMove('Invalid', null, '0'), 1, 'matchID', '0' ); expect(sendAll).toHaveBeenCalled(); expect(error).toHaveBeenCalledWith('invalid move: Invalid args: null'); }); test('valid matchID / stateID / playerID', async () => { await master.onUpdate(action, 1, 'matchID', '0'); expect(sendAll).toHaveBeenCalled(); }); describe('undo / redo', () => { test('player 0 can undo', async () => { await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '1'); // The master allows this, but the reducer does not. expect(error).toHaveBeenCalledWith(`No moves to undo`); }); test('player 1 can’t undo', async () => { await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0'); expect(error).toHaveBeenCalledWith( `playerID=[0] cannot undo / redo right now` ); }); test('player can’t undo with multiple active players', async () => { const setActivePlayers = ActionCreators.gameEvent( 'setActivePlayers', [{ all: 'A' }], '0' ); await master.onUpdate(setActivePlayers, 2, 'matchID', '0'); await master.onUpdate(ActionCreators.undo('0'), 3, 'matchID', '0'); expect(error).toHaveBeenCalledWith( `playerID=[0] cannot undo / redo right now` ); }); test('player can undo if they are the only active player', async () => { const endStage = ActionCreators.gameEvent('endStage', undefined, '1'); await master.onUpdate(endStage, 2, 'matchID', '1'); await master.onUpdate(ActionCreators.undo('0'), 3, 'matchID', '1'); // The master allows this, but the reducer does not. expect(error).toHaveBeenCalledWith(`Cannot undo other players' moves`); // Clean-up active players. const endStage2 = ActionCreators.gameEvent('endStage', undefined, '1'); await master.onUpdate(endStage2, 4, 'matchID', '1'); }); }); test('game over', async () => { let event = ActionCreators.gameEvent('endGame'); await master.onUpdate(event, 3, 'matchID', '1'); event = ActionCreators.gameEvent('endTurn'); await master.onUpdate(event, 3, 'matchID', '1'); expect(error).toHaveBeenCalledWith( `game over - matchID=[matchID] - playerID=[1] - action[endTurn]` ); }); }); describe('connectionChange', () => { const send = jest.fn(); const sendAll = jest.fn(); const db = new InMemory(); const master = new Master(game, db, TransportAPI(send, sendAll)); const metadata = { gameName: 'tic-tac-toe', setupData: {}, players: { '0': { id: 0, credentials: 'qS2m4Ujb_', name: 'Alice', }, '1': { id: 1, credentials: 'nIQtXFybDD', name: 'Bob', isConnected: true, }, }, createdAt: 0, updatedAt: 0, }; db.createMatch('matchID', { metadata, initialState: {} as State }); beforeEach(() => { master.subscribe(({ state }) => { validateNotTransientState(state); }); jest.clearAllMocks(); }); test('changes players metadata', async () => { await master.onConnectionChange('matchID', '0', undefined, true); const expectedPlayerData = { id: 0, name: 'Alice', isConnected: true }; const { metadata: { players }, } = db.fetch('matchID', { metadata: true }); expect(players['0']).toMatchObject(expectedPlayerData); }); test('sends metadata to all', async () => { await master.onConnectionChange('matchID', '1', undefined, false); const expectedMetadata = [ { id: 0, name: 'Alice', isConnected: true }, { id: 1, name: 'Bob', isConnected: false }, ]; const sentMessage = sendAll.mock.calls[0][0]; expect(sentMessage.type).toEqual('matchData'); expect(sentMessage.args[1]).toMatchObject(expectedMetadata); }); test('invalid matchID', async () => { const result = await master.onConnectionChange( 'invalidMatchID', '0', undefined, true ); expect(error).toHaveBeenCalledWith( 'metadata not found for matchID=[invalidMatchID]' ); expect(result && result.error).toEqual('metadata not found'); }); test('invalid playerID', async () => { const result = await master.onConnectionChange( 'matchID', '3', undefined, true ); expect(error).toHaveBeenCalledWith( 'Player not in the match, matchID=[matchID] playerID=[3]' ); expect(result && result.error).toEqual('player not in the match'); }); test('processes connection change with an async db', async () => { const asyncDb = new InMemoryAsync(); const masterWithAsyncDb = new Master( game, asyncDb, TransportAPI(send, sendAll) ); await asyncDb.createMatch('matchID', { metadata, initialState: {} as State, }); await masterWithAsyncDb.onConnectionChange('matchID', '0', undefined, true); expect(sendAll).toHaveBeenCalled(); }); }); describe('subscribe', () => { const callback = jest.fn(); let master; beforeAll(() => { master = new Master({}, new InMemory(), TransportAPI(jest.fn(), jest.fn())); master.subscribe(callback); }); test('sync', async () => { master.onSync('matchID', '0'); expect(callback).toBeCalledWith({ matchID: 'matchID', state: expect.objectContaining({ _stateID: 0 }), }); }); test('update', async () => { const action = ActionCreators.gameEvent('endTurn'); master.onUpdate(action, 0, 'matchID', '0'); expect(callback).toBeCalledWith({ matchID: 'matchID', action, state: expect.objectContaining({ _stateID: 1 }), }); }); }); describe('authentication', () => { const send = jest.fn(); const sendAll = jest.fn(); const game = { seed: 0 }; const matchID = 'matchID'; let storage = new InMemoryAsync(); const resetTestEnvironment = async () => { send.mockReset(); sendAll.mockReset(); storage = new InMemoryAsync(); const master = new Master(game, storage, TransportAPI()); await master.onSync(matchID, '0', undefined, 2); }; describe('onUpdate', () => { const action = ActionCreators.gameEvent('endTurn'); beforeEach(resetTestEnvironment); test('auth failure', async () => { const authenticateCredentials = () => false; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onUpdate(action, 0, matchID, '0'); expect(ret && ret.error).toBe('unauthorized action'); expect(sendAll).not.toHaveBeenCalled(); }); test('auth success', async () => { const authenticateCredentials = () => true; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onUpdate(action, 0, matchID, '0'); expect(ret).toBeUndefined(); expect(sendAll).toHaveBeenCalled(); }); test('default', async () => { const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth() ); const ret = await master.onUpdate(action, 0, matchID, '0'); expect(ret).toBeUndefined(); expect(sendAll).toHaveBeenCalled(); }); }); describe('onSync', () => { beforeEach(resetTestEnvironment); test('auth failure', async () => { const authenticateCredentials = () => false; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onSync(matchID, '0'); expect(ret && ret.error).toBe('unauthorized'); expect(send).not.toHaveBeenCalled(); }); test('auth success', async () => { const authenticateCredentials = () => true; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onSync(matchID, '0'); expect(ret).toBeUndefined(); expect(send).toHaveBeenCalled(); }); test('spectators don’t need to authenticate', async () => { const authenticateCredentials = () => false; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onSync(matchID, null); expect(ret).toBeUndefined(); expect(send).toHaveBeenCalled(); }); }); describe('onConnectionChange', () => { beforeEach(resetTestEnvironment); test('auth failure', async () => { const authenticateCredentials = () => false; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onConnectionChange(matchID, '0', null, true); expect(ret && ret.error).toBe('unauthorized'); expect(sendAll).not.toHaveBeenCalled(); }); test('auth success', async () => { const authenticateCredentials = () => true; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onConnectionChange(matchID, '0', null, true); expect(ret).toBeUndefined(); expect(sendAll).toHaveBeenCalled(); }); test('spectators are ignored', async () => { const authenticateCredentials = jest.fn(); const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onConnectionChange(matchID, null, null, true); expect(ret).toBeUndefined(); expect(authenticateCredentials).not.toHaveBeenCalled(); expect(sendAll).not.toHaveBeenCalled(); }); }); describe('onChatMessage', () => { const chatMessage = { id: 'uuid', payload: { message: 'foo' }, sender: '0', }; beforeEach(resetTestEnvironment); test('auth success', async () => { const authenticateCredentials = () => true; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onChatMessage(matchID, chatMessage, undefined); expect(ret).toBeUndefined(); expect(sendAll).toHaveBeenCalled(); }); test('auth failure', async () => { const authenticateCredentials = () => false; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onChatMessage(matchID, chatMessage, undefined); expect(ret && ret.error).toBe('unauthorized'); expect(sendAll).not.toHaveBeenCalled(); }); test('invalid packet', async () => { const authenticateCredentials = () => true; const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth({ authenticateCredentials }) ); const ret = await master.onChatMessage(matchID, undefined, undefined); expect(ret && ret.error).toBe('unauthorized'); expect(sendAll).not.toHaveBeenCalled(); }); test('default', async () => { const master = new Master( game, storage, TransportAPI(send, sendAll), new Auth() ); const ret = await master.onChatMessage(matchID, chatMessage, undefined); expect(ret).toBeUndefined(); expect(sendAll).toHaveBeenCalled(); }); }); }); describe('chat', () => { const send = jest.fn(); const sendAll = jest.fn(); const db = new InMemory(); const master = new Master(game, db, TransportAPI(send, sendAll)); beforeEach(() => { jest.clearAllMocks(); }); test('Sends chat messages to all', async () => { master.onChatMessage( 'matchID', { id: 'uuid', sender: '0', payload: { message: 'foo' } }, undefined ); expect(sendAll.mock.calls[0][0]).toEqual({ type: 'chat', args: [ 'matchID', { id: 'uuid', sender: '0', payload: { message: 'foo' } }, ], }); }); });