diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..b58b603
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/aws.xml b/.idea/aws.xml
new file mode 100644
index 0000000..b63b642
--- /dev/null
+++ b/.idea/aws.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..ca3cfd8
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..af4dbd4
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/thomaswilson-sveltekit.iml b/.idea/thomaswilson-sveltekit.iml
new file mode 100644
index 0000000..0c8867d
--- /dev/null
+++ b/.idea/thomaswilson-sveltekit.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..c063cf8
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1661008443366
+
+
+ 1661008443366
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 16ac517..6ea32f2 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/src/components/games/ApiPasswordForm.svelte b/src/components/games/ApiPasswordForm.svelte
new file mode 100644
index 0000000..b163a40
--- /dev/null
+++ b/src/components/games/ApiPasswordForm.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#if apiPassword.length === 0}
+
+ To save things to the ledger you need to enter the password. Right now you haven't set one.
+
+ {/if}
+ {#if state === 'view'}
+ Edit Password
+ {:else}
+
+ {/if}
+
diff --git a/src/components/games/FloriferousPlayerForm.svelte b/src/components/games/FloriferousPlayerForm.svelte
new file mode 100644
index 0000000..679f861
--- /dev/null
+++ b/src/components/games/FloriferousPlayerForm.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
diff --git a/src/components/games/index.ts b/src/components/games/index.ts
new file mode 100644
index 0000000..29173c3
--- /dev/null
+++ b/src/components/games/index.ts
@@ -0,0 +1,3 @@
+import FloriferousPlayerForm from './FloriferousPlayerForm.svelte';
+
+export { FloriferousPlayerForm };
diff --git a/src/lib/Authenticator.ts b/src/lib/Authenticator.ts
new file mode 100644
index 0000000..12e0add
--- /dev/null
+++ b/src/lib/Authenticator.ts
@@ -0,0 +1,3 @@
+export interface Authenticator {
+ authenticate(password: string): boolean;
+}
\ No newline at end of file
diff --git a/src/lib/floriferous/FloriferousGameRepository.ts b/src/lib/floriferous/FloriferousGameRepository.ts
new file mode 100644
index 0000000..8677716
--- /dev/null
+++ b/src/lib/floriferous/FloriferousGameRepository.ts
@@ -0,0 +1,8 @@
+import type { FloriferousGame } from './floriferous-game';
+
+export interface FloriferousGameRepository {
+ save(game: FloriferousGame): Promise;
+ getById(id: string): Promise;
+ getRecent(count: number): Promise;
+
+}
\ No newline at end of file
diff --git a/src/lib/floriferous/floriferous-api-controller.spec.ts b/src/lib/floriferous/floriferous-api-controller.spec.ts
new file mode 100644
index 0000000..5283859
--- /dev/null
+++ b/src/lib/floriferous/floriferous-api-controller.spec.ts
@@ -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
+ }
+ ]
+ });
+ });
+});
diff --git a/src/lib/floriferous/floriferous-api-controller.ts b/src/lib/floriferous/floriferous-api-controller.ts
new file mode 100644
index 0000000..cdd5cb6
--- /dev/null
+++ b/src/lib/floriferous/floriferous-api-controller.ts
@@ -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 {
+ const games = await this.repository.getRecent(count);
+ return games.map((game) => FloriferousGameApiPort.gameToJson(game));
+ }
+
+ async createNewGame(data: ApiGamesFloriferousPostRequest): Promise {
+ 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);
+ }
+}
diff --git a/src/lib/floriferous/floriferous-game-api-port.spec.ts b/src/lib/floriferous/floriferous-game-api-port.spec.ts
new file mode 100644
index 0000000..4a5357b
--- /dev/null
+++ b/src/lib/floriferous/floriferous-game-api-port.spec.ts
@@ -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 }
+ ]
+ })
+ );
+});
diff --git a/src/lib/floriferous/floriferous-game-api-port.ts b/src/lib/floriferous/floriferous-game-api-port.ts
new file mode 100644
index 0000000..cabda72
--- /dev/null
+++ b/src/lib/floriferous/floriferous-game-api-port.ts
@@ -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
+ }))
+ };
+ }
+}
diff --git a/src/lib/floriferous/floriferous-game.spec.ts b/src/lib/floriferous/floriferous-game.spec.ts
new file mode 100644
index 0000000..b98aa40
--- /dev/null
+++ b/src/lib/floriferous/floriferous-game.spec.ts
@@ -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');
+ });
+});
diff --git a/src/lib/floriferous/floriferous-game.ts b/src/lib/floriferous/floriferous-game.ts
new file mode 100644
index 0000000..1af9fce
--- /dev/null
+++ b/src/lib/floriferous/floriferous-game.ts
@@ -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;
+ }
+}
diff --git a/src/lib/floriferous/floriferous-player.spec.ts b/src/lib/floriferous/floriferous-player.spec.ts
new file mode 100644
index 0000000..a22ca71
--- /dev/null
+++ b/src/lib/floriferous/floriferous-player.spec.ts
@@ -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);
+ });
+});
diff --git a/src/lib/floriferous/floriferous-player.ts b/src/lib/floriferous/floriferous-player.ts
new file mode 100644
index 0000000..a324138
--- /dev/null
+++ b/src/lib/floriferous/floriferous-player.ts
@@ -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;
+ }
+}
diff --git a/src/lib/floriferous/index.ts b/src/lib/floriferous/index.ts
new file mode 100644
index 0000000..7be618f
--- /dev/null
+++ b/src/lib/floriferous/index.ts
@@ -0,0 +1,2 @@
+export { FloriferousGame } from './floriferous-game';
+export { FloriferousPlayer } from './floriferous-player';
diff --git a/src/lib/floriferous/mongodb-floriferous-game-repository.integration.spec.ts b/src/lib/floriferous/mongodb-floriferous-game-repository.integration.spec.ts
new file mode 100644
index 0000000..a9b1f5d
--- /dev/null
+++ b/src/lib/floriferous/mongodb-floriferous-game-repository.integration.spec.ts
@@ -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 {
+ 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 = {}
+): 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 = {}): FloriferousGame {
+ return new FloriferousGame(overrides);
+}
diff --git a/src/lib/floriferous/mongodb-floriferous-game-repository.ts b/src/lib/floriferous/mongodb-floriferous-game-repository.ts
new file mode 100644
index 0000000..e3ccf14
--- /dev/null
+++ b/src/lib/floriferous/mongodb-floriferous-game-repository.ts
@@ -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 {
+ this.connection = await this.client.connect();
+ }
+
+ private async disconnect(): Promise {
+ if (this.connection === null) {
+ return;
+ }
+
+ await this.connection.close();
+ }
+
+ async save(game: FloriferousGame): Promise {
+ await this.connect();
+
+ const data = MongodbFloriferousGameRepository.gameToMongoDocument(game);
+
+ const document = await this.connection
+ .db(this.dbName)
+ .collection(this.collectionName)
+ .insertOne(data);
+
+ await this.disconnect();
+
+ return game;
+ }
+
+ async getById(id: string): Promise {
+ await this.connect();
+
+ const document = await this.connection
+ .db(this.dbName)
+ .collection(this.collectionName)
+ .findOne({ id });
+
+ await this.disconnect();
+
+ if (document === null) {
+ return null;
+ }
+
+ return MongodbFloriferousGameRepository.documentToGame(document);
+ }
+
+ async getRecent(count = 10): Promise {
+ await this.connect();
+
+ const documents = await this.connection
+ .db(this.dbName)
+ .collection(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
+ }))
+ };
+ }
+}
diff --git a/src/lib/floriferous/stub-floriferous-game-repository.ts b/src/lib/floriferous/stub-floriferous-game-repository.ts
new file mode 100644
index 0000000..a879f76
--- /dev/null
+++ b/src/lib/floriferous/stub-floriferous-game-repository.ts
@@ -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 {
+ throw new Error('Method not implemented.');
+ }
+
+ getRecent(count: number): Promise {
+ return Promise.resolve(this.games.map((game, index) => {
+ if (index < count -1) {
+ return game;
+ }
+
+ return undefined
+ }).filter((game) => game !== undefined));
+
+ }
+
+ save(game: FloriferousGame): Promise {
+ return Promise.resolve(undefined);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/simple-password-authenticator.spec.ts b/src/lib/simple-password-authenticator.spec.ts
new file mode 100644
index 0000000..d3b5a62
--- /dev/null
+++ b/src/lib/simple-password-authenticator.spec.ts
@@ -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();
+
+})
\ No newline at end of file
diff --git a/src/lib/simple-password-authenticator.ts b/src/lib/simple-password-authenticator.ts
new file mode 100644
index 0000000..0a6337a
--- /dev/null
+++ b/src/lib/simple-password-authenticator.ts
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/api/games/floriferous.json.ts b/src/routes/api/games/floriferous.json.ts
new file mode 100644
index 0000000..cfeceb8
--- /dev/null
+++ b/src/routes/api/games/floriferous.json.ts
@@ -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 => {
+ 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)
+ };
+ }
+};
diff --git a/src/routes/games/floriferous/index.svelte b/src/routes/games/floriferous/index.svelte
new file mode 100644
index 0000000..3e471de
--- /dev/null
+++ b/src/routes/games/floriferous/index.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+Floriferous Scoring
+{#if previousGames.length > 0}
+
+ Previous Games
+
+ {#each previousGames as game}
+
+ {Intl.DateTimeFormat('en-GB', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ }).format(game.playedTs)}:
+ {game.winner} Won
+
+ {/each}
+
+
+{/if}
+
+
+ Players
+
+ {#if players.length > 0}
+
+ {#each players as player}
+
+ {player.name} ({player.score} points, finished on row {player.rowAtEndOfGame}) ( onRemovePlayer(player)}>Remove )
+
+ {/each}
+
+
+ {#if players.length > 1}
+ {#if isWinnerVisible}
+ And the winner is:{game.winner}
+ Add to Ledger
+
+ (apiPassword = event.detail)} />
+
+ {#if apiPassword.length > 0}
+ You can save this game in the Ledger
+ Save Game
+ {/if}
+ {:else}
+ Show me the winner
+ {/if}
+ {/if}
+ {:else}
+ Add at least one player to get started
+ {/if}
+
+ {#if !isWinnerVisible}
+ Add a New Player
+
+ {/if}
+
+
+
diff --git a/src/styles/thomaswilson.css b/src/styles/thomaswilson.css
index 88f3e7d..91f016f 100644
--- a/src/styles/thomaswilson.css
+++ b/src/styles/thomaswilson.css
@@ -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 {
diff --git a/svelte.config.js b/svelte.config.js
index 958b7ff..a24937b 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -13,7 +13,10 @@ const config = {
})],
kit: {
- adapter: adapter({ split: false })
+ adapter: adapter({ split: false }),
+ env: {
+ publicPrefix: 'PUBLIC_'
+ }
}
};
diff --git a/vite.config.js b/vite.config.js
index 3871b65..2449d19 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -2,7 +2,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
- plugins: [sveltekit()]
+ plugins: [sveltekit()],
};
export default config;
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index b3696b2..895a233 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==