floriferous: Add simple Floriferous Game and scoring API points

This commit is contained in:
Thomas 2022-08-14 19:13:46 +01:00
parent 2fde8691b7
commit ede74c2461
35 changed files with 1533 additions and 8 deletions

5
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

11
.idea/aws.xml Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="accountSettings">
<option name="activeRegion" value="us-east-1" />
<option name="recentlyUsedRegions">
<list>
<option value="us-east-1" />
</list>
</option>
</component>
</project>

View file

@ -0,0 +1,65 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_QUOTE_STYLE" value="Single" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/thomaswilson-sveltekit.iml" filepath="$PROJECT_DIR$/.idea/thomaswilson-sveltekit.iml" />
</modules>
</component>
</project>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

69
.idea/workspace.xml Normal file
View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="74a06e37-01d9-4509-8ef0-5f4ca3c12565" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="GitSEFilterConfiguration">
<file-type-list>
<filtered-out-file-type name="LOCAL_BRANCH" />
<filtered-out-file-type name="REMOTE_BRANCH" />
<filtered-out-file-type name="TAG" />
<filtered-out-file-type name="COMMIT_BY_MESSAGE" />
</file-type-list>
</component>
<component name="ProjectId" id="2DcptjnYJZ3WE1cAFfqixbjIsUq" />
<component name="ProjectViewState">
<option name="autoscrollFromSource" value="true" />
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">
<property name="RunOnceActivity.OpenProjectViewOnStart" value="true" />
<property name="RunOnceActivity.ShowReadmeOnStart" value="true" />
<property name="WebServerToolWindowFactoryState" value="false" />
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
<property name="node.js.detected.package.eslint" value="true" />
<property name="node.js.detected.package.tslint" value="true" />
<property name="node.js.selected.package.eslint" value="(autodetect)" />
<property name="node.js.selected.package.tslint" value="(autodetect)" />
<property name="nodejs_package_manager_path" value="yarn" />
<property name="ts.external.directory.path" value="$APPLICATION_HOME_DIR$/plugins/JavaScriptLanguage/jsLanguageServicesImpl/external" />
<property name="vue.rearranger.settings.migration" value="true" />
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="74a06e37-01d9-4509-8ef0-5f4ca3c12565" name="Changes" comment="" />
<created>1661008443366</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1661008443366</updated>
<workItem from="1661008446925" duration="7444000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
</project>

View file

@ -9,7 +9,8 @@
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. .",
"test": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^1.0.0-next.65",
@ -28,12 +29,17 @@
"svelte-preprocess": "^4.10.1",
"tslib": "^2.3.1",
"typescript": "^4.7.4",
"vite": "^3.0.4"
"vite": "^3.0.4",
"vitest": "^0.21.0"
},
"type": "module",
"dependencies": {
"date-fns": "^2.28.0",
"mdsvex": "^0.10.5",
"sanitize-html": "^2.7.0"
"mongodb": "^4.8.1",
"nanoid": "^4.0.0",
"node-fetch": "^3.2.10",
"sanitize-html": "^2.7.0",
"zod": "^3.18.0"
}
}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { writable } from 'svelte/store';
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{ change: string }>();
let apiPassword = '';
let state: 'edit' | 'view' = 'edit';
let unsubscribe: () => void;
function onSubmit() {
state = 'view';
if (localStorage) {
localStorage.setItem('apiPassword', apiPassword);
}
dispatch('change', apiPassword);
}
function onEdit() {
state = 'edit';
}
onMount(() => {
if (localStorage !== undefined) {
apiPassword = localStorage.getItem('apiPassword') || '';
}
if (apiPassword.length > 0) {
dispatch('change', apiPassword);
state = 'view';
}
});
</script>
<section>
{#if apiPassword.length === 0}
<p>
To save things to the ledger you need to enter the password. Right now you haven't set one.
</p>
{/if}
{#if state === 'view'}
<button on:click={onEdit}>Edit Password</button>
{:else}
<form on:submit|preventDefault={onSubmit}>
<input type="text" bind:value={apiPassword} />
<input type="submit" value="Set Password" />
</form>
{/if}
</section>

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { FloriferousPlayer } from '$lib/floriferous';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let nameInput: HTMLInputElement;
let name = '';
let score = 0;
let rowAtEndOfGame = 1;
function handleFormSubmit() {
const player = new FloriferousPlayer({
name,
score,
rowAtEndOfGame: rowAtEndOfGame
});
dispatch('submit', player);
name = '';
score = 0;
rowAtEndOfGame = 1;
nameInput.focus();
}
</script>
<form on:submit|preventDefault={() => handleFormSubmit()}>
<div class="field">
<label for="player-name">Name</label>
<input bind:this={nameInput} bind:value={name} type="text" id="player-name" />
</div>
<div class="field">
<label for="player-score">Score</label>
<input bind:value={score} type="number" step="1" min="0" id="player-score" />
</div>
<div class="field">
<label for="player-score">Finishing Row</label>
<input bind:value={rowAtEndOfGame} type="number" step="1" min="0" id="player-score" />
<p class="example-text">"1" for the highest row, "2" for the second highest, etc.</p>
</div>
<input type="submit" value="add" />
</form>
<style>
form {
display: flex;
flex-direction: column;
padding: var(--spacing-sm);
border: 1px solid var(--gray-600);
}
.field {
display: flex;
flex-flow: column;
padding: var(--spacing-sm) 0;
}
.example-text {
color: var(--grey800);
font-size: var(--font-size-sm);
}
</style>

View file

@ -0,0 +1,3 @@
import FloriferousPlayerForm from './FloriferousPlayerForm.svelte';
export { FloriferousPlayerForm };

3
src/lib/Authenticator.ts Normal file
View file

@ -0,0 +1,3 @@
export interface Authenticator {
authenticate(password: string): boolean;
}

View file

@ -0,0 +1,8 @@
import type { FloriferousGame } from './floriferous-game';
export interface FloriferousGameRepository {
save(game: FloriferousGame): Promise<FloriferousGame>;
getById(id: string): Promise<FloriferousGame | null>;
getRecent(count: number): Promise<FloriferousGame[]>;
}

View file

@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import type { ApiGamesFloriferousPostRequest } from './floriferous-api-controller';
import { FloriferousApiController } from './floriferous-api-controller';
import { StubFloriferousGameRepository } from './stub-floriferous-game-repository';
import { FloriferousGame } from './floriferous-game';
import { SimplePasswordAuthenticator } from '../simple-password-authenticator';
import { Headers } from 'node-fetch';
const isDate = (value = 'invalid'): boolean => {
return value !== 'invalid' && !isNaN(Date.parse(value));
};
describe('FloriferousApiController', () => {
const stubGameRepository = new StubFloriferousGameRepository();
const authenticator = new SimplePasswordAuthenticator('expected-password');
const controller = new FloriferousApiController(stubGameRepository, authenticator);
it('should validate a request with a proper password', async () => {
// GIVEN
const headers: Headers = new Headers();
headers.set('x-api-password', 'expected-password');
// WHEN
const result = controller.isRequestAuthenticated({ headers: headers });
// THEN
expect(result).toBe(true);
});
it('should not validate a request with an invalid password', async () => {
// GIVEN
const headers = new Headers();
headers.set('x-api-password', 'invalid-password');
// WHEN
const result = controller.isRequestAuthenticated({ headers });
// THEN
expect(result).toBe(false);
});
it('should not validate a request without a password', async () => {
// GIVEN
const request = {
headers: new Headers()
};
// WHEN
const result = controller.isRequestAuthenticated(request);
// THEN
expect(result).toBe(false);
});
it('should get a list of recent games', async () => {
// GIVEN
const gameOne = new FloriferousGame({
id: 'game-one',
players: [{ name: 'Alice', score: 10, rowAtEndOfGame: 1 }],
playedTs: new Date('2022-07-01T07:00Z')
});
const gameTwo = new FloriferousGame({
id: 'game-two',
players: [],
playedTs: new Date('2022-08-20T13:25Z')
});
stubGameRepository.setAllGames([gameOne, gameTwo]);
// WHEN
const result = await controller.getRecentGames(10);
// THEN
expect(result).toStrictEqual([
{
id: 'game-one',
playedTs: '2022-07-01T07:00:00.000Z',
players: [{ name: 'Alice', score: 10, rowAtEndOfGame: 1 }]
},
{
id: 'game-two',
playedTs: '2022-08-20T13:25:00.000Z',
players: []
}
]);
});
it('should save a new game', async () => {
// GIVEN
const requestBody: ApiGamesFloriferousPostRequest = {
players: [
{
name: 'Alice',
rowAtEndOfGame: 1,
score: 10
}
]
};
// WHEN
const response = await controller.createNewGame(requestBody);
// THEN
expect(isDate(response.playedTs)).toBe(true);
expect(response).toStrictEqual({
id: expect.any(String),
playedTs: expect.any(String),
players: [
{
name: 'Alice',
score: 10,
rowAtEndOfGame: 1
}
]
});
});
});

View file

@ -0,0 +1,88 @@
import { z } from 'zod';
import type { ZodError } from 'zod';
import type { FloriferousGameJson } from './floriferous-game-api-port';
import type { FloriferousGameRepository } from './FloriferousGameRepository';
import { FloriferousGameApiPort } from './floriferous-game-api-port';
import { FloriferousGame } from './floriferous-game';
import { FloriferousPlayer } from './floriferous-player';
import type { Authenticator } from '../Authenticator';
export interface ApiGamesFloriferousPostRequest {
players: {
name: string;
score: number;
rowAtEndOfGame: number;
}[];
}
const apiGamesFloriferousPostRequestSchema = z.object({
players: z
.array(
z.object({
name: z.string().min(1, { message: 'Player names must be at least 1 character long' }),
score: z
.number({ invalid_type_error: 'Player Score must be a number.' })
.int({ message: 'Player Score must be a whole number.' })
.min(0, { message: 'Player score cannot be less than 0.' }),
rowAtEndOfGame: z.number().int().min(0)
})
)
.min(1)
});
export class FloriferousApiController {
constructor(
private readonly repository: FloriferousGameRepository,
private readonly validator: Authenticator
) {}
isRequestAuthenticated(request: any): boolean {
const password = request?.headers?.get('x-api-password') ?? '';
if (password === undefined) {
return false;
}
return this.validator.authenticate(password);
}
async getRecentGames(count = 10): Promise<FloriferousGameJson[]> {
const games = await this.repository.getRecent(count);
return games.map((game) => FloriferousGameApiPort.gameToJson(game));
}
async createNewGame(data: ApiGamesFloriferousPostRequest): Promise<FloriferousGameJson> {
try {
apiGamesFloriferousPostRequestSchema.parse(data);
} catch (e: any) {
if (e.issues !== undefined) {
const zodError: ZodError = e;
throw new Error(zodError.issues[0].message);
}
console.error({
message: `Caught error validating body data in createNewGame`,
error: e,
data: JSON.stringify(data)
});
throw new Error(e?.message ?? e);
}
const validatedData = data;
const players: FloriferousPlayer[] = validatedData.players.map(
({ name, rowAtEndOfGame, score }) => {
return new FloriferousPlayer({ name, rowAtEndOfGame, score });
}
);
const game = new FloriferousGame({
players,
playedTs: new Date()
});
const savedGame = await this.repository.save(game);
return FloriferousGameApiPort.gameToJson(game);
}
}

View file

@ -0,0 +1,57 @@
import { FloriferousGame } from './floriferous-game';
import { FloriferousPlayer } from './floriferous-player';
import { FloriferousGameApiPort } from './floriferous-game-api-port';
import { it, expect } from 'vitest';
it('should stringify a FloriferousGame into JSON', () => {
// GIVEN
const game = new FloriferousGame({
id: 'the-id',
playedTs: new Date('2020-01-01T00:00Z'),
players: [
new FloriferousPlayer({ name: 'first player', rowAtEndOfGame: 1, score: 2 }),
new FloriferousPlayer({ name: 'second player', rowAtEndOfGame: 3, score: 4 })
]
});
// WHEN
const gameAsJson = FloriferousGameApiPort.gameToJson(game);
// THEN
expect(gameAsJson).toStrictEqual({
id: 'the-id',
playedTs: '2020-01-01T00:00:00.000Z',
players: [
{ name: 'first player', rowAtEndOfGame: 1, score: 2 },
{ name: 'second player', rowAtEndOfGame: 3, score: 4 }
]
});
});
it('should parse JSON into a floriferous game', () => {
// GIVEN
const gameAsJson = {
id: 'the-id',
playedTs: '2020-01-01T00:00:00.000Z',
players: [
{ name: 'first player', rowAtEndOfGame: 1, score: 2 },
{ name: 'second player', rowAtEndOfGame: 3, score: 4 }
]
};
// WHEN
const game = FloriferousGameApiPort.jsonToGame(gameAsJson);
// THEN
expect(game).toStrictEqual(
new FloriferousGame({
id: 'the-id',
playedTs: new Date('2020-01-01T00:00Z'),
players: [
{ name: 'first player', rowAtEndOfGame: 1, score: 2 },
{ name: 'second player', rowAtEndOfGame: 3, score: 4 }
]
})
);
});

View file

@ -0,0 +1,39 @@
import { FloriferousGame } from './floriferous-game';
export interface FloriferousGameJson {
id: string;
playedTs: string;
players: {
name: string;
score: number;
rowAtEndOfGame: number;
}[];
}
export class FloriferousGameApiPort {
static jsonToGame(json: FloriferousGameJson): FloriferousGame {
const players = json.players.map((player) => ({
name: player.name,
score: player.score,
rowAtEndOfGame: player.rowAtEndOfGame
}));
return new FloriferousGame({
id: json.id,
playedTs: new Date(json.playedTs),
players
});
}
static gameToJson(game: FloriferousGame): FloriferousGameJson {
return {
id: game.id,
playedTs: game.playedTs.toISOString(),
players: game.players.map((player) => ({
name: player.name,
score: player.score,
rowAtEndOfGame: player.rowAtEndOfGame
}))
};
}
}

View file

@ -0,0 +1,46 @@
import { FloriferousGame } from './floriferous-game';
import { describe, it, expect } from 'vitest';
import { FloriferousPlayer } from './floriferous-player';
describe('FloriferousGame', () => {
const alice = new FloriferousPlayer({
name: 'Alice',
score: 2,
rowAtEndOfGame: 0
});
const bob = new FloriferousPlayer({
name: 'Bob',
score: 1,
rowAtEndOfGame: 1
});
it('Determines a winner', () => {
const game = new FloriferousGame();
// WHEN
game.addPlayer(alice);
game.addPlayer(bob);
// THEN
expect(game.winner).toBe('Alice');
});
it('Breaks a tie using the player closest to the top of the board', () => {
// GIVEN
const bobWithTwoPoints = new FloriferousPlayer({
name: 'Bob',
score: 2,
rowAtEndOfGame: 1
});
const game = new FloriferousGame();
// WHEN
game.addPlayer(alice);
game.addPlayer(bobWithTwoPoints);
// THEN
expect(game.winner).toBe('Alice');
});
});

View file

@ -0,0 +1,53 @@
import type { FloriferousPlayer } from './floriferous-player';
import { nanoid } from 'nanoid';
export interface FloriferousGameParams {
playedTs?: Date;
id?: string;
players?: FloriferousPlayer[];
}
export class FloriferousGame {
readonly id: string;
readonly playedTs: Date;
private _players: FloriferousPlayer[] = [];
constructor({ id = nanoid(), playedTs = new Date(), players = [] }: FloriferousGameParams = {}) {
this.id = id;
this.playedTs = playedTs;
this._players = players;
}
addPlayer(player: FloriferousPlayer): void {
this._players.push(player);
}
addPlayers(players: FloriferousPlayer[]): void {
players.forEach((player) => {
this.addPlayer(player);
});
}
get players(): FloriferousPlayer[] {
return this._players;
}
get winner(): string | undefined {
if (this._players.length === 0) {
return undefined;
}
const playersSortedByScore = this._players.sort((a, b) => {
return b.score - a.score;
});
if (playersSortedByScore[0].score === playersSortedByScore[1].score) {
const playersSortedByRowAtEndOfGame = this._players.sort((a, b) => {
return a.rowAtEndOfGame - b.rowAtEndOfGame;
});
return playersSortedByRowAtEndOfGame[0].name;
}
return playersSortedByScore[0].name;
}
}

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { FloriferousPlayer } from './floriferous-player';
describe('FloriferousPlayer', () => {
it('should construct with properties', () => {
// GIVEN
const player = new FloriferousPlayer({
name: 'Alice',
score: 2,
rowAtEndOfGame: 0
});
// THEN
expect(player.name).toBe('Alice');
expect(player.score).toBe(2);
expect(player.rowAtEndOfGame).toBe(0);
});
});

View file

@ -0,0 +1,17 @@
export interface FloriferousPlayerParams {
name: string;
score: number;
rowAtEndOfGame: number;
}
export class FloriferousPlayer {
readonly name: string;
readonly score: number;
readonly rowAtEndOfGame: number;
constructor(params: FloriferousPlayerParams) {
this.name = params.name;
this.score = params.score;
this.rowAtEndOfGame = params.rowAtEndOfGame;
}
}

View file

@ -0,0 +1,2 @@
export { FloriferousGame } from './floriferous-game';
export { FloriferousPlayer } from './floriferous-player';

View file

@ -0,0 +1,128 @@
import { describe, expect, it, beforeEach, afterAll } from 'vitest';
import { FloriferousGame, type FloriferousGameParams } from './floriferous-game';
import { FloriferousPlayer, type FloriferousPlayerParams } from './floriferous-player';
import { customAlphabet } from 'nanoid';
import { MongodbFloriferousGameRepository } from './mongodb-floriferous-game-repository';
import { MongoClient } from 'mongodb';
describe('MongoDB FloriferousGame Repository', () => {
const mongoDbUrl = import.meta.env.VITE_MONGO_URL;
const mongoDbName = import.meta.env.VITE_MONGO_DB_NAME;
const janitor = new MongodbJanitor(mongoDbUrl, mongoDbName);
let playerOne: FloriferousPlayer;
let playerTwo: FloriferousPlayer;
let game: FloriferousGame;
beforeEach(async () => {
playerOne = floriferousPlayerFactory({ name: 'Player 1' });
playerTwo = floriferousPlayerFactory({ name: 'Player 2' });
game = floriferousGameFactory();
game.addPlayer(playerOne);
game.addPlayer(playerTwo);
});
afterAll(async () => {
await janitor.deleteAllDocumentsInConnection('floriferous-games');
});
const repository = new MongodbFloriferousGameRepository(mongoDbUrl, mongoDbName);
it('should save', async () => {
// WHEN
const savedGame = await repository.save(game);
// THEN
expect(savedGame).toBeDefined();
});
it('should get by ID when it exists', async () => {
// GIVEN
const savedGame = await repository.save(game);
// WHEN
const foundGame = await repository.getById(game.id);
// THEN
expect(foundGame).toStrictEqual(game);
});
it('should return null when fetching by ID for a game which did not happen', async () => {
// WHEN
const foundGame = await repository.getById('any-random-id');
// THEN
expect(foundGame).toBeNull();
});
it('should get the last 10 games, sorted by playedTs', async () => {
// GIVEN
await janitor.deleteAllDocumentsInConnection('floriferous-games');
const gameOne = floriferousGameFactory({
playedTs: new Date('2022-08-07T19:00Z')
});
const gameTwo = floriferousGameFactory({
playedTs: new Date('2022-08-07T06:00Z')
});
await repository.save(gameOne);
await repository.save(gameTwo);
// WHEN
const recentGames = await repository.getRecent(10);
// THEN
expect(recentGames).toStrictEqual([gameTwo, gameOne]);
});
it('should return an empty array when no games have taken place', async () => {
// GIVEN
await janitor.deleteAllDocumentsInConnection('floriferous-games');
// WHEN
const recentGames = await repository.getRecent(5);
// THEN
expect(recentGames).toStrictEqual([]);
});
});
class MongodbJanitor {
private client: MongoClient;
constructor(private readonly url: string, private readonly dbName: string) {
this.client = new MongoClient(url);
}
async deleteAllDocumentsInConnection(collectionName: string): Promise<void> {
console.info(`Deleting all documents in ${collectionName}`);
const connection = await this.client.connect();
const deltedDocuments = await connection
.db(this.dbName)
.collection(collectionName)
.deleteMany({});
await connection.close();
console.info(`Deleted ${deltedDocuments.deletedCount} documents`);
}
}
function floriferousPlayerFactory(
overrides: Partial<FloriferousPlayerParams> = {}
): FloriferousPlayer {
return new FloriferousPlayer({
name: `name-${customAlphabet('abcdefghijklmnopqrstuvwxyz', 8)}`,
score: Math.floor(Math.random() * 100),
rowAtEndOfGame: Math.floor(Math.random() * 10),
...overrides
});
}
function floriferousGameFactory(overrides: Partial<FloriferousGameParams> = {}): FloriferousGame {
return new FloriferousGame(overrides);
}

View file

@ -0,0 +1,123 @@
import { FloriferousGame } from './floriferous-game';
import { MongoClient } from 'mongodb';
import { FloriferousPlayer } from './floriferous-player';
import type { FloriferousGameRepository } from './FloriferousGameRepository';
interface FloriferousGameMongoDocument {
id: string;
playedTs: Date;
players: {
name: string;
score: number;
rowAtEndOfGame: number;
}[];
}
export class MongodbFloriferousGameRepository implements FloriferousGameRepository {
private client: MongoClient;
private connection: MongoClient | null = null;
private collectionName = 'floriferous-games';
constructor(private readonly mongodbUrl: string, private readonly dbName: string) {
this.client = new MongoClient(mongodbUrl);
}
private async connect(): Promise<void> {
this.connection = await this.client.connect();
}
private async disconnect(): Promise<void> {
if (this.connection === null) {
return;
}
await this.connection.close();
}
async save(game: FloriferousGame): Promise<FloriferousGame> {
await this.connect();
const data = MongodbFloriferousGameRepository.gameToMongoDocument(game);
const document = await this.connection
.db(this.dbName)
.collection<FloriferousGameMongoDocument>(this.collectionName)
.insertOne(data);
await this.disconnect();
return game;
}
async getById(id: string): Promise<FloriferousGame | null> {
await this.connect();
const document = await this.connection
.db(this.dbName)
.collection<FloriferousGameMongoDocument>(this.collectionName)
.findOne({ id });
await this.disconnect();
if (document === null) {
return null;
}
return MongodbFloriferousGameRepository.documentToGame(document);
}
async getRecent(count = 10): Promise<FloriferousGame[]> {
await this.connect();
const documents = await this.connection
.db(this.dbName)
.collection<FloriferousGameMongoDocument>(this.collectionName)
.find({})
.sort({ playedTs: 1 })
.limit(count)
.toArray();
await this.disconnect();
if (!documents) {
return [];
}
const formatted = documents.map((document) =>
MongodbFloriferousGameRepository.documentToGame(document)
);
return formatted;
}
private static documentToGame(document: FloriferousGameMongoDocument): FloriferousGame {
const players: FloriferousPlayer[] = document.players.map(
({ name, rowAtEndOfGame, score }) =>
new FloriferousPlayer({
name,
rowAtEndOfGame: rowAtEndOfGame,
score
})
);
const game = new FloriferousGame({
id: document.id,
playedTs: document.playedTs,
players
});
return game;
}
private static gameToMongoDocument(game: FloriferousGame): FloriferousGameMongoDocument {
return {
id: game.id,
playedTs: game.playedTs,
players: game.players.map((player) => ({
rowAtEndOfGame: player.rowAtEndOfGame,
name: player.name,
score: player.score
}))
};
}
}

View file

@ -0,0 +1,30 @@
import type { FloriferousGameRepository } from './FloriferousGameRepository';
import type { FloriferousGame } from './floriferous-game';
export class StubFloriferousGameRepository implements FloriferousGameRepository{
private games: FloriferousGame[] = [];
setAllGames(games: FloriferousGame[]): void {
this.games = games;
}
getById(id: string): Promise<FloriferousGame | null> {
throw new Error('Method not implemented.');
}
getRecent(count: number): Promise<FloriferousGame[]> {
return Promise.resolve(this.games.map((game, index) => {
if (index < count -1) {
return game;
}
return undefined
}).filter((game) => game !== undefined));
}
save(game: FloriferousGame): Promise<FloriferousGame> {
return Promise.resolve(undefined);
}
}

View file

@ -0,0 +1,26 @@
import { SimplePasswordAuthenticator } from './simple-password-authenticator';
import { it, expect} from 'vitest'
it('should do nothing when things are valid', () => {
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// WHEN
const result = authenticator.authenticate('expected-password');
//
expect(result).toBeTruthy();
})
it('should not authenticate when the password is invalid', () => {
// GIVEN
const authenticator = new SimplePasswordAuthenticator('expected-password');
// WHEN
const result = authenticator.authenticate('invalid-password');
// THEN
expect(result).toBeFalsy();
})

View file

@ -0,0 +1,13 @@
import type { Authenticator } from './Authenticator';
export class SimplePasswordAuthenticator implements Authenticator{
constructor(private readonly password: string) {
if (this.password === undefined) {
throw new Error('Password must be defined');
}
}
authenticate(password: string): boolean {
return this.password === password;
}
}

View file

@ -0,0 +1,51 @@
import { MONGO_URL, MONGO_DB_NAME, API_PASSWORD } from '$env/static/private';
import type { RequestHandler, RequestHandlerOutput } from '@sveltejs/kit';
import { MongodbFloriferousGameRepository } from '../../../lib/floriferous/mongodb-floriferous-game-repository';
import { FloriferousApiController } from '../../../lib/floriferous/floriferous-api-controller';
import { SimplePasswordAuthenticator } from '../../../lib/simple-password-authenticator';
export const GET: RequestHandler = async (): Promise<RequestHandlerOutput> => {
const controller = new FloriferousApiController(
new MongodbFloriferousGameRepository(MONGO_URL, MONGO_DB_NAME),
new SimplePasswordAuthenticator(API_PASSWORD)
);
const response = await controller.getRecentGames(10);
return {
status: 200,
body: JSON.stringify(response)
};
};
export const POST: RequestHandler = async ({ request }) => {
const controller = new FloriferousApiController(
new MongodbFloriferousGameRepository(MONGO_URL, MONGO_DB_NAME),
new SimplePasswordAuthenticator(API_PASSWORD)
);
const isAuthenticated = controller.isRequestAuthenticated(request);
if (!isAuthenticated) {
return {
status: 401,
body: 'Unauthorized'
};
}
try {
const requestBody = await request.json();
const response = await controller.createNewGame(requestBody);
return {
status: 200,
body: JSON.parse(JSON.stringify(response))
};
} catch (e) {
return {
status: 500,
body: JSON.stringify(e)
};
}
};

View file

@ -0,0 +1,167 @@
<script lang="ts" context="module">
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ fetch }) => {
const previousGames = await fetch('/api/games/floriferous.json').then((res) => res.json());
return {
status: 200,
props: {
previousGames: previousGames.map(FloriferousGameApiPort.jsonToGame)
}
};
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { FloriferousGame } from '../../../lib/floriferous';
import type { FloriferousPlayer } from '../../../lib/floriferous';
import { FloriferousPlayerForm } from '../../../components/games';
import {
FloriferousGameApiPort,
type FloriferousGameJson
} from '$lib/floriferous/floriferous-game-api-port';
import ApiPasswordFrom from '../../../components/games/ApiPasswordForm.svelte';
import type { ApiGamesFloriferousPostRequest } from '$lib/floriferous/floriferous-api-controller';
export let previousGames: FloriferousGame[];
let apiPassword = '';
let players: FloriferousPlayer[] = [];
let isWinnerVisible = false;
let isSaveSubmitting = false;
let isGameSaved = false;
onMount(() => {
console.log({ previousGames });
});
function handleShowWinner() {
isWinnerVisible = true;
}
function onAddPlayer(event: CustomEvent<FloriferousPlayer>) {
players = [...players, event.detail];
}
function onRemovePlayer(playerToRemove: FloriferousPlayer) {
players = players.filter((player) => {
return playerToRemove.name !== player.name;
});
}
function clearGameData() {
players = [];
isWinnerVisible = false;
}
async function onSaveGame() {
isSaveSubmitting = true;
if (players.length < 2) {
console.warn(`Not enough players to save game`);
isSaveSubmitting = false;
return;
}
const body: { players: FloriferousPlayer[] } = { players };
fetch('/api/games/floriferous.json', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'x-api-password': apiPassword
}
})
.then((response) => {
return response.json();
})
.then((gameAsJson: FloriferousGameJson) => {
const game = FloriferousGameApiPort.jsonToGame(gameAsJson);
previousGames = [...previousGames, game];
clearGameData();
})
.catch((error) => {
if (error.status === 401) {
console.warn(`Invalid API password`);
return;
}
console.error(error);
isSaveSubmitting = false;
});
}
$: game = new FloriferousGame({ playedTs: new Date(), players });
</script>
<h1>Floriferous Scoring</h1>
{#if previousGames.length > 0}
<section class="previous-games">
<h2>Previous Games</h2>
<ul>
{#each previousGames as game}
<li transition:slide>
{Intl.DateTimeFormat('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(game.playedTs)}:
{game.winner} Won
</li>
{/each}
</ul>
</section>
{/if}
<section class="players">
<h2>Players</h2>
{#if players.length > 0}
<ul>
{#each players as player}
<li>
{player.name} ({player.score} points, finished on row {player.rowAtEndOfGame}) (<button
on:click={() => onRemovePlayer(player)}>Remove</button
>)
</li>
{/each}
</ul>
{#if players.length > 1}
{#if isWinnerVisible}
<p transition:slide>And the winner is:<strong>{game.winner}</strong></p>
<h3>Add to Ledger</h3>
<ApiPasswordFrom on:change={(event) => (apiPassword = event.detail)} />
{#if apiPassword.length > 0}
<p>You can save this game in the Ledger</p>
<button on:click={onSaveGame}>Save Game</button>
{/if}
{:else}
<button on:click={handleShowWinner}>Show me the winner</button>
{/if}
{/if}
{:else}
<p>Add at least one player to get started</p>
{/if}
{#if !isWinnerVisible}
<h3>Add a New Player</h3>
<FloriferousPlayerForm on:submit={onAddPlayer} />
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
padding: var(--spacing-sm);
max-width: 600px;
}
</style>

View file

@ -32,6 +32,10 @@
--spacing-lg: 1rem;
--spacing-xl: 1.5rem;
--navbar-height: 75px;
--font-size-sm: 0.875rem;
--font-size-md: 1.25rem;
--font-size-lg: 1.5rem;
}
body {

View file

@ -13,7 +13,10 @@ const config = {
})],
kit: {
adapter: adapter({ split: false })
adapter: adapter({ split: false }),
env: {
publicPrefix: 'PUBLIC_'
}
}
};

View file

@ -2,7 +2,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()]
plugins: [sveltekit()],
};
export default config;

230
yarn.lock
View file

@ -200,6 +200,18 @@
magic-string "^0.26.2"
svelte-hmr "^0.14.12"
"@types/chai-subset@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
dependencies:
"@types/chai" "*"
"@types/chai@*", "@types/chai@^4.3.3":
version "4.3.3"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@ -234,6 +246,19 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
"@types/webidl-conversions@*":
version "6.1.1"
resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e"
integrity sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q==
"@types/whatwg-url@^8.2.1":
version "8.2.2"
resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-8.2.2.tgz#749d5b3873e845897ada99be4448041d4cc39e63"
integrity sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==
dependencies:
"@types/node" "*"
"@types/webidl-conversions" "*"
"@typescript-eslint/eslint-plugin@^5.32.0":
version "5.33.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz#c0a480d05211660221eda963cc844732fe9b1714"
@ -435,6 +460,11 @@ array-union@^2.1.0:
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
assertion-error@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
@ -450,6 +480,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
@ -477,16 +512,44 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
bson@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/bson/-/bson-4.7.0.tgz#7874a60091ffc7a45c5dd2973b5cad7cded9718a"
integrity sha512-VrlEE4vuiO1WTpfof4VmaVolCVYkYTgB9iWgYNOrVlnifpME/06fhFRmONgBhClD5pFC1t9ZWqFUQEQAzY43bA==
dependencies:
buffer "^5.6.0"
buffer-crc32@^0.2.5:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
buffer@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.1.13"
callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
chai@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c"
integrity sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==
dependencies:
assertion-error "^1.1.0"
check-error "^1.0.2"
deep-eql "^3.0.1"
get-func-name "^2.0.0"
loupe "^2.3.1"
pathval "^1.1.1"
type-detect "^4.0.5"
chalk@^2.0.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@ -504,6 +567,11 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
check-error@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
integrity sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==
chokidar@^3.4.1:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -594,6 +662,13 @@ debug@4, debug@^4.0.1, debug@^4.1.1, debug@^4.3.4:
dependencies:
ms "2.1.2"
deep-eql@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
dependencies:
type-detect "^4.0.0"
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@ -609,6 +684,11 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
denque@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
detect-indent@^6.0.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
@ -1095,6 +1175,11 @@ gauge@^3.0.0:
strip-ansi "^6.0.1"
wide-align "^1.1.2"
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@ -1188,6 +1273,11 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@ -1224,6 +1314,11 @@ inherits@2, inherits@^2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ip@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da"
integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
@ -1311,6 +1406,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
local-pkg@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.2.tgz#13107310b77e74a0e513147a131a2ba288176c2f"
integrity sha512-mlERgSPrbxU3BP4qBqAvvwlgW4MTg78iwJdGGnv7kibKjWcJksrG3t6LB5lXI93wXRDvG4NpUgJFmTG4T6rdrg==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
@ -1321,6 +1421,13 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==
loupe@^2.3.1:
version "2.3.4"
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.4.tgz#7e0b9bffc76f148f9be769cb1321d3dcf3cb25f3"
integrity sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==
dependencies:
get-func-name "^2.0.0"
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@ -1359,6 +1466,11 @@ mdsvex@^0.10.5:
prismjs "^1.17.1"
vfile-message "^2.0.4"
memory-pager@^1.0.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
merge2@^1.3.0, merge2@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
@ -1421,6 +1533,26 @@ mkdirp@^1.0.3:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mongodb-connection-string-url@^2.5.3:
version "2.5.3"
resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-2.5.3.tgz#c0c572b71570e58be2bd52b33dffd1330cfb6990"
integrity sha512-f+/WsED+xF4B74l3k9V/XkTVj5/fxFH2o5ToKXd8Iyi5UhM+sO9u0Ape17Mvl/GkZaFtM0HQnzAG5OTmhKw+tQ==
dependencies:
"@types/whatwg-url" "^8.2.1"
whatwg-url "^11.0.0"
mongodb@^4.8.1:
version "4.9.0"
resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-4.9.0.tgz#58618439b721f2d6f7d38bb10a4612e29d7f1c8a"
integrity sha512-tJJEFJz7OQTQPZeVHZJIeSOjMRqc5eSyXTt86vSQENEErpkiG7279tM/GT5AVZ7TgXNh9HQxoa2ZkbrANz5GQw==
dependencies:
bson "^4.7.0"
denque "^2.1.0"
mongodb-connection-string-url "^2.5.3"
socks "^2.7.0"
optionalDependencies:
saslprep "^1.0.3"
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@ -1441,6 +1573,11 @@ nanoid@^3.3.4:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5"
integrity sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -1458,7 +1595,7 @@ node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"
node-fetch@^3.2.4:
node-fetch@^3.2.10, node-fetch@^3.2.4:
version "3.2.10"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8"
integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==
@ -1550,6 +1687,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pathval@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
@ -1599,7 +1741,7 @@ progress@^2.0.0:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
punycode@^2.1.0:
punycode@^2.1.0, punycode@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@ -1733,6 +1875,13 @@ sanitize-html@^2.7.0:
parse-srcset "^1.0.2"
postcss "^8.3.11"
saslprep@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
dependencies:
sparse-bitfield "^3.0.3"
semver@^6.0.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
@ -1795,6 +1944,19 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
socks@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.0.tgz#f9225acdb841e874dca25f870e9130990f3913d0"
integrity sha512-scnOe9y4VuiNUULJN72GrM26BNOjVsfPXI+j+98PkyEfsIXroa5ofyjT+FzGvn/xHs73U2JtoBYAVx9Hl4quSA==
dependencies:
ip "^2.0.0"
smart-buffer "^4.2.0"
sorcery@^0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/sorcery/-/sorcery-0.10.0.tgz#8ae90ad7d7cb05fc59f1ab0c637845d5c15a52b7"
@ -1815,6 +1977,13 @@ sourcemap-codec@^1.3.0, sourcemap-codec@^1.4.8:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
sparse-bitfield@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11"
integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==
dependencies:
memory-pager "^1.0.2"
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@ -1946,6 +2115,16 @@ tiny-glob@^0.2.9:
globalyzer "0.1.0"
globrex "^0.1.2"
tinypool@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.2.4.tgz#4d2598c4689d1a2ce267ddf3360a9c6b3925a20c"
integrity sha512-Vs3rhkUH6Qq1t5bqtb816oT+HeJTXfwt2cbPH17sWHIYKTotQIFPk3tf2fgqRrVyMDVOc1EnPgzIxfIulXVzwQ==
tinyspy@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-1.0.2.tgz#6da0b3918bfd56170fb3cd3a2b5ef832ee1dff0d"
integrity sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q==
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@ -1958,6 +2137,13 @@ totalist@^3.0.0:
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.0.tgz#4ef9c58c5f095255cdc3ff2a0a55091c57a3a1bd"
integrity sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==
tr46@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
dependencies:
punycode "^2.1.1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@ -1987,6 +2173,11 @@ type-check@^0.4.0, type-check@~0.4.0:
dependencies:
prelude-ls "^1.2.1"
type-detect@^4.0.0, type-detect@^4.0.5:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
type-fest@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
@ -2034,7 +2225,7 @@ vfile-message@^2.0.4:
"@types/unist" "^2.0.0"
unist-util-stringify-position "^2.0.0"
vite@^3.0.4:
"vite@^2.9.12 || ^3.0.0-0", vite@^3.0.4:
version "3.0.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.9.tgz#45fac22c2a5290a970f23d66c1aef56a04be8a30"
integrity sha512-waYABTM+G6DBTCpYAxvevpG50UOlZuynR0ckTK5PawNVt7ebX6X7wNXHaGIO6wYYFXSM7/WcuFuO2QzhBB6aMw==
@ -2046,6 +2237,21 @@ vite@^3.0.4:
optionalDependencies:
fsevents "~2.3.2"
vitest@^0.21.0:
version "0.21.1"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.21.1.tgz#b4f5b901c9a23a3aaec76d3404f3072821d93d00"
integrity sha512-WBIxuFmIDPuK47GO6Lu9eNeRMqHj/FWL3dk73OHH3eyPPWPiu+UB3QHLkLK2PEggCqJW4FaWoWg8R68S7p9+9Q==
dependencies:
"@types/chai" "^4.3.3"
"@types/chai-subset" "^1.3.3"
"@types/node" "*"
chai "^4.3.6"
debug "^4.3.4"
local-pkg "^0.4.2"
tinypool "^0.2.4"
tinyspy "^1.0.0"
vite "^2.9.12 || ^3.0.0-0"
web-streams-polyfill@^3.0.3:
version "3.2.1"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6"
@ -2056,6 +2262,19 @@ webidl-conversions@^3.0.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
whatwg-url@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
dependencies:
tr46 "^3.0.0"
webidl-conversions "^7.0.0"
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@ -2100,3 +2319,8 @@ yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
zod@^3.18.0:
version "3.18.0"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.18.0.tgz#2eed58b3cafb8d9a67aa2fee69279702f584f3bc"
integrity sha512-gwTm8RfUCe8l9rDwN5r2A17DkAa8Ez4Yl4yXqc5VqeGaXaJahzYYXbTwvhroZi0SNBqTwh/bKm2N0mpCzuw4bA==