Skip to content

Commit

Permalink
Add resolveActionAttempt
Browse files Browse the repository at this point in the history
  • Loading branch information
razor-x committed Nov 11, 2023
1 parent d7d29a3 commit 1f69599
Show file tree
Hide file tree
Showing 3 changed files with 295 additions and 0 deletions.
22 changes: 22 additions & 0 deletions src/lib/seam/connect/action-attempt-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// UPSTREAM: These types should be provided by @seamapi/types/connect.

export interface ActionAttempt {
action_attempt_id: string
status: 'pending' | 'error' | 'success'
}

export type SuccessfulActionAttempt<T extends ActionAttempt> = T & {
status: 'success'
}

export type PendingActionAttempt<T extends ActionAttempt> = T & {
status: 'pending'
}

export type FailedActionAttempt<T extends ActionAttempt> = T & {
status: 'error'
error: {
type: string
message: string
}
}
125 changes: 125 additions & 0 deletions src/lib/seam/connect/wait-for-action-attempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type {
ActionAttempt,
FailedActionAttempt,
SuccessfulActionAttempt,
} from './action-attempt-types.js'
import type { SeamHttp } from './seam-http.js'

interface Options {
timeout?: number
pollingInterval?: number
}

export const resolveActionAttempt = async <T extends ActionAttempt>(
actionAttempt: T,
seam: SeamHttp,
{ timeout = 5000, pollingInterval = 500 }: Options = {},
): Promise<SuccessfulActionAttempt<T>> => {
let timeoutRef
const timeoutPromise = new Promise<SuccessfulActionAttempt<T>>(
(_resolve, reject) => {
timeoutRef = globalThis.setTimeout(() => {
reject(new SeamActionAttemptTimeoutError<T>(actionAttempt, timeout))
}, timeout)
},
)

try {
return await Promise.race([
pollActionAttempt<T>(actionAttempt, seam, { pollingInterval }),
timeoutPromise,
])
} finally {
if (timeoutRef != null) globalThis.clearTimeout(timeoutRef)
}
}

const pollActionAttempt = async <T extends ActionAttempt>(
actionAttempt: T,
seam: SeamHttp,
options: Pick<Options, 'pollingInterval'>,
): Promise<SuccessfulActionAttempt<T>> => {
if (isSuccessfulActionAttempt(actionAttempt)) {
return actionAttempt
}

if (isFailedActionAttempt(actionAttempt)) {
throw new SeamActionAttemptFailedError(actionAttempt)
}

await new Promise((resolve) => setTimeout(resolve, options.pollingInterval))

const nextActionAttempt = await seam.actionAttempts.get({
action_attempt_id: actionAttempt.action_attempt_id,
})

return await pollActionAttempt(
nextActionAttempt as unknown as T,
seam,
options,
)
}

export const isSeamActionAttemptError = <T extends ActionAttempt>(
error: unknown,
): error is SeamActionAttemptError<T> => {
return error instanceof SeamActionAttemptError
}

export class SeamActionAttemptError<T extends ActionAttempt> extends Error {
actionAttempt: T

constructor(message: string, actionAttempt: T) {
super(message)
this.name = this.constructor.name
this.actionAttempt = actionAttempt
Error.captureStackTrace(this, this.constructor)
}
}

export const isSeamActionAttemptFailedError = <T extends ActionAttempt>(
error: unknown,
): error is SeamActionAttemptFailedError<T> => {
return error instanceof SeamActionAttemptFailedError
}

export class SeamActionAttemptFailedError<
T extends ActionAttempt,
> extends SeamActionAttemptError<T> {
code: string

constructor(actionAttempt: FailedActionAttempt<T>) {
super(actionAttempt.error.message, actionAttempt)
this.name = this.constructor.name
this.code = actionAttempt.error.type
Error.captureStackTrace(this, this.constructor)
}
}

export const isSeamActionAttemptTimeoutError = <T extends ActionAttempt>(
error: unknown,
): error is SeamActionAttemptTimeoutError<T> => {
return error instanceof SeamActionAttemptTimeoutError
}

export class SeamActionAttemptTimeoutError<
T extends ActionAttempt,
> extends SeamActionAttemptError<T> {
constructor(actionAttempt: T, timeout: number) {
super(
`Timed out waiting for action action attempt after ${timeout}ms`,
actionAttempt,
)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}

const isSuccessfulActionAttempt = <T extends ActionAttempt>(
actionAttempt: T,
): actionAttempt is SuccessfulActionAttempt<T> =>
actionAttempt.status === 'success'

const isFailedActionAttempt = <T extends ActionAttempt>(
actionAttempt: T,
): actionAttempt is FailedActionAttempt<T> => actionAttempt.status === 'error'
148 changes: 148 additions & 0 deletions test/seam/connect/resolve-action-attempt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import test from 'ava'
import { getTestServer } from 'fixtures/seam/connect/api.js'

import { SeamHttp } from '@seamapi/http/connect'

import {
resolveActionAttempt,
SeamActionAttemptFailedError,
SeamActionAttemptTimeoutError,
} from 'lib/seam/connect/wait-for-action-attempt.js'

test('resolveActionAttempt: waits for pending action attempt', async (t) => {
const { seed, endpoint, db } = await getTestServer(t)

const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
endpoint,
})

const actionAttempt = await seam.locks.unlockDoor({
device_id: seed.august_device_1,
})

t.is(actionAttempt.status, 'pending')

setTimeout(() => {
db.updateActionAttempt({
action_attempt_id: actionAttempt.action_attempt_id,
status: 'success',
})
}, 1000)

const { status } = await resolveActionAttempt(actionAttempt, seam)
t.is(status, 'success')
})

test('resolveActionAttempt: returns successful action attempt', async (t) => {
const { seed, endpoint, db } = await getTestServer(t)

const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
endpoint,
})

const actionAttempt = await seam.locks.unlockDoor({
device_id: seed.august_device_1,
})

t.is(actionAttempt.status, 'pending')

db.updateActionAttempt({
action_attempt_id: actionAttempt.action_attempt_id,
status: 'success',
})

const successfulActionAttempt = await seam.actionAttempts.get({
action_attempt_id: actionAttempt.action_attempt_id,
})

if (successfulActionAttempt.status !== 'success') {
t.fail('Action attempt status did not update to success')
return
}

const resolvedActionAttempt = await resolveActionAttempt(
successfulActionAttempt,
seam,
)

t.is(resolvedActionAttempt, successfulActionAttempt)
})

test('resolveActionAttempt: times out while waiting for action attempt', async (t) => {
const { seed, endpoint } = await getTestServer(t)

const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
endpoint,
})

const actionAttempt = await seam.locks.unlockDoor({
device_id: seed.august_device_1,
})

t.is(actionAttempt.status, 'pending')

const err = await t.throwsAsync(
async () =>
await resolveActionAttempt(actionAttempt, seam, {
timeout: 100,
}),
{ instanceOf: SeamActionAttemptTimeoutError },
)

t.is(err?.actionAttempt, actionAttempt)
})

test('resolveActionAttempt: rejects when action attempt fails', async (t) => {
const { seed, endpoint, db } = await getTestServer(t)

const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
endpoint,
})

const actionAttempt = await seam.locks.unlockDoor({
device_id: seed.august_device_1,
})

t.is(actionAttempt.status, 'pending')

db.updateActionAttempt({
action_attempt_id: actionAttempt.action_attempt_id,
status: 'error',
error: {
message: 'Failed',
type: 'foo',
},
})

const err = await t.throwsAsync(
async () => await resolveActionAttempt(actionAttempt, seam),
{ instanceOf: SeamActionAttemptFailedError, message: 'Failed' },
)

t.is(err?.actionAttempt.action_attempt_id, actionAttempt.action_attempt_id)
t.is(err?.actionAttempt.status, 'error')
t.is(err?.code, 'foo')
})

test('resolveActionAttempt: times out if waiting for polling interval', async (t) => {
const { seed, endpoint } = await getTestServer(t)

const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
endpoint,
})

const actionAttempt = await seam.locks.unlockDoor({
device_id: seed.august_device_1,
})

const err = await t.throwsAsync(
async () =>
await resolveActionAttempt(actionAttempt, seam, {
timeout: 500,
pollingInterval: 10_000,
}),
{ instanceOf: SeamActionAttemptTimeoutError },
)

t.is(err?.actionAttempt, actionAttempt)
})

0 comments on commit 1f69599

Please sign in to comment.