about summary refs log tree commit diff
path: root/__tests__/lib
diff options
context:
space:
mode:
authorJoão Ferreiro <ferreiro@pinkroom.dev>2022-12-22 15:32:39 +0000
committerGitHub <noreply@github.com>2022-12-22 09:32:39 -0600
commit7517b65dcd676f36d38f31c991929c32168b3e12 (patch)
tree65793d2575b205365c2997b4bbddc1ba6424d2ba /__tests__/lib
parent4913a07e3365d2004e67e9131dd4b4c15094dd33 (diff)
downloadvoidsky-7517b65dcd676f36d38f31c991929c32168b3e12.tar.zst
Unit testing (#32)
* add testing lib

* remove coverage folder from git

* finished basic test setup

* fix tests typescript and import paths

* add first snapshot

* testing utils

* rename test files; update script flags; ++tests

* testing utils functions

* testing downloadAndResize wip

* remove download test

* specify unwanted coverage paths;
remove update snapshots flag

* fix strings tests

* testing downloadAndResize method

* increasing testing

* fixing snapshots wip

* fixed shell mobile snapshot

* adding snapshots for the screens

* fix onboard snapshot

* fix typescript issues

* fix TabsSelector snapshot

* Account for testing device's locale in ago() tests

* Remove platform detection on regex

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Diffstat (limited to '__tests__/lib')
-rw-r--r--__tests__/lib/download.test.ts93
-rw-r--r--__tests__/lib/errors.test.ts19
-rw-r--r--__tests__/lib/link-meta.test.ts146
-rw-r--r--__tests__/lib/numbers.test.ts24
-rw-r--r--__tests__/lib/string.test.ts532
5 files changed, 814 insertions, 0 deletions
diff --git a/__tests__/lib/download.test.ts b/__tests__/lib/download.test.ts
new file mode 100644
index 000000000..d90e8c895
--- /dev/null
+++ b/__tests__/lib/download.test.ts
@@ -0,0 +1,93 @@
+import {downloadAndResize, DownloadAndResizeOpts} from '../../src/lib/download'
+import ImageResizer from '@bam.tech/react-native-image-resizer'
+import RNFetchBlob from 'rn-fetch-blob'
+
+jest.mock('rn-fetch-blob', () => ({
+  config: jest.fn().mockReturnThis(),
+  cancel: jest.fn(),
+  fetch: jest.fn(),
+}))
+jest.mock('@bam.tech/react-native-image-resizer', () => ({
+  createResizedImage: jest.fn(),
+}))
+
+describe('downloadAndResize', () => {
+  const errorSpy = jest.spyOn(global.console, 'error')
+
+  const mockResizedImage = {
+    path: jest.fn().mockReturnValue('file://resized-image.jpg'),
+  }
+
+  beforeEach(() => {
+    jest.clearAllMocks()
+
+    const mockedCreateResizedImage =
+      ImageResizer.createResizedImage as jest.Mock
+    mockedCreateResizedImage.mockResolvedValue(mockResizedImage)
+  })
+
+  it('should return resized image for valid URI and options', async () => {
+    const mockedFetch = RNFetchBlob.fetch as jest.Mock
+    mockedFetch.mockResolvedValueOnce({
+      path: jest.fn().mockReturnValue('file://downloaded-image.jpg'),
+      flush: jest.fn(),
+    })
+
+    const opts: DownloadAndResizeOpts = {
+      uri: 'https://example.com/image.jpg',
+      width: 100,
+      height: 100,
+      mode: 'cover',
+      timeout: 10000,
+    }
+
+    const result = await downloadAndResize(opts)
+    expect(result).toEqual(mockResizedImage)
+    expect(RNFetchBlob.config).toHaveBeenCalledWith({
+      fileCache: true,
+      appendExt: 'jpeg',
+    })
+    expect(RNFetchBlob.fetch).toHaveBeenCalledWith(
+      'GET',
+      'https://example.com/image.jpg',
+    )
+    expect(ImageResizer.createResizedImage).toHaveBeenCalledWith(
+      'file://downloaded-image.jpg',
+      100,
+      100,
+      'JPEG',
+      0.7,
+      undefined,
+      undefined,
+      undefined,
+      {mode: 'cover'},
+    )
+  })
+
+  it('should return undefined for invalid URI', async () => {
+    const opts: DownloadAndResizeOpts = {
+      uri: 'invalid-uri',
+      width: 100,
+      height: 100,
+      mode: 'cover',
+      timeout: 10000,
+    }
+
+    const result = await downloadAndResize(opts)
+    expect(errorSpy).toHaveBeenCalled()
+    expect(result).toBeUndefined()
+  })
+
+  it('should return undefined for unsupported file type', async () => {
+    const opts: DownloadAndResizeOpts = {
+      uri: 'https://example.com/image.bmp',
+      width: 100,
+      height: 100,
+      mode: 'cover',
+      timeout: 10000,
+    }
+
+    const result = await downloadAndResize(opts)
+    expect(result).toBeUndefined()
+  })
+})
diff --git a/__tests__/lib/errors.test.ts b/__tests__/lib/errors.test.ts
new file mode 100644
index 000000000..b9549e6d8
--- /dev/null
+++ b/__tests__/lib/errors.test.ts
@@ -0,0 +1,19 @@
+import {isNetworkError} from '../../src/lib/errors'
+
+describe('isNetworkError', () => {
+  const inputs = [
+    'TypeError: Network request failed',
+    'Uncaught TypeError: Cannot read property x of undefined',
+    'Uncaught RangeError',
+    'Error: Aborted',
+  ]
+  const outputs = [true, false, false, true]
+
+  it('correctly distinguishes network errors', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = isNetworkError(input)
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
diff --git a/__tests__/lib/link-meta.test.ts b/__tests__/lib/link-meta.test.ts
new file mode 100644
index 000000000..5df5153ee
--- /dev/null
+++ b/__tests__/lib/link-meta.test.ts
@@ -0,0 +1,146 @@
+import {LikelyType, getLinkMeta, getLikelyType} from '../../src/lib/link-meta'
+
+const exampleComHtml = `<!doctype html>
+<html>
+<head>
+    <title>Example Domain</title>
+    <meta name="description" content="An example website">
+
+    <meta charset="utf-8" />
+    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type="text/css">
+    body {
+        background-color: #f0f0f2;
+        margin: 0;
+        padding: 0;
+        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
+
+    }
+    div {
+        width: 600px;
+        margin: 5em auto;
+        padding: 2em;
+        background-color: #fdfdff;
+        border-radius: 0.5em;
+        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
+    }
+    a:link, a:visited {
+        color: #38488f;
+        text-decoration: none;
+    }
+    @media (max-width: 700px) {
+        div {
+            margin: 0 auto;
+            width: auto;
+        }
+    }
+    </style>
+</head>
+
+<body>
+<div>
+    <h1>Example Domain</h1>
+    <p>This domain is for use in illustrative examples in documents. You may use this
+    domain in literature without prior coordination or asking for permission.</p>
+    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
+</div>
+</body>
+</html>`
+
+describe('getLinkMeta', () => {
+  const inputs = [
+    '',
+    'httpbadurl',
+    'https://example.com',
+    'https://example.com/index.html',
+    'https://example.com/image.png',
+    'https://example.com/video.avi',
+    'https://example.com/audio.ogg',
+    'https://example.com/text.txt',
+    'https://example.com/javascript.js',
+    'https://bsky.app/index.html',
+  ]
+  const outputs = [
+    {
+      error: 'Invalid URL',
+      likelyType: LikelyType.Other,
+      url: '',
+    },
+    {
+      error: 'Invalid URL',
+      likelyType: LikelyType.Other,
+      url: 'httpbadurl',
+    },
+    {
+      likelyType: LikelyType.HTML,
+      url: 'https://example.com',
+      title: 'Example Domain',
+      description: 'An example website',
+    },
+    {
+      likelyType: LikelyType.HTML,
+      url: 'https://example.com/index.html',
+      title: 'Example Domain',
+      description: 'An example website',
+    },
+    {
+      likelyType: LikelyType.Image,
+      url: 'https://example.com/image.png',
+    },
+    {
+      likelyType: LikelyType.Video,
+      url: 'https://example.com/video.avi',
+    },
+    {
+      likelyType: LikelyType.Audio,
+      url: 'https://example.com/audio.ogg',
+    },
+    {
+      likelyType: LikelyType.Text,
+      url: 'https://example.com/text.txt',
+    },
+    {
+      likelyType: LikelyType.Other,
+      url: 'https://example.com/javascript.js',
+    },
+    {
+      likelyType: LikelyType.AtpData,
+      url: '/index.html',
+      title: 'Not found',
+    },
+    {
+      likelyType: LikelyType.Other,
+      url: '',
+      title: '',
+    },
+  ]
+  it('correctly handles a set of text inputs', async () => {
+    for (let i = 0; i < inputs.length; i++) {
+      global.fetch = jest.fn().mockImplementationOnce(() => {
+        return new Promise((resolve, _reject) => {
+          resolve({
+            ok: true,
+            status: 200,
+            text: () => exampleComHtml,
+          })
+        })
+      })
+      const input = inputs[i]
+      const output = await getLinkMeta(input)
+      expect(output).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('getLikelyType', () => {
+  it('correctly handles non-parsed url', async () => {
+    const output = await getLikelyType('https://example.com')
+    expect(output).toEqual(LikelyType.HTML)
+  })
+
+  it('handles non-string urls without crashing', async () => {
+    const output = await getLikelyType('123')
+    expect(output).toEqual(LikelyType.Other)
+  })
+})
diff --git a/__tests__/lib/numbers.test.ts b/__tests__/lib/numbers.test.ts
new file mode 100644
index 000000000..be92d6c0f
--- /dev/null
+++ b/__tests__/lib/numbers.test.ts
@@ -0,0 +1,24 @@
+import {clamp} from '../../src/lib/numbers'
+
+describe('clamp', () => {
+  const inputs: [number, number, number][] = [
+    [100, 0, 200],
+    [100, 0, 100],
+    [0, 0, 100],
+    [100, 0, -1],
+    [4, 1, 1],
+    [100, -100, 0],
+    [400, 100, -100],
+    [70, -1, 1],
+    [Infinity, Infinity, Infinity],
+  ]
+  const outputs = [100, 100, 0, -1, 1, 0, -100, 1, Infinity]
+
+  it('correctly clamps any given number and range', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = clamp(...input)
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
new file mode 100644
index 000000000..d8a56b36b
--- /dev/null
+++ b/__tests__/lib/string.test.ts
@@ -0,0 +1,532 @@
+import {
+  extractEntities,
+  detectLinkables,
+  extractHtmlMeta,
+  pluralize,
+  makeRecordUri,
+  ago,
+  makeValidHandle,
+  createFullHandle,
+  enforceLen,
+  cleanError,
+  toNiceDomain,
+  toShortUrl,
+  toShareUrl,
+} from '../../src/lib/strings'
+
+describe('extractEntities', () => {
+  const knownHandles = new Set(['handle.com', 'full123.test-of-chars'])
+  const inputs = [
+    'no mention',
+    '@handle.com middle end',
+    'start @handle.com end',
+    'start middle @handle.com',
+    '@handle.com @handle.com @handle.com',
+    '@full123.test-of-chars',
+    'not@right',
+    '@handle.com!@#$chars',
+    '@handle.com\n@handle.com',
+    'parenthetical (@handle.com)',
+    'start https://middle.com end',
+    'start https://middle.com/foo/bar end',
+    'start https://middle.com/foo/bar?baz=bux end',
+    'start https://middle.com/foo/bar?baz=bux#hash end',
+    'https://start.com/foo/bar?baz=bux#hash middle end',
+    'start middle https://end.com/foo/bar?baz=bux#hash',
+    'https://newline1.com\nhttps://newline2.com',
+    'start middle.com end',
+    'start middle.com/foo/bar end',
+    'start middle.com/foo/bar?baz=bux end',
+    'start middle.com/foo/bar?baz=bux#hash end',
+    'start.com/foo/bar?baz=bux#hash middle end',
+    'start middle end.com/foo/bar?baz=bux#hash',
+    'newline1.com\nnewline2.com',
+    'not.. a..url ..here',
+    'e.g.',
+    'something-cool.jpg',
+    'website.com.jpg',
+    'e.g./foo',
+    'website.com.jpg/foo',
+    'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+    'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
+    'https://foo.com https://bar.com/whatever https://baz.com',
+    'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
+    'parenthentical (https://foo.com)',
+    'except for https://foo.com/thing_(cool)',
+  ]
+  interface Output {
+    type: string
+    value: string
+    noScheme?: boolean
+  }
+  const outputs: Output[][] = [
+    [],
+    [{type: 'mention', value: 'handle.com'}],
+    [{type: 'mention', value: 'handle.com'}],
+    [{type: 'mention', value: 'handle.com'}],
+    [
+      {type: 'mention', value: 'handle.com'},
+      {type: 'mention', value: 'handle.com'},
+      {type: 'mention', value: 'handle.com'},
+    ],
+    [
+      {
+        type: 'mention',
+        value: 'full123.test-of-chars',
+      },
+    ],
+    [],
+    [{type: 'mention', value: 'handle.com'}],
+    [
+      {type: 'mention', value: 'handle.com'},
+      {type: 'mention', value: 'handle.com'},
+    ],
+    [{type: 'mention', value: 'handle.com'}],
+    [{type: 'link', value: 'https://middle.com'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}],
+    [{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}],
+    [{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}],
+    [
+      {type: 'link', value: 'https://newline1.com'},
+      {type: 'link', value: 'https://newline2.com'},
+    ],
+    [{type: 'link', value: 'middle.com', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [
+      {type: 'link', value: 'newline1.com', noScheme: true},
+      {type: 'link', value: 'newline2.com', noScheme: true},
+    ],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [],
+    [
+      {
+        type: 'link',
+        value:
+          'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+      },
+    ],
+    [
+      {
+        type: 'link',
+        value:
+          'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+      },
+    ],
+    [
+      {type: 'link', value: 'https://foo.com'},
+      {type: 'link', value: 'https://bar.com/whatever'},
+      {type: 'link', value: 'https://baz.com'},
+    ],
+    [
+      {type: 'link', value: 'https://foo.com'},
+      {type: 'link', value: 'https://bar.com/whatever'},
+      {type: 'link', value: 'https://baz.com'},
+    ],
+    [{type: 'link', value: 'https://foo.com'}],
+    [{type: 'link', value: 'https://foo.com/thing_(cool)'}],
+  ]
+  it('correctly handles a set of text inputs', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = extractEntities(input, knownHandles)
+      if (!outputs[i].length) {
+        expect(result).toBeFalsy()
+      } else if (outputs[i].length && !result) {
+        expect(result).toBeTruthy()
+      } else if (result) {
+        expect(result.length).toBe(outputs[i].length)
+        for (let j = 0; j < outputs[i].length; j++) {
+          expect(result[j].type).toEqual(outputs[i][j].type)
+          if (outputs[i][j].noScheme) {
+            expect(result[j].value).toEqual(`https://${outputs[i][j].value}`)
+          } else {
+            expect(result[j].value).toEqual(outputs[i][j].value)
+          }
+          if (outputs[i]?.[j].type === 'mention') {
+            expect(
+              input.slice(result[j].index.start, result[j].index.end),
+            ).toBe(`@${result[j].value}`)
+          } else {
+            if (!outputs[i]?.[j].noScheme) {
+              expect(
+                input.slice(result[j].index.start, result[j].index.end),
+              ).toBe(result[j].value)
+            } else {
+              expect(
+                input.slice(result[j].index.start, result[j].index.end),
+              ).toBe(result[j].value.slice('https://'.length))
+            }
+          }
+        }
+      }
+    }
+  })
+})
+
+describe('detectLinkables', () => {
+  const inputs = [
+    'no linkable',
+    '@start middle end',
+    'start @middle end',
+    'start middle @end',
+    '@start @middle @end',
+    '@full123.test-of-chars',
+    'not@right',
+    '@bad!@#$chars',
+    '@newline1\n@newline2',
+    'parenthetical (@handle)',
+    'start https://middle.com end',
+    'start https://middle.com/foo/bar end',
+    'start https://middle.com/foo/bar?baz=bux end',
+    'start https://middle.com/foo/bar?baz=bux#hash end',
+    'https://start.com/foo/bar?baz=bux#hash middle end',
+    'start middle https://end.com/foo/bar?baz=bux#hash',
+    'https://newline1.com\nhttps://newline2.com',
+    'start middle.com end',
+    'start middle.com/foo/bar end',
+    'start middle.com/foo/bar?baz=bux end',
+    'start middle.com/foo/bar?baz=bux#hash end',
+    'start.com/foo/bar?baz=bux#hash middle end',
+    'start middle end.com/foo/bar?baz=bux#hash',
+    'newline1.com\nnewline2.com',
+    'not.. a..url ..here',
+    'e.g.',
+    'e.g. real.com fake.notreal',
+    'something-cool.jpg',
+    'website.com.jpg',
+    'e.g./foo',
+    'website.com.jpg/foo',
+    'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+    'Classic article https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/ ',
+    'https://foo.com https://bar.com/whatever https://baz.com',
+    'punctuation https://foo.com, https://bar.com/whatever; https://baz.com.',
+    'parenthentical (https://foo.com)',
+    'except for https://foo.com/thing_(cool)',
+  ]
+  const outputs = [
+    ['no linkable'],
+    [{link: '@start'}, ' middle end'],
+    ['start ', {link: '@middle'}, ' end'],
+    ['start middle ', {link: '@end'}],
+    [{link: '@start'}, ' ', {link: '@middle'}, ' ', {link: '@end'}],
+    [{link: '@full123.test-of-chars'}],
+    ['not@right'],
+    [{link: '@bad'}, '!@#$chars'],
+    [{link: '@newline1'}, '\n', {link: '@newline2'}],
+    ['parenthetical (', {link: '@handle'}, ')'],
+    ['start ', {link: 'https://middle.com'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar?baz=bux'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar?baz=bux#hash'}, ' end'],
+    [{link: 'https://start.com/foo/bar?baz=bux#hash'}, ' middle end'],
+    ['start middle ', {link: 'https://end.com/foo/bar?baz=bux#hash'}],
+    [{link: 'https://newline1.com'}, '\n', {link: 'https://newline2.com'}],
+    ['start ', {link: 'middle.com'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar?baz=bux'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar?baz=bux#hash'}, ' end'],
+    [{link: 'start.com/foo/bar?baz=bux#hash'}, ' middle end'],
+    ['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
+    [{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
+    ['not.. a..url ..here'],
+    ['e.g.'],
+    ['e.g. ', {link: 'real.com'}, ' fake.notreal'],
+    ['something-cool.jpg'],
+    ['website.com.jpg'],
+    ['e.g./foo'],
+    ['website.com.jpg/foo'],
+    [
+      'Classic article ',
+      {
+        link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+      },
+    ],
+    [
+      'Classic article ',
+      {
+        link: 'https://socket3.wordpress.com/2018/02/03/designing-windows-95s-user-interface/',
+      },
+      ' ',
+    ],
+    [
+      {link: 'https://foo.com'},
+      ' ',
+      {link: 'https://bar.com/whatever'},
+      ' ',
+      {link: 'https://baz.com'},
+    ],
+    [
+      'punctuation ',
+      {link: 'https://foo.com'},
+      ', ',
+      {link: 'https://bar.com/whatever'},
+      '; ',
+      {link: 'https://baz.com'},
+      '.',
+    ],
+    ['parenthentical (', {link: 'https://foo.com'}, ')'],
+    ['except for ', {link: 'https://foo.com/thing_(cool)'}],
+  ]
+  it('correctly handles a set of text inputs', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const output = detectLinkables(input)
+      expect(output).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('extractHtmlMeta', () => {
+  const inputs = [
+    '',
+    'nothing',
+    '<title>title</title>',
+    '<title> aSd!@#AC </title>',
+    '<title>\n  title\n  </title>',
+    '<meta name="title" content="meta title">',
+    '<meta name="description" content="meta description">',
+    '<meta property="og:title" content="og title">',
+    '<meta property="og:description" content="og description">',
+    '<meta property="og:image" content="https://ogimage.com/foo.png">',
+    '<meta property="twitter:title" content="twitter title">',
+    '<meta property="twitter:description" content="twitter description">',
+    '<meta property="twitter:image" content="https://twitterimage.com/foo.png">',
+    '<meta\n  name="title"\n  content="meta title"\n>',
+  ]
+  const outputs = [
+    {},
+    {},
+    {title: 'title'},
+    {title: 'aSd!@#AC'},
+    {title: 'title'},
+    {title: 'meta title'},
+    {description: 'meta description'},
+    {title: 'og title'},
+    {description: 'og description'},
+    {image: 'https://ogimage.com/foo.png'},
+    {title: 'twitter title'},
+    {description: 'twitter description'},
+    {image: 'https://twitterimage.com/foo.png'},
+    {title: 'meta title'},
+  ]
+  it('correctly handles a set of text inputs', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const output = extractHtmlMeta(input)
+      expect(output).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('pluralize', () => {
+  const inputs: [number, string, string?][] = [
+    [1, 'follower'],
+    [1, 'member'],
+    [100, 'post'],
+    [1000, 'repost'],
+    [10000, 'upvote'],
+    [100000, 'other'],
+    [2, 'man', 'men'],
+  ]
+  const outputs = [
+    'follower',
+    'member',
+    'posts',
+    'reposts',
+    'upvotes',
+    'others',
+    'men',
+  ]
+
+  it('correctly pluralizes a set of words', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const output = pluralize(...input)
+      expect(output).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('makeRecordUri', () => {
+  const inputs: [string, string, string][] = [
+    ['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
+  ]
+  const outputs = ['at://alice.test/app.bsky.feed.post/3jk7x4irgv52r']
+
+  it('correctly builds a record URI', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = makeRecordUri(...input)
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('ago', () => {
+  const inputs = [
+    1671461038,
+    '04 Dec 1995 00:12:00 GMT',
+    new Date(),
+    new Date().setMinutes(new Date().getMinutes() - 10),
+    new Date().setHours(new Date().getHours() - 1),
+    new Date().setDate(new Date().getDate() - 1),
+    new Date().setMonth(new Date().getMonth() - 1),
+  ]
+  const outputs = [
+    new Date(1671461038).toLocaleDateString(),
+    new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
+    '0s',
+    '10m',
+    '1h',
+    '1d',
+    '1mo',
+  ]
+
+  it('correctly calculates how much time passed, in a string', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = ago(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('makeValidHandle', () => {
+  const inputs = [
+    'test-handle-123',
+    'test!"#$%&/()=?_',
+    'this-handle-should-be-too-big',
+  ]
+  const outputs = ['test-handle-123', 'test', 'this-handle-should-b']
+
+  it('correctly parses and corrects handles', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = makeValidHandle(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('createFullHandle', () => {
+  const inputs: [string, string][] = [
+    ['test-handle-123', 'test'],
+    ['.test.handle', 'test.test.'],
+    ['test.handle.', '.test.test'],
+  ]
+  const outputs = [
+    'test-handle-123.test',
+    '.test.handle.test.test.',
+    'test.handle.test.test',
+  ]
+
+  it('correctly parses and corrects handles', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = createFullHandle(...input)
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('enforceLen', () => {
+  const inputs: [string, number][] = [
+    ['Hello World!', 5],
+    ['Hello World!', 20],
+    ['', 5],
+  ]
+  const outputs = ['Hello', 'Hello World!', '']
+
+  it('correctly enforces defined length on a given string', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = enforceLen(...input)
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('cleanError', () => {
+  const inputs = [
+    'TypeError: Network request failed',
+    'Error: Aborted',
+    'Error: TypeError "x" is not a function',
+    'Error: SyntaxError unexpected token "export"',
+    'Some other error',
+  ]
+  const outputs = [
+    'Unable to connect. Please check your internet connection and try again.',
+    'Unable to connect. Please check your internet connection and try again.',
+    'TypeError "x" is not a function',
+    'SyntaxError unexpected token "export"',
+    'Some other error',
+  ]
+
+  it('removes extra content from error message', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = cleanError(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('toNiceDomain', () => {
+  const inputs = [
+    'https://example.com/index.html',
+    'https://bsky.app',
+    'https://bsky.social',
+    '#123123123',
+  ]
+  const outputs = ['example.com', 'bsky.app', 'Bluesky Social', '#123123123']
+
+  it("displays the url's host in a easily readable manner", () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = toNiceDomain(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('toShortUrl', () => {
+  const inputs = [
+    'https://bsky.app',
+    'https://bsky.app/3jk7x4irgv52r',
+    'https://bsky.app/3jk7x4irgv52r2313y182h9',
+  ]
+  const outputs = [
+    'bsky.app',
+    'bsky.app/3jk7x4irgv52r',
+    'bsky.app/3jk7x4irgv52r2313y...',
+  ]
+
+  it('shortens the url', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = toShortUrl(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})
+
+describe('toShareUrl', () => {
+  const inputs = ['https://bsky.app', '/3jk7x4irgv52r', 'item/test/123']
+  const outputs = [
+    'https://bsky.app',
+    'https://bsky.app/3jk7x4irgv52r',
+    'https://bsky.app/item/test/123',
+  ]
+
+  it('appends https, when not present', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const result = toShareUrl(inputs[i])
+      expect(result).toEqual(outputs[i])
+    }
+  })
+})