Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { ExperienceResponse } from '@contentful/optimization-api-schemas'
import { http, HttpResponse } from 'msw'
import CoreStateful, { type CoreStatefulConfig } from './CoreStateful'
import { batch, signals, type ExperienceRequestState } from './signals'
import { profile as profileFixture } from './test/fixtures/profile'
import { server } from './test/setup'

const EXPERIENCE_BASE_URL = 'https://experience.ninetailed.co/'

const config: CoreStatefulConfig = {
clientId: 'key_123',
environment: 'main',
defaults: { consent: true },
}

const SUCCESS_BODY: ExperienceResponse = {
data: {
profile: profileFixture,
experiences: [],
changes: [],
},
message: 'OK',
error: null,
}

const observe = (): {
states: ExperienceRequestState[]
unsubscribe: () => void
} => {
const states: ExperienceRequestState[] = []
const unsubscribe = signals.experienceRequestState.subscribe((value) => {
states.push(value)
})
return { states, unsubscribe }
}

describe('CoreStateful experienceRequestState end-to-end', () => {
const createdCores: CoreStateful[] = []

beforeEach(() => {
batch(() => {
signals.consent.value = undefined
signals.persistenceConsent.value = undefined
signals.profile.value = undefined
signals.selectedOptimizations.value = undefined
signals.changes.value = undefined
signals.experienceRequestState.value = { status: 'idle' }
signals.online.value = true
})
})

afterEach(() => {
while (createdCores.length > 0) {
const core = createdCores.pop()
core?.destroy()
}
batch(() => {
signals.experienceRequestState.value = { status: 'idle' }
signals.selectedOptimizations.value = undefined
})
})

const createCore = (overrides: Partial<CoreStatefulConfig> = {}): CoreStateful => {
const core = new CoreStateful({ ...config, ...overrides })
createdCores.push(core)
return core
}

it('publishes idle as the initial value to new subscribers', () => {
const { states, unsubscribe } = observe()
expect(states).toEqual([{ status: 'idle' }])
unsubscribe()
})

it('flips idle -> pending -> success when the Experience API responds 200', async () => {
server.use(
http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () =>
HttpResponse.json(SUCCESS_BODY, { status: 200 }),
),
)

const core = createCore()
const { states, unsubscribe } = observe()

await core.page({})

expect(states).toEqual([{ status: 'idle' }, { status: 'pending' }, { status: 'success' }])
expect(signals.selectedOptimizations.value).toBeDefined()

unsubscribe()
})

it('flips idle -> pending -> failed:api-error on a 500 response', async () => {
server.use(
http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () =>
HttpResponse.json({ error: 'kaboom' }, { status: 500 }),
),
)

const core = createCore()
const { states, unsubscribe } = observe()

await core.page({}).catch(() => undefined)

expect(states).toEqual([
{ status: 'idle' },
{ status: 'pending' },
{ status: 'failed', reason: 'api-error' },
])
expect(signals.selectedOptimizations.value).toBeUndefined()

unsubscribe()
})

// The api-client's retry layer rethrows aborted requests as a generic Error
// (`createRetryFetchMethod.ts:"may not be retried"`), which loses the original
// `AbortError` identity. Today the queue cannot distinguish a timeout from
// a non-2xx response and reports both as `failed:api-error`. The reason
// union still allows for `timeout` so a future api-client change that
// preserves error identity (or a non-retry-wrapped platform) can emit it
// without breaking the public contract.
it('flips idle -> pending -> failed:api-error when the request times out', async () => {
server.use(
http.post(
`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`,
async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
return HttpResponse.json(SUCCESS_BODY, { status: 200 })
},
),
)

const core = createCore({ fetchOptions: { requestTimeout: 25 } })
const { states, unsubscribe } = observe()

await core.page({}).catch(() => undefined)

expect(states).toEqual([
{ status: 'idle' },
{ status: 'pending' },
{ status: 'failed', reason: 'api-error' },
])
expect(signals.selectedOptimizations.value).toBeUndefined()

unsubscribe()
})

it('overwrites a terminal failed state with pending on the next request', async () => {
server.use(
http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () =>
HttpResponse.json({ error: 'kaboom' }, { status: 500 }),
),
)

const core = createCore()

await core.page({}).catch(() => undefined)
expect(signals.experienceRequestState.value).toEqual({
status: 'failed',
reason: 'api-error',
})

server.use(
http.post(`${EXPERIENCE_BASE_URL}v2/organizations/:org/environments/:env/profiles`, () =>
HttpResponse.json(SUCCESS_BODY, { status: 200 }),
),
)

const { states, unsubscribe } = observe()

await core.page({})

expect(states).toEqual([
{ status: 'failed', reason: 'api-error' },
{ status: 'pending' },
{ status: 'success' },
])

unsubscribe()
})
})
6 changes: 6 additions & 0 deletions packages/universal/core-sdk/src/CoreStateful.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
consent as consentSignal,
effect,
event as eventSignal,
type ExperienceRequestState,
experienceRequestState as experienceRequestStateSignal,
locale as localeSignal,
type Observable,
online as onlineSignal,
Expand Down Expand Up @@ -155,6 +157,8 @@ export interface CoreStates {
canOptimize: Observable<boolean>
/** Whether the current consent + allow-list configuration could ever produce optimizations. */
optimizationPossible: Observable<boolean>
/** Outcome of the most recent Experience API request. */
experienceRequestState: Observable<ExperienceRequestState>
}

/**
Expand Down Expand Up @@ -254,6 +258,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController
locale: toObservable(localeSignal),
canOptimize: toObservable(canOptimizeSignal),
optimizationPossible: toObservable(this.optimizationPossibleSignal),
experienceRequestState: toObservable(experienceRequestStateSignal),
selectedOptimizations: toObservable(selectedOptimizationsSignal),
previewPanelAttached: toObservable(previewPanelAttachedSignal),
previewPanelOpen: toObservable(previewPanelOpenSignal),
Expand Down Expand Up @@ -392,6 +397,7 @@ class CoreStateful extends CoreStatefulEventEmitter implements ConsentController
changesSignal.value = undefined
profileSignal.value = undefined
selectedOptimizationsSignal.value = undefined
experienceRequestStateSignal.value = { status: 'idle' }
})
}

Expand Down
2 changes: 2 additions & 0 deletions packages/universal/core-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export {
signals,
toDistinctObservable,
toObservable,
type ExperienceRequestFailureReason,
type ExperienceRequestState,
type Observable,
type Signal,
type SignalFns,
Expand Down
160 changes: 160 additions & 0 deletions packages/universal/core-sdk/src/queues/ExperienceQueue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type {
ExperienceEventArray,
OptimizationData,
} from '@contentful/optimization-api-client/api-schemas'
import { InterceptorManager } from '../lib/interceptor'
import { resolveQueueFlushPolicy } from '../lib/queue'
import {
experienceRequestState,
online as onlineSignal,
selectedOptimizations as selectedOptimizationsSignal,
type ExperienceRequestState,
} from '../signals'
import { profile as profileFixture } from '../test/fixtures/profile'
import { ExperienceQueue } from './ExperienceQueue'

const SAMPLE_DATA: OptimizationData = {
changes: [],
selectedOptimizations: [],
profile: profileFixture,
}

class ExperienceQueueTestHarness extends ExperienceQueue {
async invokeUpsert(events: ExperienceEventArray): Promise<OptimizationData> {
return await this.upsertProfile(events)
}
}

interface BuildQueueOptions {
upsertProfile?: (payload: {
profileId?: string
events: ExperienceEventArray
}) => Promise<OptimizationData>
}

const buildQueue = ({ upsertProfile }: BuildQueueOptions = {}): {
queue: ExperienceQueueTestHarness
upsertProfile: ReturnType<typeof rs.fn>
} => {
const upsertProfileMock =
upsertProfile !== undefined
? rs.fn(upsertProfile)
: rs.fn(async () => await Promise.resolve(SAMPLE_DATA))

const queue = new ExperienceQueueTestHarness({
experienceApi: { upsertProfile: upsertProfileMock },
eventInterceptors: new InterceptorManager(),
flushPolicy: resolveQueueFlushPolicy(undefined),
getAnonymousId: () => undefined,
offlineMaxEvents: 100,
stateInterceptors: new InterceptorManager(),
})

return { queue, upsertProfile: upsertProfileMock }
}

const observeRequestState = (): {
states: ExperienceRequestState[]
unsubscribe: () => void
} => {
const states: ExperienceRequestState[] = []
const unsubscribe = experienceRequestState.subscribe((value) => {
states.push(value)
})
return { states, unsubscribe }
}

describe('ExperienceQueue.experienceRequestState transitions', () => {
beforeEach(() => {
experienceRequestState.value = { status: 'idle' }
onlineSignal.value = true
selectedOptimizationsSignal.value = undefined
})

afterEach(() => {
experienceRequestState.value = { status: 'idle' }
selectedOptimizationsSignal.value = undefined
})

it('starts in the idle state', () => {
expect(experienceRequestState.value).toEqual({ status: 'idle' })
})

it('transitions pending -> success around a successful upsert', async () => {
const { queue } = buildQueue()
const { states, unsubscribe } = observeRequestState()

await queue.invokeUpsert([])

expect(states).toEqual([{ status: 'idle' }, { status: 'pending' }, { status: 'success' }])
expect(experienceRequestState.value).toEqual({ status: 'success' })

unsubscribe()
})

it('transitions pending -> failed:timeout when the request aborts', async () => {
const abortError = new Error('Aborted')
abortError.name = 'AbortError'
const { queue } = buildQueue({
upsertProfile: async () => {
await Promise.resolve()
throw abortError
},
})
const { states, unsubscribe } = observeRequestState()

await expect(queue.invokeUpsert([])).rejects.toBe(abortError)

expect(states).toEqual([
{ status: 'idle' },
{ status: 'pending' },
{ status: 'failed', reason: 'timeout' },
])
expect(experienceRequestState.value).toEqual({ status: 'failed', reason: 'timeout' })

unsubscribe()
})

it('transitions pending -> failed:api-error for non-abort failures', async () => {
const { queue } = buildQueue({
upsertProfile: async () => {
await Promise.resolve()
throw new Error('500 Internal Server Error')
},
})
const { states, unsubscribe } = observeRequestState()

await expect(queue.invokeUpsert([])).rejects.toThrow('500 Internal Server Error')

expect(states.at(-1)).toEqual({ status: 'failed', reason: 'api-error' })

unsubscribe()
})

it('overwrites a terminal failed state with pending on the next request', async () => {
let attempt = 0
const { queue } = buildQueue({
upsertProfile: async () => {
await Promise.resolve()
attempt += 1
if (attempt === 1) throw new Error('boom')
return SAMPLE_DATA
},
})

await expect(queue.invokeUpsert([])).rejects.toThrow('boom')
expect(experienceRequestState.value).toEqual({ status: 'failed', reason: 'api-error' })

const { states, unsubscribe } = observeRequestState()

await queue.invokeUpsert([])

expect(states).toEqual([
{ status: 'failed', reason: 'api-error' },
{ status: 'pending' },
{ status: 'success' },
])

unsubscribe()
})
})
Loading