test: refactor

This commit is contained in:
Nolan Lawson 2024-03-17 09:56:26 -07:00
parent c820e67be6
commit a627dbf933
4 changed files with 102 additions and 72 deletions

View File

@ -21,6 +21,7 @@ import {
import { customEmojiIndex } from './customEmojiIndex'
import { cleanEmoji } from './utils/cleanEmoji'
import { loadDataForFirstTime, checkForUpdates } from './dataLoading'
import { isSignalAborted } from './utils/isSignalAborted.js'
export default class Database {
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
@ -36,17 +37,17 @@ export default class Database {
}
async _init () {
const controller = this._controller = new AbortController() // used to cancel ongoing requests if necessary
const controller = this._controller = new AbortController() // used to cancel inflight requests if necessary
const { signal } = controller
const db = this._db = await openDatabase(this._dbName)
addOnCloseListener(this._dbName, this._clear)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
const dataSource = this.dataSource
const empty = await isEmpty(db)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}

View File

@ -1,18 +1,19 @@
import { getETag, getETagAndData } from './utils/ajax'
import { jsonChecksum } from './utils/jsonChecksum'
import { hasData, loadData } from './idbInterface'
import { isSignalAborted } from './utils/isSignalAborted.js'
export async function checkForUpdates (db, dataSource, signal) {
// just do a simple HEAD request first to see if the eTags match
let emojiData
let eTag = await getETag(dataSource)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
const eTagAndData = await getETagAndData(dataSource)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
@ -20,13 +21,13 @@ export async function checkForUpdates (db, dataSource, signal) {
emojiData = eTagAndData[1]
if (!eTag) {
eTag = await jsonChecksum(emojiData)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
}
}
const doesHaveData = await hasData(db, dataSource, eTag)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
@ -36,7 +37,7 @@ export async function checkForUpdates (db, dataSource, signal) {
console.log('Database update available')
if (!emojiData) {
const eTagAndData = await getETagAndData(dataSource)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
@ -48,7 +49,7 @@ export async function checkForUpdates (db, dataSource, signal) {
export async function loadDataForFirstTime (db, dataSource, signal) {
let [eTag, emojiData] = await getETagAndData(dataSource)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
@ -56,7 +57,7 @@ export async function loadDataForFirstTime (db, dataSource, signal) {
// Handle lack of support for ETag or Access-Control-Expose-Headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility
eTag = await jsonChecksum(emojiData)
if (signal.aborted) {
if (isSignalAborted(signal)) {
return
}
}

View File

@ -0,0 +1,16 @@
export let signalAbortedEventTarget
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
signalAbortedEventTarget = new EventTarget()
}
export function isSignalAborted (signal) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
// we need some way to test that someone called signal.aborted in our tests
signalAbortedEventTarget.dispatchEvent(new CustomEvent('called'))
}
return signal.aborted
}

View File

@ -7,74 +7,86 @@ import {
tick, truncatedFrEmoji
} from '../shared.js'
import Database from '../../../src/database/Database.js'
import { signalAbortedEventTarget } from '../../../src/database/utils/isSignalAborted.js'
import { mockFetch, mockGetAndHead } from '../mockFetch.js'
const waitForSignalAbortCalledNTimes = async (n) => {
for (let i = 0; i < n; i++) {
await Promise.race([
new Promise(resolve => {
signalAbortedEventTarget.addEventListener('called', resolve, { once: true })
}),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out waiting for signal.aborted call')), 1000))
])
}
}
function runTest ({ secondLoad, dataChanged, dataSource, signalAbortedCallCount }) {
test(`throws no errors when DB is closed after ${signalAbortedCallCount} signal.aborted calls`, async () => {
if (secondLoad) {
// do a throwaway first load
const db = new Database({ dataSource })
await db.ready()
await db.close()
await tick(40)
}
if (dataChanged) {
// second time - update, data is v2
fetch.reset()
if (dataSource === ALL_EMOJI_NO_ETAG) {
mockGetAndHead(dataSource, truncatedFrEmoji)
} else if (dataSource === ALL_EMOJI_MISCONFIGURED_ETAG) {
mockFetch('get', dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
mockFetch('head', dataSource, null)
} else {
mockGetAndHead(dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
}
}
const db2 = new Database({ dataSource })
await waitForSignalAbortCalledNTimes(signalAbortedCallCount)
await db2.close()
await tick(40)
})
}
describe('database timing tests', () => {
beforeEach(basicBeforeEach)
afterEach(basicAfterEach)
const dataChanges = [false, true]
dataChanges.forEach(dataChanged => {
describe(`dataChanged=${dataChanged}`, () => {
const scenarios = [
{
testName: 'basic',
dataSource: ALL_EMOJI
},
{
testName: 'misconfigured etag server',
dataSource: ALL_EMOJI_MISCONFIGURED_ETAG
},
{
testName: 'missing etag server',
dataSource: ALL_EMOJI_NO_ETAG
}
]
scenarios.forEach(({ testName, dataSource }) => {
describe(testName, () => {
const secondLoads = [false, true]
secondLoads.forEach(secondLoad => {
describe(`secondLoad=${secondLoad}`, () => {
const dataChangeds = secondLoad ? [false, true] : [false]
dataChangeds.forEach(dataChanged => {
describe(`dataChanged=${dataChanged}`, () => {
const scenarios = [
{
testName: 'basic',
dataSource: ALL_EMOJI,
maxExpectedSignalAbortedCallCount: secondLoad ? (dataChanged ? 6 : 5) : 4
},
{
testName: 'misconfigured etag',
dataSource: ALL_EMOJI_MISCONFIGURED_ETAG,
maxExpectedSignalAbortedCallCount: secondLoad ? 6 : 4
},
{
testName: 'no etag',
dataSource: ALL_EMOJI_NO_ETAG,
maxExpectedSignalAbortedCallCount: secondLoad ? 7 : 5
}
]
scenarios.forEach(({ testName, dataSource, maxExpectedSignalAbortedCallCount }) => {
// Number of times somebody called the getter on `signal.aborted` which
// we are using as an easy way to get full code coverage here
const signalAbortedCallCounts = new Array(maxExpectedSignalAbortedCallCount).fill().map((_, i) => i)
let descriptor
let onSignalAbortedCalled = new EventTarget()
beforeEach(() => {
descriptor = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')
Object.defineProperty(AbortSignal.prototype, 'aborted', {
get() {
console.info('signal dot aborted called')
onSignalAbortedCalled.dispatchEvent(new CustomEvent('signal-dot-aborted'))
return descriptor.get.apply(this)
},
set(val) {
return descriptor.set.apply(this, val)
}
})
})
afterEach(() => {
Object.defineProperty(AbortSignal.prototype, 'aborted', descriptor)
})
// Number of times somebody called the getter on `signal.aborted` which
// we are using as an easy way to get full code coverage here
const signalAbortedCheckCounts = new Array(5).fill().map((_, i) => i)
signalAbortedCheckCounts.forEach(count => {
test(`throws no errors when DB is canceled after ${count} ticks`, async () => {
// first load
const db = new Database({ dataSource })
await tick(count)
await db.close()
await tick(40)
if (dataChanged) {
// second time - update, data is v2
fetch.reset()
fetch.get(dataSource, () => new Response(JSON.stringify(truncatedFrEmoji), { headers: { ETag: 'W/yyy' } }), { delay: 1 })
fetch.head(dataSource, () => new Response(null, { headers: { ETag: 'W/yyy' } }), { delay: 1 })
}
// second load
const db2 = new Database({ dataSource })
await tick(count)
await db2.close()
await tick(40)
signalAbortedCallCounts.forEach(signalAbortedCallCount => {
describe(testName, () => {
runTest({ secondLoad, dataChanged, dataSource, signalAbortedCallCount })
})
})
})
})