diff options
| author | Ruben Beltran del Rio <ruben@unlimited.pizza> | 2022-05-01 00:56:06 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <ruben@unlimited.pizza> | 2022-05-01 00:56:06 +0200 |
| commit | a7cf03c192470cbab13edeb1aec99e0c66dede10 (patch) | |
| tree | 581b4430d1de958dcb666bae80a7678332134602 /src/lib | |
| parent | 010f307346e525ac2e4239a0549d2c1a4d6d102b (diff) | |
Update / use typescript
Diffstat (limited to 'src/lib')
54 files changed, 3787 insertions, 0 deletions
diff --git a/src/lib/animations/blink.test.ts b/src/lib/animations/blink.test.ts new file mode 100644 index 0000000..32dc872 --- /dev/null +++ b/src/lib/animations/blink.test.ts @@ -0,0 +1,107 @@ +/** + * @jest-environment jsdom + */ + +import { blink } from './blink'; +import { sineOut } from 'svelte/easing'; + +const internals = { + response: null +}; + +describe('blink', () => { + + test('it has a default delay of 0ms', () => { + + const response = blink(document.createElement('div'), {}); + + expect(response.delay).toBe(0); + }); + + test('it allows delay to be overridden', () => { + + const response = blink(document.createElement('div'), { + delay: 300 + }); + + expect(response.delay).toBe(300); + }); + + test('it has a default duration of 400ms', () => { + + const response = blink(document.createElement('div'), {}); + + expect(response.duration).toBe(400); + }); + + test('it allows delay to be overridden', () => { + + const response = blink(document.createElement('div'), { + duration: 999 + }); + + expect(response.duration).toBe(999); + }); + + test('it uses sineOut as the default easing function', () => { + + const response = blink(document.createElement('div'), {}); + + expect(response.easing).toBe(sineOut); + }); + + test('it allows easing function to be overridden', () => { + + const response = blink(document.createElement('div'), { + easing: () => 666 + }); + + expect(response.easing(0)).toBe(666); + }); + + describe('css animation function', () => { + + beforeEach(() => { + + const div = document.createElement('div'); + div.style.width = '100px'; + div.style.height = '200px'; + internals.response = blink(div, {}); + }); + + test('It starts with with zeroed width and height', () => { + + const css = internals.response.css(0, 1); + expect(css).toContain('width: 0px'); + expect(css).toContain('height: 0px'); + }); + + test('It grows to full height and 0 width in first 20%', () => { + + const css = internals.response.css(0.2, 0.8); + expect(css).toContain('width: 0px'); + expect(css).toContain('height: 200px'); + }); + + test('It expands to full height by the end', () => { + + const css = internals.response.css(1, 0); + expect(css).toContain('width: 100px'); + expect(css).toContain('height: 200px'); + }); + + test('It keeps element vertically centered by adjusting the margin', () => { + + const css = internals.response.css(0.1, 0.9); + expect(css).toContain('margin: 50px 50px'); + expect(css).toContain('height: 100px'); + }); + + test('It keeps element horizontally centered by adjusting the margin', () => { + + const css = internals.response.css(0.6, 0.4); + expect(css).toContain('margin: 0px 25px'); + expect(css).toContain('width: 50px'); + }); + }); +}); diff --git a/src/lib/animations/blink.ts b/src/lib/animations/blink.ts new file mode 100644 index 0000000..c291b06 --- /dev/null +++ b/src/lib/animations/blink.ts @@ -0,0 +1,25 @@ +import { sineOut } from 'svelte/easing'; +import type { AnimationConfig } from 'svelte/animate'; + +export const blink = function blink(node: HTMLElement, params: AnimationConfig): AnimationConfig{ + + const originalWidth = parseFloat(getComputedStyle(node).width); + const originalHeight = parseFloat(getComputedStyle(node).height); + + return { + delay: params.delay || 0, + duration: params.duration || 400, + easing: params.easing || sineOut, + css: (t: number): string => { + + const halfWidth = originalWidth / 2; + const halfHeight = originalHeight / 2; + const height = Math.round(t <= 0.2 ? (originalHeight * t) / 0.2 : originalHeight); + const marginY = Math.round(t <= 0.2 ? halfHeight * (1 - t / 0.2) : 0); + const width = Math.round(t > 0.2 ? ((t - 0.2) / 0.8) * originalWidth : 0); + const marginX = Math.round(t > 0.2 ? (1 - (t - 0.2) / 0.8) * halfWidth : halfWidth); + + return `width: ${width}px; height: ${height}px; margin: ${marginY}px ${marginX}px`; + } + }; +}; diff --git a/src/lib/components/actions/topic.svelte b/src/lib/components/actions/topic.svelte new file mode 100644 index 0000000..1311d06 --- /dev/null +++ b/src/lib/components/actions/topic.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { TopicAction } from '$lib/stores/action'; + export let actions: TopicAction; + + import { _ } from 'svelte-i18n'; +</script> + +<li> + <a href="/reply/{actions.id}" title={$_('header.action.reply.title')}> + {@html $_('header.action.reply.display')} + </a> +</li> + +<style> + li { + display: inline; + margin: 5px; + } + + a { + text-decoration: none; + line-height: 3em; + display: inline-block; + } +</style> diff --git a/src/lib/components/actions/topic.test.ts b/src/lib/components/actions/topic.test.ts new file mode 100644 index 0000000..0037a7f --- /dev/null +++ b/src/lib/components/actions/topic.test.ts @@ -0,0 +1,29 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Topic from './topic.svelte'; + +const internals = { + results: null +}; + +describe('Topic Actions component', () => { + + test('Should link to reply page', () => { + + const results = render(Topic, { props: { + actions: { + id: '8ebaa211-fd9b-423a-8b4f-b57622007fde' + } + } }); + + expect(results.getByTitle('Reply').closest('a')) + .toHaveAttribute('href', '/reply/8ebaa211-fd9b-423a-8b4f-b57622007fde'); + }); +}); diff --git a/src/lib/components/author/author.svelte b/src/lib/components/author/author.svelte new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/lib/components/author/author.svelte diff --git a/src/lib/components/error_block/error_block.svelte b/src/lib/components/error_block/error_block.svelte new file mode 100644 index 0000000..d25f507 --- /dev/null +++ b/src/lib/components/error_block/error_block.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + export let message = null; + + import { blink } from '$lib/animations/blink'; +</script> + +<div transition:blink> + <h2>{$_('error.generic.title')}</h2> + <p>{message || $_('error.generic.message')}</p> +</div> + +<style> + div { + background: repeating-linear-gradient(-45deg, red, red 5px, black 5px, black 10px); + border: 5px solid red; + color: yellow; + font-family: 'ヒラギノ角ゴ ProN', 'Hiragino Kaku Gothic ProN', '游ゴシック', '游ゴシック体', + YuGothic, 'Yu Gothic', 'メイリオ', Meiryo, 'MS ゴシック', 'MS Gothic', HiraKakuProN-W3, + 'TakaoExゴシック', TakaoExGothic, 'MotoyaLCedar', 'Droid Sans Japanese', sans-serif; + margin: 0 10px 0 0; + text-align: center; + overflow: hidden; + } + + h2, + p { + background-color: black; + font-size: 1em; + } + + h2 { + text-transform: uppercase; + margin: 100px 5px 10px; + } + + p { + margin: 10px 5px 100px; + } +</style> diff --git a/src/lib/components/error_block/error_block.test.ts b/src/lib/components/error_block/error_block.test.ts new file mode 100644 index 0000000..d2ac458 --- /dev/null +++ b/src/lib/components/error_block/error_block.test.ts @@ -0,0 +1,32 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import ErrorBlock from './error_block.svelte'; + +describe('Error Block component', () => { + + test('Should display error message sent', () => { + + const results = render(ErrorBlock, { props: { + message: 'An error has, sadly, destroyed everything.' + } }); + + expect(results.getByText('An error has, sadly, destroyed everything.')) + .toBeVisible(); + }); + + test('Should display default error message', () => { + + const results = render(ErrorBlock); + + expect(results.getByText('Unknown error has occurred. Panic!')) + .toBeVisible(); + }); +}); + diff --git a/src/lib/components/footer/footer.svelte b/src/lib/components/footer/footer.svelte new file mode 100644 index 0000000..5c171d2 --- /dev/null +++ b/src/lib/components/footer/footer.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + + import LanguageSelector from '$lib/components/language_selector/language_selector.svelte'; + + const licenseUrl = 'https://gitlab.com/rbdr/forum/'; +</script> + +<footer title={$_('footer.title')}> + <ul> + <li>{@html $_('footer.license', { values: { licenseUrl } })}</li> + <li>{$_('footer.choose_language')}: <LanguageSelector /></li> + </ul> +</footer> + +<style> + footer { + grid-column: col-start 1 / span 12; + border-top: 1px solid black; + } + + ul { + padding: 0; + } + + li { + display: inline; + margin: 5px; + } +</style> diff --git a/src/lib/components/forum/forum.svelte b/src/lib/components/forum/forum.svelte new file mode 100644 index 0000000..b886428 --- /dev/null +++ b/src/lib/components/forum/forum.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + export let forum; + + import { _ } from 'svelte-i18n'; + import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte'; +</script> + +<h1>{forum.glyph} {$_(forum.label)}</h1> +<ul> + {#each forum.topics as topic} + <TopicSummary {topic} /> + {/each} +</ul> diff --git a/src/lib/components/forum/forum.test.ts b/src/lib/components/forum/forum.test.ts new file mode 100644 index 0000000..b3ce167 --- /dev/null +++ b/src/lib/components/forum/forum.test.ts @@ -0,0 +1,73 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { addMessages } from 'svelte-i18n'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Forum from './forum.svelte'; + +const internals = { + results: null +}; + +describe('Forum component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.oleo': 'Oleo' + }); + }); + + beforeEach(() => { + + internals.results = render(Forum, { props: { + forum: { + id: 'oleo', + glyph: '☽', + label: 'test_forums.oleo', + topics: [ + { + id: '0575d375-5bea-44df-a597-bee3adda624d', + title: 'Very forumy topic', + ttl: 160 * 1000, + updated_at: Date.now() + }, + { + id: 'aeeb56e4-751d-4400-8aa7-d0f3a20d4e25', + title: 'Only mildly forum-like', + ttl: 160 * 1000, + updated_at: Date.now() + } + ] + } + } }); + }); + + test('It should display the forum glyph and label', () => { + + expect(internals.results.getByText(/^\s*☽\s*Oleo\s*$/)) + .toBeVisible(); + }); + + test('It should display the topics', () => { + + expect(internals.results.getByText('Very forumy topic')) + .toBeVisible(); + expect(internals.results.getByText('Only mildly forum-like')) + .toBeVisible(); + }); + + test('It should link to the topics', () => { + + expect(internals.results.getByText('Very forumy topic').closest('a')) + .toHaveAttribute('href', '/t/0575d375-5bea-44df-a597-bee3adda624d'); + expect(internals.results.getByText('Only mildly forum-like').closest('a')) + .toHaveAttribute('href', '/t/aeeb56e4-751d-4400-8aa7-d0f3a20d4e25'); + }); +}); diff --git a/src/lib/components/forum_list/forum_list.svelte b/src/lib/components/forum_list/forum_list.svelte new file mode 100644 index 0000000..3529de8 --- /dev/null +++ b/src/lib/components/forum_list/forum_list.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + export let forums; + + $: sortedForums = forums.slice().sort((a, b) => a.position - b.position); +</script> + +<ul> + {#each sortedForums as forum} + <li> + <a href="/f/{forum.id}"> + <span aria-hidden="true" class="navigation-glyph {forum.glyph}">{forum.glyph}</span> + <span class="navigation-label">{$_(forum.label)}</span> + </a> + </li> + {/each} +</ul> + +<style> + ul { + padding: 0; + } + + li { + display: block; + text-align: left; + margin-bottom: 20px; + } + + .navigation-glyph { + font-size: 1.5rem; + display: block; + } + + .☽ { + font-size: 2rem; + } + + a { + text-decoration: none; + } +</style> diff --git a/src/lib/components/forum_list/forum_list.test.ts b/src/lib/components/forum_list/forum_list.test.ts new file mode 100644 index 0000000..be7d449 --- /dev/null +++ b/src/lib/components/forum_list/forum_list.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import { addMessages } from 'svelte-i18n'; + +import ForumList from './forum_list.svelte'; + +const internals = { + results: null +}; + +describe('Forum List component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.yes': 'Absolutely yes', + 'test_forums.no': 'No, not at all', + 'test_forums.maybe': 'OK, maybe...' + }); + }); + + beforeEach(() => { + + internals.results = render(ForumList, { props: { + forums: [ + { + id: 'yes', + glyph: '☆', + label: 'test_forums.yes', + position: 2 + }, + { + id: 'no', + glyph: '◯', + label: 'test_forums.no', + position: 0 + }, + { + id: 'maybe', + glyph: '⏀', + label: 'test_forums.maybe', + position: 1 + } + ] + } }); + }); + + test('It should display each forum according to their position', () => { + + expect(internals.results.container) + .toHaveTextContent(/^◯.+⏀.+☆.+$/); + }); + + test('It should translate forum labels', () => { + + expect(internals.results.getByText('Absolutely yes')).toBeVisible(); + expect(internals.results.getByText('No, not at all')).toBeVisible(); + expect(internals.results.getByText('OK, maybe...')).toBeVisible(); + }); + + test('It should display forum glyphs', () => { + + expect(internals.results.getByText('☆')).toBeVisible(); + expect(internals.results.getByText('◯')).toBeVisible(); + expect(internals.results.getByText('⏀')).toBeVisible(); + }); + + test('Label should be a permalink to the forum', () => { + + expect(internals.results.getByText('Absolutely yes').closest('a')) + .toHaveAttribute('href', '/f/yes'); + expect(internals.results.getByText('No, not at all').closest('a')) + .toHaveAttribute('href', '/f/no'); + expect(internals.results.getByText('OK, maybe...').closest('a')) + .toHaveAttribute('href', '/f/maybe'); + }); + + test('Glyph should be a permalink to the forum', () => { + + expect(internals.results.getByText('☆').closest('a')) + .toHaveAttribute('href', '/f/yes'); + expect(internals.results.getByText('◯').closest('a')) + .toHaveAttribute('href', '/f/no'); + expect(internals.results.getByText('⏀').closest('a')) + .toHaveAttribute('href', '/f/maybe'); + }); +}); diff --git a/src/lib/components/glyph/glyph.svelte b/src/lib/components/glyph/glyph.svelte new file mode 100644 index 0000000..0559b98 --- /dev/null +++ b/src/lib/components/glyph/glyph.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + import { getGlyphHash } from '$lib/utils/glyph_hash'; + + export let uuid: string; +</script> + +<div class="glyphicon" role="img" aria-label={$_('glyph.title')} title={$_('glyph.title')}> + {#each getGlyphHash(uuid) as fragment} + <span class={fragment.glyph} style="color: {fragment.color} "> + {fragment.glyph} + </span> + {/each} +</div> + +<style> + .glyphicon { + border: 1px solid black; + display: inline-block; + font-size: 1.4rem; + height: 48px; + margin-top: 5px; + width: 48px; + background-color: white; + padding: 2px; + } + + span { + display: block; + float: left; + width: 24px; + height: 24px; + text-align: center; + line-height: 24px; + } + + .☽ { + font-size: 2rem; + line-height: 19px; + } +</style> diff --git a/src/lib/components/glyph/glyph.test.ts b/src/lib/components/glyph/glyph.test.ts new file mode 100644 index 0000000..778353f --- /dev/null +++ b/src/lib/components/glyph/glyph.test.ts @@ -0,0 +1,36 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Glyph from './glyph.svelte'; + +const internals = { + results: null +}; + +describe('Glyph component', () => { + + beforeEach(() => { + + internals.results = render(Glyph, { props: { + uuid: '9fb14ebc-bc64-400b-915f-d429ec44b8fe' + } }); + }); + + test('Should act as an image', () => { + + expect(internals.results.getByRole('img')) + .toBeVisible(); + }); + + test('Should render 4 glyphs', () => { + + expect(internals.results.getByRole('img')) + .toHaveTextContent(/^. . . .$/); + }); +}); diff --git a/src/lib/components/header/header.svelte b/src/lib/components/header/header.svelte new file mode 100644 index 0000000..e6c9348 --- /dev/null +++ b/src/lib/components/header/header.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; + import { version } from '$lib/config/config'; + import { topicActions } from '$lib/stores/actions'; + import TopicActions from '$lib/components/actions/topic.svelte'; +</script> + +<header title={$_('header.title')}> + <ul> + <li> + <strong + ><a href="/" title={$_('header.long_version', { values: { version } })} + >{$_('header.short_version', { values: { version } })}</a + ></strong + > + </li> + <li> + <a href="/new" title={$_('header.action.new.title')} + >{@html $_('header.action.new.display')}</a + > + </li> + {#if $topicActions} + <TopicActions actions={$topicActions} /> + {/if} + <li> + <a href="/search" title={$_('header.action.search.title')} + >{@html $_('header.action.search.display')}</a + > + </li> + <li> + <a href="/logout" title={$_('header.action.log_out.title')} + >{@html $_('header.action.log_out.display')}</a + > + </li> + </ul> +</header> + +<style> + header { + grid-column: col-start 1 / span 12; + border-bottom: 1px solid black; + } + + ul { + padding: 0; + } + + li { + display: inline; + margin: 5px; + } + + a { + text-decoration: none; + line-height: 3em; + display: inline-block; + } + + strong a { + color: blue; + text-decoration: underline; + } +</style> diff --git a/src/lib/components/header/header.test.ts b/src/lib/components/header/header.test.ts new file mode 100644 index 0000000..4107b2d --- /dev/null +++ b/src/lib/components/header/header.test.ts @@ -0,0 +1,33 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; +import { enableTopicActions } from '$lib/stores/actions'; + +jest.mock('$lib/config/config.ts'); + +import Header from './header.svelte'; + +describe('Header component', () => { + + test('Should not display topic if action is not set', () => { + + const results = render(Header); + + expect(results.queryByTitle('Reply')) + .toBe(null); + }); + + test('Should display topic if action is set', () => { + + enableTopicActions('d138d6d8-e669-42e7-995d-20a7fcc176f5'); + const results = render(Header); + + expect(results.getByTitle('Reply')) + .toBeVisible(); + }); +}); diff --git a/src/lib/components/home/home.svelte b/src/lib/components/home/home.svelte new file mode 100644 index 0000000..9473bbb --- /dev/null +++ b/src/lib/components/home/home.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<h1>{$_('home.title')}</h1> +<p>{$_('home.content')}</p> diff --git a/src/lib/components/invalid_route/invalid_route.svelte b/src/lib/components/invalid_route/invalid_route.svelte new file mode 100644 index 0000000..a75316d --- /dev/null +++ b/src/lib/components/invalid_route/invalid_route.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<h1>{$_('error.invalid_url.title')}</h1> +<p>{$_('error.invalid_url.message')}</p> diff --git a/src/lib/components/language_selector/language_selector.svelte b/src/lib/components/language_selector/language_selector.svelte new file mode 100644 index 0000000..64a4ef9 --- /dev/null +++ b/src/lib/components/language_selector/language_selector.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { locale, locales } from 'svelte-i18n'; + import { getLangNameFromCode } from 'language-name-map'; + + $: namedLocales = $locales + .map((code) => ({ + code, + ...getLangNameFromCode(code) + })) + .sort((a, b) => a.native - b.native); + + let selected = $locale + + $: { + locale.set(selected); + } +</script> + +<select bind:value={selected}> + {#each namedLocales as namedLocale} + <option value={namedLocale.code}>{namedLocale.native}</option> + {/each} +</select> + +<style> +</style> diff --git a/src/lib/components/language_selector/language_selector.test.ts b/src/lib/components/language_selector/language_selector.test.ts new file mode 100644 index 0000000..74156bc --- /dev/null +++ b/src/lib/components/language_selector/language_selector.test.ts @@ -0,0 +1,48 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { locale } from 'svelte-i18n'; +import { act, render } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import '$lib/i18n'; + +import LanguageSelector from './language_selector.svelte'; + +const internals = { + results: null +}; + +describe('Language Selector component', () => { + + beforeEach(() => { + + internals.results = render(LanguageSelector); + }); + + test('Should display languages in their own language', () => { + + expect(internals.results.getByText('English')) + .toBeVisible(); + expect(internals.results.getByText('Español')) + .toBeVisible(); + }); + + test('Should change locale when a language is selected', async () => { + + locale.subscribe((localeValue) => { + + expect(localeValue).toBe('en'); + })(); + const spanish = internals.results.getByText('Español'); + await userEvent.selectOptions(spanish.closest('select'), spanish); + await act(); + locale.subscribe((localeValue) => { + + expect(localeValue).toBe('es'); + })(); + }); +}); + diff --git a/src/lib/components/loader/loader.svelte b/src/lib/components/loader/loader.svelte new file mode 100644 index 0000000..dad0957 --- /dev/null +++ b/src/lib/components/loader/loader.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import { _ } from 'svelte-i18n'; +</script> + +<p>{$_('loader.message')}</p> diff --git a/src/lib/components/post/post.svelte b/src/lib/components/post/post.svelte new file mode 100644 index 0000000..4e6c28f --- /dev/null +++ b/src/lib/components/post/post.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + export let post; + export let index = 0; + export let count = 1; + + import { _ } from 'svelte-i18n'; + import Glyph from '$lib/components/glyph/glyph.svelte'; + + const timestampToISO = (timestamp) => new Date(timestamp).toISOString(); +</script> + +<aside + class="post-meta" + title={$_('post.metadata_title', { values: { count: index + 1, total: count } })} +> + <Glyph uuid={post.author.id} /> + <span class="h-card"> + {$_('post.author_credit')} + <a href="/a/{post.author.handle}" class="p-nickname u-url">{post.author.handle}</a>. + </span> + <time role="presentation" class="dt-published" datetime={timestampToISO(post.created_at)}> + <a title={$_('post.permalink_title')} href="/p/{post.id}"> + {timestampToISO(post.created_at)} + </a> + </time> + {#if post.topic} + <span> + ({$_('post.topic_location')} <a href="/t/{post.topic.id}">{post.topic.title}</a>.) + </span> + {/if} +</aside> +<article + class="e-content" + title={$_('post.title', { + values: { count: index + 1, total: count, author: post.author.handle } + })} +> + {post.text} +</article> +<hr /> + +<style> + .post-meta * { + vertical-align: top; + } + + article { + white-space: pre; + } +</style> diff --git a/src/lib/components/post/post.test.ts b/src/lib/components/post/post.test.ts new file mode 100644 index 0000000..d43583e --- /dev/null +++ b/src/lib/components/post/post.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { cleanup, render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Post from './post.svelte'; + +const internals = { + basicPost: { + id: 'e5a19d53-4c9a-4be8-afa5-00942ea3afa4', + text: 'This is an example post qwerty', + created_at: Date.UTC(2021, 3, 19, 6, 6, 6, 666).valueOf(), + author: { + handle: 'very_cool_user', + id: 'b01bdb48-4b5e-46a4-97f3-6db789bcd33b' + }, + topic: { + id: '35d3c3eb-e486-42ef-994c-d8ab1f1e167a', + title: 'Parent topic, yes' + } + }, + postWithoutTopic: { + id: '9e52e38e-9007-4a20-bbf1-cea4e2f950f3', + text: 'This is a post without a topic', + created_at: Date.UTC(2022, 8, 21, 4, 3, 1, 340).valueOf(), + author: { + handle: 'my_normal_user', + id: '121f8f97-de02-4102-b25d-f34fd619009b' + } + }, + + results: null +}; + +describe('Post component', () => { + + beforeEach(() => { + + internals.results = render(Post, { props: { + post: internals.basicPost + } }); + }); + + test('Should display the text of the post', () => { + + expect(internals.results.getByText('This is an example post qwerty')).toBeVisible(); + }); + + test('Should display date of the post', () => { + + expect(internals.results.getByText('2021-04-19T06:06:06.666Z')) + .toBeVisible(); + }); + + test('Date of post should be a permalink to the post', () => { + + expect(internals.results.getByText('2021-04-19T06:06:06.666Z').closest('a')) + .toHaveAttribute('href', '/p/e5a19d53-4c9a-4be8-afa5-00942ea3afa4'); + }); + + test('Should display the glyph of the post author', () => { + + const glyphicon = internals.results.getByRole('img'); + + expect(glyphicon) + .toBeVisible(); + expect(glyphicon) + .toHaveTextContent(/^. . . .$/); + }); + + test('Should display author handle', () => { + + expect(internals.results.getByText('very_cool_user')) + .toBeVisible(); + }); + + test('Author handle should have a permalink to topic', () => { + + expect(internals.results.getByText('very_cool_user').closest('a')) + .toHaveAttribute('href', '/a/very_cool_user'); + }); + + test('Should display parent topic title', () => { + + expect(internals.results.getByText('Parent topic, yes')) + .toBeVisible(); + }); + + test('Parent topic title should have a permalink to topic', () => { + + expect(internals.results.getByText('Parent topic, yes').closest('a')) + .toHaveAttribute('href', '/t/35d3c3eb-e486-42ef-994c-d8ab1f1e167a'); + }); + + test('Parent topic title should have a permalink to topic', () => { + + cleanup(); + internals.results = render(Post, { props: { + post: internals.postWithoutTopic + } }); + + expect(internals.results.queryByText('Parent topic, yes')) + .toBe(null); + }); + + test('It should default to 1/1 when no index or count is passed', () => { + + expect(internals.results.getByTitle('Post 1 of 1 by very_cool_user')) + .toBeVisible(); + }); + + test('Parent topic title should have a permalink to topic', () => { + + cleanup(); + internals.results = render(Post, { props: { + index: 2, + count: 5, + post: internals.postWithoutTopic + } }); + + expect(internals.results.getByTitle('Post 3 of 5 by my_normal_user')) + .toBeVisible(); + }); +}); diff --git a/src/lib/components/tag/tag.svelte b/src/lib/components/tag/tag.svelte new file mode 100644 index 0000000..86d0b82 --- /dev/null +++ b/src/lib/components/tag/tag.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + export let tag; + + import { _ } from 'svelte-i18n'; + import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte'; +</script> + +<h1>{$_('tag.title')}: {tag.id}</h1> +<ul> + {#each tag.topics as topic} + <TopicSummary {topic} /> + {/each} +</ul> diff --git a/src/lib/components/tag/tag.test.ts b/src/lib/components/tag/tag.test.ts new file mode 100644 index 0000000..76304e6 --- /dev/null +++ b/src/lib/components/tag/tag.test.ts @@ -0,0 +1,62 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Tag from './tag.svelte'; + +const internals = { + results: null +}; + +describe('Tag component', () => { + + beforeEach(() => { + + internals.results = render(Tag, { props: { + tag: { + id: 'avocado', + topics: [ + { + id: 'eb751e7a-5777-46c3-b81b-cc66546d5157', + title: 'A single topic', + ttl: 160 * 1000, + updated_at: Date.now() + }, + { + id: 'b4a5613c-237b-4147-a867-9c105d51e365', + title: 'And its companion', + ttl: 160 * 1000, + updated_at: Date.now() + } + ] + } + } }); + }); + + test('It should display the tag title', () => { + + expect(internals.results.getByText('Tag: avocado')) + .toBeVisible(); + }); + + test('It should display the topics', () => { + + expect(internals.results.getByText('A single topic')) + .toBeVisible(); + expect(internals.results.getByText('And its companion')) + .toBeVisible(); + }); + + test('It should link to the topics', () => { + + expect(internals.results.getByText('A single topic').closest('a')) + .toHaveAttribute('href', '/t/eb751e7a-5777-46c3-b81b-cc66546d5157'); + expect(internals.results.getByText('And its companion').closest('a')) + .toHaveAttribute('href', '/t/b4a5613c-237b-4147-a867-9c105d51e365'); + }); +}); diff --git a/src/lib/components/topic/topic.svelte b/src/lib/components/topic/topic.svelte new file mode 100644 index 0000000..06f2aeb --- /dev/null +++ b/src/lib/components/topic/topic.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + export let topic; + + import { _ } from 'svelte-i18n'; + import Post from '$lib/components/post/post.svelte'; + import { readableTime } from '$lib/utils/readable_time'; + + $: remainingTime = topic.updated_at + topic.ttl - Date.now(); + $: remaining = readableTime(remainingTime); +</script> + +<div class="h-entry" title={$_('topic.title')}> + <h1 class="p-name">{topic.title}</h1> + <aside class="topic-meta" title={$_('topic.metadata_title')}> + {#if topic.forum} + <span class="topic-location"> + {$_('topic.category_location')} + <a href="/f/{topic.forum.id}" class="p-category"> + {topic.forum.glyph} {$_(topic.forum.label)} + </a>. + </span> + {/if} + <span class="topic-ttl"> + <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}"> + ({$_('topic.remaining_time', { + values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) } + })}) + </a>. + </span> + </aside> + {#if topic.tags.length > 0} + <aside class="topic-tags" title={$_('topic.tags_title')}> + {$_('topic.tags_location')} + {#each topic.tags as tag} + <a href="/g/{tag.id}" class="p-category"> + {tag.id}<span class="tag-weight">({tag.weight})</span> + </a>{' '} + {/each} + </aside> + {/if} + {#each topic.posts as post, index} + <Post {post} {index} count={topic.posts.length} /> + {/each} +</div> diff --git a/src/lib/components/topic/topic.test.ts b/src/lib/components/topic/topic.test.ts new file mode 100644 index 0000000..985ca72 --- /dev/null +++ b/src/lib/components/topic/topic.test.ts @@ -0,0 +1,168 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { addMessages } from 'svelte-i18n'; + +import { cleanup, render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import Topic from './topic.svelte'; + +const internals = { + results: null, + basicTopic: { + id: 'b1a4f8d1-4d16-4872-b391-fda6a0e9012d', + title: 'I sure am a test topic', + ttl: 160 * 1000, + updated_at: Date.now(), + forum: { + id: 'diversion', + glyph: '⏃', + label: 'test_forums.diversion' + }, + tags: [ + { + id: 'fish', + weight: 40 + }, + { + id: 'statue', + weight: 5 + } + ], + posts: [ + { + id: '413a74db-9473-4bac-8698-da9452c05854', + text: 'This is the first post', + created_at: Date.UTC(1999, 7, 1, 8, 8, 2, 111).valueOf(), + author: { + handle: 'past_user', + id: 'c76d3e51-76ac-4e84-a1b2-2eee9abd68b3' + } + }, + { + id: '821ff177-5250-406f-9431-1a8097b35430', + text: 'This response came later', + created_at: Date.UTC(2038, 1, 2, 3, 4, 6, 789).valueOf(), + author: { + handle: 'future_user', + id: 'cb9307cb-77e9-4c55-bbe7-dbbf88737358' + } + } + ] + }, + topicWithoutForum: { + id: '9715e9ee-0d63-4b50-b613-826ef2791728', + title: 'This topic, no forums', + ttl: 160 * 1000, + updated_at: Date.now(), + tags: [ + { + id: 'cauliflower', + weight: 33 + } + ], + posts: [] + } +}; + +describe('Topic component', () => { + + beforeAll(() => { + + addMessages('en', { + 'test_forums.diversion': 'Diversion' + }); + }); + + beforeEach(() => { + + internals.results = render(Topic, { props: { + topic: internals.basicTopic + } }); + }); + + test('Should show the topic title', () => { + + expect(internals.results.getByText('I sure am a test topic')) + .toBeVisible(); + }); + test('Should display remaining time in readable format', () => { + + expect(internals.results.getByText(/2 minutes remaining/)) + .toBeVisible(); + }); + test('Remaining time should be a permalink to the topic', () => { + + expect(internals.results.getByText(/2 minutes remaining/).closest('a')) + .toHaveAttribute('href', '/t/b1a4f8d1-4d16-4872-b391-fda6a0e9012d'); + }); + + test('Should show text for all posts', () => { + + expect(internals.results.getByText('This is the first post')) + .toBeVisible(); + expect(internals.results.getByText('This response came later')) + .toBeVisible(); + }); + + test('Should send index and count to posts', () => { + + expect(internals.results.getByTitle('Post 1 of 2 by past_user')) + .toBeVisible(); + expect(internals.results.getByTitle('Post 2 of 2 by future_user')) + .toBeVisible(); + }); + + describe('Forum link', () => { + + test('Should show forum if the post has one', () => { + + expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/)) + .toBeVisible(); + }); + + test('Forum text should be a permalink to the forum', () => { + + expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/).closest('a')) + .toHaveAttribute('href', '/f/diversion'); + }); + + test('Should not show forum if the post doesn\'t have one', () => { + + cleanup(); + internals.results = render(Topic, { props: { + topic: internals.topicWithoutForum + } }); + + expect(internals.results.queryByText(/^\s*⏃\s*Diversion\s*$/)) + .toBe(null); + }); + }); + + describe('Tag listing', () => { + + test('Should show topic tags', () => { + + expect(internals.results.getByText('fish')) + .toBeVisible(); + expect(internals.results.getByText('fish')) + .toHaveTextContent('fish(40)'); + expect(internals.results.getByText('statue')) + .toBeVisible(); + expect(internals.results.getByText('statue')) + .toHaveTextContent('statue(5)'); + }); + + test('Tag text should be a permalink to the tag', () => { + + expect(internals.results.getByText('fish').closest('a')) + .toHaveAttribute('href', '/g/fish'); + expect(internals.results.getByText('statue').closest('a')) + .toHaveAttribute('href', '/g/statue'); + }); + }); +}); diff --git a/src/lib/components/topic_summary/topic_summary.svelte b/src/lib/components/topic_summary/topic_summary.svelte new file mode 100644 index 0000000..bfcf8ab --- /dev/null +++ b/src/lib/components/topic_summary/topic_summary.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + export let topic; + + import { _ } from 'svelte-i18n'; + import { readableTime } from '$lib/utils/readable_time'; + + $: remainingTime = topic.updated_at + topic.ttl - Date.now(); + $: remaining = readableTime(remainingTime); +</script> + +<li class="h-entry" title={$_('topic.title')}> + <span class="p-name"> + <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}"> + {topic.title} + </a></span> + <span class="topic-ttl">({$_('topic.remaining_time', { + values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) } + })}) + </span> +</li> + +<style> +</style> diff --git a/src/lib/components/topic_summary/topic_summary.test.ts b/src/lib/components/topic_summary/topic_summary.test.ts new file mode 100644 index 0000000..a4c46bf --- /dev/null +++ b/src/lib/components/topic_summary/topic_summary.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ + +import '@testing-library/jest-dom/extend-expect'; + +import { render } from '@testing-library/svelte'; +import '$lib/i18n'; + +import TopicSummary from './topic_summary.svelte'; + +const internals = { + results: null +}; + +describe('Topic Summary component', () => { + + beforeEach(() => { + + internals.results = render(TopicSummary, { props: { + topic: { + id: 'ea2431c8-5c1c-4ed0-907a-45e012696ab8', + title: 'I sure am a test topic', + ttl: 160 * 1000, + updated_at: Date.now() + } + } }); + }); + + test('It should display the title', () => { + + expect(internals.results.getByText('I sure am a test topic')) + .toBeVisible(); + }); + + test('Topic title should be a permalink', () => { + + expect(internals.results.getByText('I sure am a test topic').closest('a')) + .toHaveAttribute('href', '/t/ea2431c8-5c1c-4ed0-907a-45e012696ab8'); + }); + + test('It should display remaining time in readable format', () => { + + expect(internals.results.getByText(/2 minutes remaining/)) + .toBeVisible(); + }); +}); diff --git a/src/lib/config/__mocks__/config.ts b/src/lib/config/__mocks__/config.ts new file mode 100644 index 0000000..448e1db --- /dev/null +++ b/src/lib/config/__mocks__/config.ts @@ -0,0 +1,15 @@ +export const apollo = { + uri: 'http://127.0.0.1:1234/graphql', + name: 'COOL_APP', + version: '9.9.9', + defaultOptions: { + watchQuery: { + fetchPolicy: 'no-cache' + }, + query: { + fetchPolicy: 'no-cache' + } + } +}; + +export const version = '9.9.9'; diff --git a/src/lib/config/apollo.ts b/src/lib/config/apollo.ts new file mode 100644 index 0000000..a3820ed --- /dev/null +++ b/src/lib/config/apollo.ts @@ -0,0 +1,14 @@ +import fetch from 'cross-fetch'; +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core'; +import { apollo as apolloConfig } from './config'; + +const cache = new InMemoryCache(); + +export const client = new ApolloClient({ + cache, + link: new HttpLink({ + uri: apolloConfig.uri, + fetch + }), + ...apolloConfig +}); diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts new file mode 100644 index 0000000..1ec5ab2 --- /dev/null +++ b/src/lib/config/config.ts @@ -0,0 +1,17 @@ +const internals = { + version: '1.0.0' +}; + +/* + * The main configuration object for the Forum frontend. These values + * are calculated during compile time and need to be set in a .env + * file, otherwise it won't work. + */ + +export const apollo = { + uri: import.meta.env.VITE_APOLLO_SERVER, + name: 'forum', + version: internals.version +}; + +export const version = internals.version; diff --git a/src/lib/config/env.dist b/src/lib/config/env.dist new file mode 100644 index 0000000..1f3ff0c --- /dev/null +++ b/src/lib/config/env.dist @@ -0,0 +1 @@ +VITE_APOLLO_SERVER=http://location_of_apollo_server diff --git a/src/lib/data/queries.ts b/src/lib/data/queries.ts new file mode 100644 index 0000000..7364c0f --- /dev/null +++ b/src/lib/data/queries.ts @@ -0,0 +1,90 @@ +import { gql } from '@apollo/client/core'; + +export const GET_FORUMS = gql` + query GetForums { + forums { + id + glyph + label + position + } + } +`; + +export const GET_FORUM = gql` + query GetForum($id: ID!) { + forum(id: $id) { + id + glyph + label + position + topics { + id + title + updated_at + ttl + } + } + } +`; + +export const GET_TAG = gql` + query GetTag($id: ID!) { + tag(id: $id) { + id + topics { + id + title + updated_at + ttl + } + } + } +`; + +export const GET_TOPIC = gql` + query GetTopic($id: ID!) { + topic(id: $id) { + id + title + updated_at + ttl + forum { + id + glyph + label + } + tags { + id + weight + } + posts { + id + text + created_at + author { + id + handle + } + } + } + } +`; + +export const GET_POST = gql` + query GetPost($id: ID!) { + post(id: $id) { + id + text + created_at + author { + id + handle + } + topic { + id + title + } + } + } +`; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..5dd533a --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,12 @@ +import { addMessages, getLocaleFromNavigator, init } from 'svelte-i18n'; + +import en from './translations/en.json'; +import es from './translations/es.json'; + +addMessages('en', en); +addMessages('es', es); + +init({ + fallbackLocale: 'en', + initialLocale: getLocaleFromNavigator().replace(/-[A-Z]{2}$/, '') +}); diff --git a/src/lib/stores/actions.test.ts b/src/lib/stores/actions.test.ts new file mode 100644 index 0000000..c650536 --- /dev/null +++ b/src/lib/stores/actions.test.ts @@ -0,0 +1,32 @@ +import { enableTopicActions, disableTopicActions, topicActions } from './actions'; + +describe('Topic actions and state', () => { + + test('There should be no topic actions by default', () => { + + topicActions.subscribe((actions) => { + + expect(actions).toBe(undefined); + })(); + }); + + test('enableTopicActions should set the topic id', () => { + + enableTopicActions('free_hat'); + topicActions.subscribe((actions) => { + + expect(actions).toEqual({ + id: 'free_hat' + }); + })(); + }); + + test('disableTopicActions should unset the topic id', () => { + + disableTopicActions(); + topicActions.subscribe((actions) => { + + expect(actions).toEqual(undefined); + })(); + }); +}); diff --git a/src/lib/stores/actions.ts b/src/lib/stores/actions.ts new file mode 100644 index 0000000..95702dc --- /dev/null +++ b/src/lib/stores/actions.ts @@ -0,0 +1,41 @@ +import { derived, writable } from 'svelte/store'; +import type { Readable, Writable } from 'svelte/store'; + +export type Actions = { + topic?: TopicAction +}; + +export type TopicAction = { + id: string +}; + +/* + * This is a store to set the actions in the top header. + */ + +const actions: Writable<Actions> = writable({}); + +export const enableTopicActions = (id: string) => { + + actions.update((actionsValue: Actions): Actions => { + + actionsValue.topic = { + id + }; + return actionsValue; + }); +}; + +export const disableTopicActions = () => { + + actions.update((actionsValue): Actions => { + + delete actionsValue.topic; + return actionsValue; + }); +}; + +export const topicActions: Readable<TopicAction> = derived( + actions, + ($actions) => $actions.topic +); diff --git a/src/lib/stores/apollo.ts b/src/lib/stores/apollo.ts new file mode 100644 index 0000000..12463c3 --- /dev/null +++ b/src/lib/stores/apollo.ts @@ -0,0 +1,64 @@ +import { ApolloError } from '@apollo/client/core'; +import { readable } from 'svelte/store'; +import { client } from '$lib/config/apollo'; +import type { DocumentNode, ApolloQueryResult } from '@apollo/client/core'; + +import type { Readable } from 'svelte/store'; + +/* + * This is a generic store for use with apollo + */ + +type ApolloStoreConfiguration<Type> = { + key: string, + query: DocumentNode, + initialValue?: Type | void + variables?: object +}; + +type ApolloStoreState<Type> = { + loading: boolean, + data: Type | void, + error: Error | void +}; + +export const store = function store<Type>({ key, query, initialValue = null, variables = {} }: ApolloStoreConfiguration<Type>): Readable<ApolloStoreState<Type>> { + + const initialState: ApolloStoreState<Type> = { + loading: true, + data: initialValue, + error: undefined + }; + + return readable( + initialState, + (set) => { + + const handleError = function (error: Error) { + + return set({ + loading: false, + data: initialValue, + error + }); + }; + + client.watchQuery({ query, variables }).subscribe( + (result: ApolloQueryResult<Type>) => { + + if (result.errors) { + const error = new ApolloError({ graphQLErrors: result.errors }); + return handleError(error); + } + + set({ + loading: false, + data: result.data[key], + error: undefined + }); + }, + (error: Error) => handleError(error) + ); + } + ); +}; diff --git a/src/lib/stores/forums.test.ts b/src/lib/stores/forums.test.ts new file mode 100644 index 0000000..5a8c723 --- /dev/null +++ b/src/lib/stores/forums.test.ts @@ -0,0 +1,570 @@ +import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact'; +import { resolve } from 'path'; + +import { resolveAfter } from '$lib/utils/resolve_after'; + +const { eachLike, like } = Matchers; + +jest.mock('$lib/config/config.ts'); + +import { getForum, getForums } from './forums'; + +const internals = { + provider: null +}; + +describe('Forums store pact', () => { + + beforeAll(async () => { + + internals.provider = new Pact({ + port: 1234, + dir: resolve(process.cwd(), 'pacts'), + consumer: 'ForumClient', + provider: 'ForumServer', + pactfileWriteMode: 'update' + }); + + await internals.provider.setup(); + }); + + afterEach(() => internals.provider.verify()); + afterAll(() => internals.provider.finalize()); + + describe('When there\'s data', () => { + + describe('GetForums', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s data') + .uponReceiving('a request to list the forums') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForums') + .withQuery( + `query GetForums { + forums { + id + glyph + label + position + __typename + } + }` + ) + .withVariables({}) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + forums: eachLike({ + id: like('butter'), + glyph: like('⌘'), + label: like('test_forums.butter'), + position: like(1) + }) + } + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the forums', async () => { + + const forums = getForums(); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forums.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toEqual(expect.arrayContaining([{ + id: 'butter', + glyph: '⌘', + label: 'test_forums.butter', + position: 1 + }])); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + + describe('GetForum', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s data') + .uponReceiving('a request to get a single forum') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForum') + .withQuery( + `query GetForum($id: ID!) { + forum(id: $id) { + id + glyph + label + position + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'freezer' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + forum: like({ + id: 'freezer', + glyph: like('✭'), + label: like('test_forums.freezer'), + position: like(3), + topics: eachLike({ + id: like('629de02c-151a-4db7-bb86-43b2add8a15a'), + title: like('Very pacty topic'), + updated_at: like(1619954611616), + ttl: like(3601) + }) + }) + } + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the forum', async () => { + + const forum = getForum('freezer'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forum.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data.id).toBe('freezer'); + expect(response.data.glyph).toBe('✭'); + expect(response.data.label).toBe('test_forums.freezer'); + expect(response.data.position).toBe(3); + expect(response.data.topics).toEqual(expect.arrayContaining([{ + id: '629de02c-151a-4db7-bb86-43b2add8a15a', + title: 'Very pacty topic', + updated_at: 1619954611616, + ttl: 3601 + }])); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s no data', () => { + + describe('GetForums', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s no data') + .uponReceiving('a request to list the forums') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForums') + .withQuery( + `query GetForums { + forums { + id + glyph + label + position + __typename + } + }` + ) + .withVariables({}) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + forums: [] + } + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the forums', async () => { + + const forums = getForums(); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forums.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + + describe('GetForum', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s no data') + .uponReceiving('a request to get a single forum') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForum') + .withQuery( + `query GetForum($id: ID!) { + forum(id: $id) { + id + glyph + label + position + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'freezer' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + forum: null + } + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the forum', async () => { + + const forum = getForum('freezer'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forum.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s a server error', () => { + + describe('GetForums', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s a server error') + .uponReceiving('a request to list the forums') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForums') + .withQuery( + `query GetForums { + forums { + id + glyph + label + position + __typename + } + }` + ) + .withVariables({}) + .willRespondWith({ + status: 500 + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the error', async () => { + + const forums = getForums(); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forums.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(false); + expect(response.error).toBeInstanceOf(Error); + }); + }); + + describe('GetForum', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s a server error') + .uponReceiving('a request to get a single forum') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForum') + .withQuery( + `query GetForum($id: ID!) { + forum(id: $id) { + id + glyph + label + position + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'freezer' + }) + .willRespondWith({ + status: 500 + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the error', async () => { + + const forum = getForum('freezer'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forum.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBeInstanceOf(Error); + }); + }); + }); + + describe('When there\'s an error in the response', () => { + + describe('GetForums', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s an error in the response') + .uponReceiving('a request to list the forums') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForums') + .withQuery( + `query GetForums { + forums { + id + glyph + label + position + __typename + } + }` + ) + .withVariables({}) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + errors: eachLike({ + message: like('An error occurred when fetching forums') + }) + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the error', async () => { + + const forums = getForums(); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forums.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBeInstanceOf(Array); + expect(response.data.length).toBe(0); + expect(response.loading).toBe(false); + expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{ + message: 'An error occurred when fetching forums' + }])); + }); + }); + + describe('GetForum', () => { + + beforeAll(async () => { + + const forumQuery = new GraphQLInteraction() + .given('there\'s an error in the response') + .uponReceiving('a request to get a single forum') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetForum') + .withQuery( + `query GetForum($id: ID!) { + forum(id: $id) { + id + glyph + label + position + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'freezer' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + errors: eachLike({ + message: like('An error occurred when fetching the forum') + }) + } + }); + return await internals.provider.addInteraction(forumQuery); + }); + + test('it returns the error', async () => { + + const forum = getForum('freezer'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + forum.subscribe((forumsValue) => { + + response = forumsValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{ + message: 'An error occurred when fetching the forum' + }])); + }); + }); + }); +}); diff --git a/src/lib/stores/forums.ts b/src/lib/stores/forums.ts new file mode 100644 index 0000000..cb62b5a --- /dev/null +++ b/src/lib/stores/forums.ts @@ -0,0 +1,5 @@ +import { store } from './apollo'; +import { GET_FORUM, GET_FORUMS } from '$lib/data/queries'; + +export const getForum = (id: string) => store({ key: 'forum', query: GET_FORUM, variables: { id } }); +export const getForums = () => store({ key: 'forums', query: GET_FORUMS, initialValue: [] }); diff --git a/src/lib/stores/posts.test.ts b/src/lib/stores/posts.test.ts new file mode 100644 index 0000000..afc22f3 --- /dev/null +++ b/src/lib/stores/posts.test.ts @@ -0,0 +1,339 @@ +import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact'; +import { resolve } from 'path'; + +import { resolveAfter } from '$lib/utils/resolve_after'; + +const { eachLike, like } = Matchers; + +jest.mock('$lib/config/config.ts'); + +import { getPost } from './posts'; + +const internals = { + provider: null +}; + +describe('Posts store pact', () => { + + beforeAll(async () => { + + internals.provider = new Pact({ + port: 1234, + dir: resolve(process.cwd(), 'pacts'), + consumer: 'ForumClient', + provider: 'ForumServer', + pactfileWriteMode: 'update' + }); + + await internals.provider.setup(); + }); + + afterEach(() => internals.provider.verify()); + afterAll(() => internals.provider.finalize()); + + describe('When there\'s data', () => { + + describe('GetPost', () => { + + beforeAll(async () => { + + const postQuery = new GraphQLInteraction() + .given('there\'s data') + .uponReceiving('a request to get a single post') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetPost') + .withQuery( + `query GetPost($id: ID!) { + post(id: $id) { + id + text + created_at + author { + id + handle + __typename + } + topic { + id + title + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '8f75eba5-6989-4dd3-b466-e464546ce374' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + post: like({ + id: like('8f75eba5-6989-4dd3-b466-e464546ce374'), + text: like('This is a very pacty post'), + created_at: like(1619976194937), + author: like({ + id: like('a805b3de-cac4-451c-a1e6-f078869c9db9'), + handle: like('pacts_person') + }), + topic: like({ + id: like('5c283ce1-0470-4b98-86f5-1fec9a22c9ac'), + title: like('The parent pacts topic') + }) + }) + } + } + }); + return await internals.provider.addInteraction(postQuery); + }); + + test('it returns the post', async () => { + + const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + post.subscribe((postValue) => { + + response = postValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toEqual({ + id: '8f75eba5-6989-4dd3-b466-e464546ce374', + text: 'This is a very pacty post', + created_at: 1619976194937, + author: { + id: 'a805b3de-cac4-451c-a1e6-f078869c9db9', + handle: 'pacts_person' + }, + topic: { + id: '5c283ce1-0470-4b98-86f5-1fec9a22c9ac', + title: 'The parent pacts topic' + } + }); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s no data', () => { + + describe('GetPost', () => { + + beforeAll(async () => { + + const postQuery = new GraphQLInteraction() + .given('there\'s no data') + .uponReceiving('a request to get a single post') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetPost') + .withQuery( + `query GetPost($id: ID!) { + post(id: $id) { + id + text + created_at + author { + id + handle + __typename + } + topic { + id + title + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '8f75eba5-6989-4dd3-b466-e464546ce374' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + post: null + } + } + }); + return await internals.provider.addInteraction(postQuery); + }); + + test('it returns the post', async () => { + + const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + post.subscribe((postValue) => { + + response = postValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s a server error', () => { + + describe('GetPost', () => { + + beforeAll(async () => { + + const postQuery = new GraphQLInteraction() + .given('there\'s a server error') + .uponReceiving('a request to get a single post') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetPost') + .withQuery( + `query GetPost($id: ID!) { + post(id: $id) { + id + text + created_at + author { + id + handle + __typename + } + topic { + id + title + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '8f75eba5-6989-4dd3-b466-e464546ce374' + }) + .willRespondWith({ + status: 500 + }); + return await internals.provider.addInteraction(postQuery); + }); + + test('it returns the error', async () => { + + const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + post.subscribe((postValue) => { + + response = postValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBeInstanceOf(Error); + }); + }); + }); + + describe('When there\'s an error in the response', () => { + + describe('GetPost', () => { + + beforeAll(async () => { + + const postQuery = new GraphQLInteraction() + .given('there\'s an error in the response') + .uponReceiving('a request to get a single post') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetPost') + .withQuery( + `query GetPost($id: ID!) { + post(id: $id) { + id + text + created_at + author { + id + handle + __typename + } + topic { + id + title + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '8f75eba5-6989-4dd3-b466-e464546ce374' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + errors: eachLike({ + message: like('An error occurred when fetching the post') + }) + } + }); + return await internals.provider.addInteraction(postQuery); + }); + + test('it returns the error', async () => { + + const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + post.subscribe((postValue) => { + + response = postValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{ + message: 'An error occurred when fetching the post' + }])); + }); + }); + }); +}); diff --git a/src/lib/stores/posts.ts b/src/lib/stores/posts.ts new file mode 100644 index 0000000..0d3dec3 --- /dev/null +++ b/src/lib/stores/posts.ts @@ -0,0 +1,4 @@ +import { store } from './apollo'; +import { GET_POST } from '$lib/data/queries'; + +export const getPost = (id: string) => store({ key: 'post', query: GET_POST, variables: { id } }); diff --git a/src/lib/stores/tags.test.ts b/src/lib/stores/tags.test.ts new file mode 100644 index 0000000..9d3eb50 --- /dev/null +++ b/src/lib/stores/tags.test.ts @@ -0,0 +1,311 @@ +import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact'; +import { resolve } from 'path'; + +import { resolveAfter } from '$lib/utils/resolve_after'; + +const { eachLike, like } = Matchers; + +jest.mock('$lib/config/config.ts'); + +import { getTag } from './tags'; + +const internals = { + provider: null +}; + +describe('Tags store pact', () => { + + beforeAll(async () => { + + internals.provider = new Pact({ + port: 1234, + dir: resolve(process.cwd(), 'pacts'), + consumer: 'ForumClient', + provider: 'ForumServer', + pactfileWriteMode: 'update' + }); + + await internals.provider.setup(); + }); + + afterEach(() => internals.provider.verify()); + afterAll(() => internals.provider.finalize()); + + describe('When there\'s data', () => { + + describe('GetTag', () => { + + beforeAll(async () => { + + const tagQuery = new GraphQLInteraction() + .given('there\'s data') + .uponReceiving('a request to get a single tag') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTag') + .withQuery( + `query GetTag($id: ID!) { + tag(id: $id) { + id + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'pineapple' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + tag: { + id: like('pineapple'), + topics: eachLike({ + id: like('cd038ae7-e8b4-4e38-9543-3d697e69ac34'), + title: like('This topic is about pineapples'), + updated_at: like(1619978944077), + ttl: like(3555) + }) + } + } + } + }); + return await internals.provider.addInteraction(tagQuery); + }); + + test('it returns the tag', async () => { + + const tag = getTag('pineapple'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + tag.subscribe((tagValue) => { + + response = tagValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toEqual({ + id: 'pineapple', + topics: [{ + id: 'cd038ae7-e8b4-4e38-9543-3d697e69ac34', + title: 'This topic is about pineapples', + updated_at: 1619978944077, + ttl: 3555 + }] + }); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s no data', () => { + + describe('GetTag', () => { + + beforeAll(async () => { + + const tagQuery = new GraphQLInteraction() + .given('there\'s no data') + .uponReceiving('a request to get a single tag') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTag') + .withQuery( + `query GetTag($id: ID!) { + tag(id: $id) { + id + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'pineapple' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + tag: null + } + } + }); + return await internals.provider.addInteraction(tagQuery); + }); + + test('it returns the tag', async () => { + + const tag = getTag('pineapple'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + tag.subscribe((tagValue) => { + + response = tagValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s a server error', () => { + + describe('GetTag', () => { + + beforeAll(async () => { + + const tagQuery = new GraphQLInteraction() + .given('there\'s a server error') + .uponReceiving('a request to get a single tag') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTag') + .withQuery( + `query GetTag($id: ID!) { + tag(id: $id) { + id + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'pineapple' + }) + .willRespondWith({ + status: 500 + }); + return await internals.provider.addInteraction(tagQuery); + }); + + test('it returns the error', async () => { + + const tag = getTag('pineapple'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + tag.subscribe((tagValue) => { + + response = tagValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBeInstanceOf(Error); + }); + }); + }); + + describe('When there\'s an error in the response', () => { + + describe('GetTag', () => { + + beforeAll(async () => { + + const tagQuery = new GraphQLInteraction() + .given('there\'s an error in the response') + .uponReceiving('a request to get a single tag') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTag') + .withQuery( + `query GetTag($id: ID!) { + tag(id: $id) { + id + topics { + id + title + updated_at + ttl + __typename + } + __typename + } + }` + ) + .withVariables({ + id: 'pineapple' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + errors: eachLike({ + message: like('An error occurred when fetching the tag') + }) + } + }); + return await internals.provider.addInteraction(tagQuery); + }); + + test('it returns the error', async () => { + + const tag = getTag('pineapple'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + tag.subscribe((tagValue) => { + + response = tagValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{ + message: 'An error occurred when fetching the tag' + }])); + }); + }); + }); +}); diff --git a/src/lib/stores/tags.ts b/src/lib/stores/tags.ts new file mode 100644 index 0000000..79f017e --- /dev/null +++ b/src/lib/stores/tags.ts @@ -0,0 +1,4 @@ +import { store } from './apollo'; +import { GET_TAG } from '$lib/data/queries'; + +export const getTag = (id: string) => store({ key: 'tag', query: GET_TAG, variables: { id } }); diff --git a/src/lib/stores/topics.test.ts b/src/lib/stores/topics.test.ts new file mode 100644 index 0000000..2d2a2e7 --- /dev/null +++ b/src/lib/stores/topics.test.ts @@ -0,0 +1,413 @@ +import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact'; +import { resolve } from 'path'; + +import { resolveAfter } from '$lib/utils/resolve_after'; + +const { eachLike, like } = Matchers; + +jest.mock('$lib/config/config.ts'); + +import { getTopic } from './topics'; + +const internals = { + provider: null +}; + +describe('Topics store pact', () => { + + beforeAll(async () => { + + internals.provider = new Pact({ + port: 1234, + dir: resolve(process.cwd(), 'pacts'), + consumer: 'ForumClient', + provider: 'ForumServer', + pactfileWriteMode: 'update' + }); + + await internals.provider.setup(); + }); + + afterEach(() => internals.provider.verify()); + afterAll(() => internals.provider.finalize()); + + describe('When there\'s data', () => { + + describe('GetTopic', () => { + + beforeAll(async () => { + + const topicQuery = new GraphQLInteraction() + .given('there\'s data') + .uponReceiving('a request to get a single topic') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTopic') + .withQuery( + `query GetTopic($id: ID!) { + topic(id: $id) { + id + title + updated_at + ttl + forum { + id + glyph + label + __typename + } + tags { + id + weight + __typename + } + posts { + id + text + created_at + author { + id + handle + __typename + } + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + topic: { + id: like('0b58959d-d448-4a4e-84b6-35e5ac0028d1'), + title: like('The pacty topic of the day'), + updated_at: like(1619979888906), + ttl: like(3399), + forum: { + id: like('cucumber'), + glyph: like('✽'), + label: like('test_forums.cucumber') + }, + tags: eachLike({ + id: like('skunk'), + weight: like(44) + }), + posts: eachLike({ + id: like('ed93530e-6f9c-4701-91ef-14f9e0ed3e26'), + text: like('The content of this post is very relevant'), + created_at: like(1619979889798), + author: like({ + id: like('07fb2ba0-0945-464a-b215-873296710c8c'), + handle: like('cucumber_fan92') + }) + }) + } + } + } + }); + return await internals.provider.addInteraction(topicQuery); + }); + + test('it returns the topic', async () => { + + const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + topic.subscribe((topicValue) => { + + response = topicValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toEqual({ + id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1', + title: 'The pacty topic of the day', + updated_at: 1619979888906, + ttl: 3399, + forum: { + id: 'cucumber', + glyph: '✽', + label: 'test_forums.cucumber' + }, + tags: [{ + id: 'skunk', + weight: 44 + }], + posts: [{ + id: 'ed93530e-6f9c-4701-91ef-14f9e0ed3e26', + text: 'The content of this post is very relevant', + created_at: 1619979889798, + author: { + id: '07fb2ba0-0945-464a-b215-873296710c8c', + handle: 'cucumber_fan92' + } + }] + }); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s no data', () => { + + describe('GetTopic', () => { + + beforeAll(async () => { + + const topicQuery = new GraphQLInteraction() + .given('there\'s no data') + .uponReceiving('a request to get a single topic') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTopic') + .withQuery( + `query GetTopic($id: ID!) { + topic(id: $id) { + id + title + updated_at + ttl + forum { + id + glyph + label + __typename + } + tags { + id + weight + __typename + } + posts { + id + text + created_at + author { + id + handle + __typename + } + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + data: { + topic: null + } + } + }); + return await internals.provider.addInteraction(topicQuery); + }); + + test('it returns the topic', async () => { + + const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + topic.subscribe((topicValue) => { + + response = topicValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBe(undefined); + }); + }); + }); + + describe('When there\'s a server error', () => { + + describe('GetTopic', () => { + + beforeAll(async () => { + + const topicQuery = new GraphQLInteraction() + .given('there\'s a server error') + .uponReceiving('a request to get a single topic') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTopic') + .withQuery( + `query GetTopic($id: ID!) { + topic(id: $id) { + id + title + updated_at + ttl + forum { + id + glyph + label + __typename + } + tags { + id + weight + __typename + } + posts { + id + text + created_at + author { + id + handle + __typename + } + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1' + }) + .willRespondWith({ + status: 500 + }); + return await internals.provider.addInteraction(topicQuery); + }); + + test('it returns the error', async () => { + + const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + topic.subscribe((topicValue) => { + + response = topicValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error).toBeInstanceOf(Error); + }); + }); + }); + + describe('When there\'s an error in the response', () => { + + describe('GetTopic', () => { + + beforeAll(async () => { + + const topicQuery = new GraphQLInteraction() + .given('there\'s an error in the response') + .uponReceiving('a request to get a single topic') + .withRequest({ + path: '/graphql', + method: 'POST' + }) + .withOperation('GetTopic') + .withQuery( + `query GetTopic($id: ID!) { + topic(id: $id) { + id + title + updated_at + ttl + forum { + id + glyph + label + __typename + } + tags { + id + weight + __typename + } + posts { + id + text + created_at + author { + id + handle + __typename + } + __typename + } + __typename + } + }` + ) + .withVariables({ + id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1' + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + }, + body: { + errors: eachLike({ + message: like('An error occurred when fetching the topic') + }) + } + }); + return await internals.provider.addInteraction(topicQuery); + }); + + test('it returns the error', async () => { + + const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1'); + const { counter, promise: resolveAfterTwo } = resolveAfter(2); + let response = null; + topic.subscribe((topicValue) => { + + response = topicValue; + counter(); + }); + expect(response.data).toBe(null); + expect(response.loading).toBe(true); + expect(response.error).toBe(undefined); + await resolveAfterTwo; + expect(response.data).toBe(null); + expect(response.loading).toBe(false); + expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{ + message: 'An error occurred when fetching the topic' + }])); + }); + }); + }); +}); diff --git a/src/lib/stores/topics.ts b/src/lib/stores/topics.ts new file mode 100644 index 0000000..84228cd --- /dev/null +++ b/src/lib/stores/topics.ts @@ -0,0 +1,4 @@ +import { store } from './apollo'; +import { GET_TOPIC } from '$lib/data/queries'; + +export const getTopic = (id: string) => store({ key: 'topic', query: GET_TOPIC, variables: { id } }); diff --git a/src/lib/translations/en.json b/src/lib/translations/en.json new file mode 100644 index 0000000..14e89e4 --- /dev/null +++ b/src/lib/translations/en.json @@ -0,0 +1,112 @@ +{ + "error": { + "generic": { + "title": "Error!", + "message": "Unknown error has occurred. Panic!" + }, + "invalid_url": { + "title": "This is not right.", + "message": "This URL is not valid. Try another one maybe?" + } + }, + "footer": { + "choose_language": "Choose your language", + "license": "Forum is <a href=\"{licenseUrl}\">open source.</a>", + "title": "Footer" + }, + "forum": { + "forum": "forum", + "name": { + "everything": "Everything", + "us": "Us", + "words": "Words", + "sound": "Sounds", + "structure": "Structure", + "interaction": "Interaction", + "emotion": "Emotion", + "movement": "Movement", + "belief": "Belief", + "experience": "Experience", + "online": "Online", + "the_world": "The World", + "life": "Life" + }, + "error": { + "unavailable": "Forum topics unavailable." + } + }, + "forum_list": { + "title": "List of forums", + "error": { + "unavailable": "Forum list unavailable." + } + }, + "glyph": { + "title": "User avatar" + }, + "header": { + "action": { + "new": { + "title": "New", + "display": "<u>N</u>ew" + }, + "reply": { + "title": "Reply", + "display": "<u>R</u>eply" + }, + "search": { + "title": "Search", + "display": "<u>S</u>earch" + }, + "log_out": { + "title": "Log Out", + "display": "Log <u>O</u>ut" + } + }, + "long_version": "Forum version {version}", + "title": "Toolbar", + "short_version": "Forum v{version}" + }, + "home": { + "title": "Hello.", + "content": "You are now in a forum. Select a category from the left or input a valid URL." + }, + "loader": { + "message": "Loading." + }, + "post": { + "author_credit": "By:", + "metadata_title": "Post {count} of {total} metadata", + "permalink_title": "Permalink to post", + "post": "Post", + "title": "Post {count} of {total} by {author}", + "topic_location": "In", + "error": { + "unavailable": "Post unavailable." + } + }, + "tag": { + "title": "Tag", + "error": { + "unavailable": "Tag topics unavailable." + } + }, + "topic": { + "category_location": "Posted on", + "metadata_title": "Topic metadata", + "permalink_title": "Permalink to topic", + "remaining_time": "{remaining} remaining", + "tags_location": "Tags:", + "tags_title": "Topic tags", + "title": "Topic", + "error": { + "unavailable": "Topic unavailable." + } + }, + "time": { + "days": "{count, plural, =1 {# day} other {# days}}", + "hours": "{count, plural, =1 {# hour} other {# hours}}", + "minutes": "{count, plural, =1 {# minute} other {# minutes}}", + "seconds": "{count, plural, =1 {# second} other {# seconds}}" + } +} diff --git a/src/lib/translations/es.json b/src/lib/translations/es.json new file mode 100644 index 0000000..4f6b0c1 --- /dev/null +++ b/src/lib/translations/es.json @@ -0,0 +1,112 @@ +{ + "error": { + "generic": { + "title": "Error!", + "message": "Hubo un error desconocido. ¡Entra en pánico!" + }, + "invalid_url": { + "title": "Esto no está bien.", + "message": "Este URL no es válido. Intenta otro, ¿Tal vez?" + } + }, + "footer": { + "choose_language": "Escoge un lenguaje", + "license": "Forum es <a href=\"{licenseUrl}\">software libre.</a>", + "title": "Pie de página" + }, + "forum": { + "forum": "foro", + "name": { + "everything": "Todo", + "us": "Nosotros", + "words": "Palabras", + "sound": "Sonidos", + "structure": "Estructura", + "interaction": "Interacción", + "emotion": "Emoción", + "movement": "Movimiento", + "belief": "Creencia", + "experience": "Experiencia", + "online": "En Línea", + "the_world": "El Mundo", + "life": "Vida" + }, + "error": { + "unavailable": "Temas del foro no disponibles." + } + }, + "forum_list": { + "title": "Lista de foros", + "error": { + "unavailable": "Lista de foros no disponible." + } + }, + "glyph": { + "title": "Avatar del usuario" + }, + "header": { + "action": { + "new": { + "title": "Nuevo", + "display": "<u>N</u>uevo" + }, + "reply": { + "title": "Responder", + "display": "<u>R</u>esponder" + }, + "search": { + "title": "Buscar", + "display": "Bu<u>s</u>car" + }, + "log_out": { + "title": "Cerrar Sesión", + "display": "Cerrar Sesi<u>ó</u>n" + } + }, + "long_version": "Forum versión {version}", + "title": "Barra de herramientas", + "short_version": "Forum v{version}" + }, + "home": { + "title": "Hola.", + "content": "Ahora estás en un foro. Elige una categoría de la izquierda o escribe un URL válido." + }, + "loader": { + "message": "Cargando." + }, + "post": { + "author_credit": "Por:", + "metadata_title": "Metadatos de entrada {count} de {total}", + "permalink_title": "Permalink a entrada", + "post": "Entrada", + "title": "Entrada {count} de {total}, por {author}", + "topic_location": "En", + "error": { + "unavailable": "Entrada no disponible." + } + }, + "tag": { + "title": "Etiqueta", + "error": { + "unavailable": "Temas de la etiqueta no disponibles." + } + }, + "topic": { + "category_location": "Agregado a", + "metadata_title": "Metadatos del tema", + "permalink_title": "Permalink al tema", + "remaining_time": "Quedan {remaining}", + "tags_location": "Etiquetas:", + "tags_title": "Etiquetas del tema", + "title": "Tema", + "error": { + "unavailable": "Tema no disponible." + } + }, + "time": { + "days": "{count, plural, =1 {# día} other {# días}}", + "hours": "{count, plural, =1 {# hora} other {# horas}}", + "minutes": "{count, plural, =1 {# minuto} other {# minutos}}", + "seconds": "{count, plural, =1 {# segundo} other {# segundos}}" + } +} diff --git a/src/lib/utils/glyph_hash.test.ts b/src/lib/utils/glyph_hash.test.ts new file mode 100644 index 0000000..5f57a2a --- /dev/null +++ b/src/lib/utils/glyph_hash.test.ts @@ -0,0 +1,68 @@ +import { getGlyphHash } from './glyph_hash'; +import type { GlyphHash } from './glyph_hash'; + +type TestState = { + glyphHash?: GlyphHash +}; + + +describe('Glyph Hash utility', () => { + + test('it throws an exception if the string is too short', () => { + + expect(() => { + + getGlyphHash('short'); + }).toThrow(); + + expect(() => { + + getGlyphHash('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); + }).toThrow(); + + expect(() => { + + getGlyphHash('abcdefghijklmnopqrstuvwxyzABCDEF'); + }).not.toThrow(); + }); + + test('it treats UUIDs with hyphens the same as those without', () => { + + const uuidWithHyphens = 'f7722355-2285-46c0-a55f-3483a826f3a6'; + const uuidWithoutHyphens = 'f7722355228546c0a55f3483a826f3a6'; + + expect(getGlyphHash(uuidWithHyphens)).toEqual(getGlyphHash(uuidWithoutHyphens)); + }); + + describe('it generates four sets of glyphs and colors', () => { + + const state: TestState = {}; + + beforeEach(() => { + + state.glyphHash = getGlyphHash('f7722355-2285-46c0-a55f-3483a826f3a6'); + }); + + test('there should be four glyph fragments', () => { + + expect(state.glyphHash.length).toBe(4); + }); + + test('each fragment should have a single character glyph', () => { + + for (const glyphHashFragment of state.glyphHash) { + expect(typeof glyphHashFragment.glyph).toBe('string'); + expect(glyphHashFragment.glyph.length).toBe(1); + } + }); + + test('each fragment should have a hexadecimal color', () => { + + for (const glyphHashFragment of state.glyphHash) { + expect(typeof glyphHashFragment.color).toBe('string'); + expect(glyphHashFragment.color.length).toBe(7); + expect(glyphHashFragment.color).toEqual(expect.stringMatching(/#[0-9a-f]{6}/)); + } + }); + }); +}); diff --git a/src/lib/utils/glyph_hash.ts b/src/lib/utils/glyph_hash.ts new file mode 100644 index 0000000..b704569 --- /dev/null +++ b/src/lib/utils/glyph_hash.ts @@ -0,0 +1,55 @@ +export type GlyphHash = GlyphHashFragment[]; +type GlyphHashFragment = { + glyph: string, + color: string +}; + +const internals = { + kDehyphenRegex: /[-]/g, + kSplitterRegex: /.{1,8}/g, + kGlyphs: [ + '☽', + '☆', + '♢', + '♡', + '╱', + '╲', + '╳', + '〰', + '▷', + '⏊', + '〒', + '▢', + '◯', + '⏃', + '⏀', + '⏆' + ], + unexpectedUUIDLength: class UnexpectedUUIDLength extends Error { + name = 'UnexpectedUUIDLength'; + message = 'The provided string was not a valid UUIDv4, please provide a 32 character long string' + } +}; + +// Return a glyph with color based on a 4 byte fragment of a UUIDv4 +const getGlyphHashFragment = function (uuidFragment: string): GlyphHashFragment { + + const glyphIndex = parseInt(uuidFragment.substring(0, 2), 16) % 16; + return { + glyph: internals.kGlyphs[glyphIndex], + color: `#${uuidFragment.substring(2, 8)}` + }; +}; + +// Return an array of glyphs based on a UUIDv4 +export const getGlyphHash = function (uuid: string): GlyphHash { + + const dehyphenedUuid = uuid.replace(/[-]/g, ''); + + if (dehyphenedUuid.length !== 32) { + throw new internals.unexpectedUUIDLength(); + } + + const hashFragments = dehyphenedUuid.match(internals.kSplitterRegex); + return hashFragments.map(getGlyphHashFragment); +}; diff --git a/src/lib/utils/readable_time.test.ts b/src/lib/utils/readable_time.test.ts new file mode 100644 index 0000000..5d8ba27 --- /dev/null +++ b/src/lib/utils/readable_time.test.ts @@ -0,0 +1,83 @@ +import { readableTime } from './readable_time'; + +describe('readableTime', () => { + + test('it shows negative time as 0', () => { + + const response = readableTime(-1000); + + expect(response.count).toBe(0); + expect(response.label).toContain('seconds'); + }); + + test('uses seconds as the smallest unit', () => { + + const response = readableTime(10); + + expect(response.count).toBe(0); + expect(response.label).toContain('seconds'); + }); + + test('correctly divides miliseconds into seconds', () => { + + const response = readableTime(4000); + + expect(response.count).toBe(4); + }); + + test('uses seconds if the time is < 1 minute', () => { + + const response = readableTime(59 * 1000); + + expect(response.label).toContain('seconds'); + }); + + test('correctly divides miliseconds into minutes', () => { + + const response = readableTime(2 * 60 * 1000); + + expect(response.count).toBe(2); + }); + + test('uses minutes if the time is < 1 hour', () => { + + const response = readableTime(59 * 60 * 1000); + + expect(response.label).toContain('minutes'); + }); + + test('correctly divides miliseconds into hours', () => { + + const response = readableTime(2 * 60 * 60 * 1000); + + expect(response.count).toBe(2); + }); + + test('uses hours if the time is < 1 days', () => { + + const response = readableTime(23 * 60 * 60 * 1000); + + expect(response.label).toContain('hours'); + }); + + test('correctly divides miliseconds into days', () => { + + const response = readableTime(2 * 24 * 60 * 60 * 1000); + + expect(response.count).toBe(2); + }); + + test('uses days if the time is >= 1 day', () => { + + const response = readableTime(364 * 24 * 60 * 60 * 1000); + + expect(response.label).toContain('days'); + }); + + test('uses days as the maximum unit', () => { + + const response = readableTime(Number.MAX_VALUE); + + expect(response.label).toContain('days'); + }); +}); diff --git a/src/lib/utils/readable_time.ts b/src/lib/utils/readable_time.ts new file mode 100644 index 0000000..86ba044 --- /dev/null +++ b/src/lib/utils/readable_time.ts @@ -0,0 +1,46 @@ +type DateMagnitude = 'day' | 'hour' | 'minute' | 'second'; + +type ReadableTime = { + count: number, + label: string +}; + + +const internals = { + magnitudes: { + day: 86400000, + hour: 3600000, + minute: 60000, + second: 1000 + }, + labels: { + day: 'time.days', + hour: 'time.hours', + minute: 'time.minutes', + second: 'time.seconds' + }, + + makeTimeReadable(time: number, magnitude: DateMagnitude): ReadableTime { + + return { + count: Math.floor(time / internals.magnitudes[magnitude]), + label: internals.labels[magnitude] + }; + } +}; + +export const readableTime = function readableTime(time: number): ReadableTime { + + switch (true) { + case time >= internals.magnitudes.day: + return internals.makeTimeReadable(time, 'day'); + case time >= internals.magnitudes.hour: + return internals.makeTimeReadable(time, 'hour'); + case time >= internals.magnitudes.minute: + return internals.makeTimeReadable(time, 'minute'); + case time < 0: + return internals.makeTimeReadable(0, 'second'); + default: + return internals.makeTimeReadable(time, 'second'); + } +}; diff --git a/src/lib/utils/resolve_after.test.ts b/src/lib/utils/resolve_after.test.ts new file mode 100644 index 0000000..7e0cc3c --- /dev/null +++ b/src/lib/utils/resolve_after.test.ts @@ -0,0 +1,37 @@ +import { resolveAfter } from './resolve_after'; + +describe('Resolve After', () => { + + test('it should throw if given 0', () => { + + expect(() => { + + resolveAfter(0); + }).toThrow(); + }); + + test('it should throw if given a negative number', () => { + + expect(() => { + + resolveAfter(-1); + }).toThrow(); + }); + + test('it should resolve after the specified number of times', () => { + + expect(() => { + + const { counter, promise: resolveAfterThree } = resolveAfter(3); + let resolved = false; + + resolveAfterThree.then(() => (resolved = true)); + counter(); + expect(resolved).toBe(false); + counter(); + expect(resolved).toBe(false); + counter(); + expect(resolved).toBe(true); + }).toThrow(); + }); +}); diff --git a/src/lib/utils/resolve_after.ts b/src/lib/utils/resolve_after.ts new file mode 100644 index 0000000..95a477e --- /dev/null +++ b/src/lib/utils/resolve_after.ts @@ -0,0 +1,29 @@ +export type ResolveAfterPromise = { + counter: () => void, + promise: Promise<void> +}; + +export const resolveAfter = function (timesUntilResolve: number): ResolveAfterPromise { + + let counter = null; + let currentValue = 0; + + if (timesUntilResolve <= 0) { + throw new Error('Resolve after requires a positive integer'); + } + + const promise: Promise<void> = new Promise((resolvePromise) => { + + counter = () => { + + if (++currentValue === timesUntilResolve) { + resolvePromise(); + } + }; + }); + + return { + counter, + promise + }; +}; |