-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
295 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |