Skip to content
Merged
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
61 changes: 60 additions & 1 deletion src/components/Search/SearchBar/client/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { __resetHeaderSearchForTests } from '@components/scripts/store/search'

type SearchBarModule = WebComponentModule<SearchBarElementInstance>

type ActionResult<TData> = { data?: TData; error?: { message?: string } }
type ActionResult<TData> = {
data?: TData
error?: { code?: string; message?: string; status?: number }
}

const searchQueryMock =
vi.fn<(_input: { q: string; limit?: number }) => Promise<ActionResult<{ hits: SearchHit[] }>>>()
const handleScriptErrorMock = vi.hoisted(() => vi.fn())

vi.mock('astro:actions', () => ({
actions: {
Expand All @@ -23,6 +27,10 @@ vi.mock('astro:actions', () => ({
},
}))

vi.mock('@components/scripts/errors/handler', () => ({
handleScriptError: handleScriptErrorMock,
}))

const flushMicrotasks = async () => {
await Promise.resolve()
await Promise.resolve()
Expand Down Expand Up @@ -53,6 +61,7 @@ describe('SearchBar web component', () => {
beforeEach(async () => {
container = await AstroContainer.create()
searchQueryMock.mockReset()
handleScriptErrorMock.mockReset()

__resetHeaderSearchForTests()

Expand Down Expand Up @@ -218,6 +227,56 @@ describe('SearchBar web component', () => {
vi.useRealTimers()
})

it('silently ignores forbidden action results', async () => {
vi.useFakeTimers()

await runComponentRender(async ({ element, window }) => {
searchQueryMock.mockResolvedValue({
error: {
code: 'FORBIDDEN',
message: 'HTTP Client Error with status code: 403',
status: 403,
},
})

const input = element.querySelector('[data-search-input]') as HTMLInputElement
const resultsContainer = element.querySelector('[data-search-results]') as HTMLElement

input.value = 'blocked'
input.dispatchEvent(new window.Event('input', { bubbles: true }))

await vi.advanceTimersByTimeAsync(260)
await flushMicrotasks()

expect(handleScriptErrorMock).not.toHaveBeenCalled()
expect(resultsContainer.classList.contains('hidden')).toBe(true)
})

vi.useRealTimers()
})

it('silently ignores forbidden thrown action errors', async () => {
vi.useFakeTimers()

await runComponentRender(async ({ element, window }) => {
searchQueryMock.mockRejectedValue(new Error('HTTP Client Error with status code: 403'))

const input = element.querySelector('[data-search-input]') as HTMLInputElement
const resultsContainer = element.querySelector('[data-search-results]') as HTMLElement

input.value = 'blocked'
input.dispatchEvent(new window.Event('input', { bubbles: true }))

await vi.advanceTimersByTimeAsync(260)
await flushMicrotasks()

expect(handleScriptErrorMock).not.toHaveBeenCalled()
expect(resultsContainer.classList.contains('hidden')).toBe(true)
})

vi.useRealTimers()
})

it('toggles open and closes on Escape in header variant', async () => {
await runHeaderComponentRender(async ({ element, window }) => {
const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement
Expand Down
73 changes: 48 additions & 25 deletions src/components/Search/SearchBar/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { render } from 'lit/html.js'
import { actions } from 'astro:actions'
import { defineCustomElement } from '@components/scripts/utils'
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
import {
isForbiddenClientActionError,
normalizeClientActionError,
} from '@components/scripts/errors/actionClient'
import { handleScriptError } from '@components/scripts/errors/handler'
import { addScriptBreadcrumb } from '@components/scripts/errors'
import {
Expand Down Expand Up @@ -670,38 +674,57 @@ export class SearchBarElement extends LitElement {
addScriptBreadcrumb(context)

const requestId = ++this.latestRequestId
const { data, error } = await actions.search.query({
q: query,
limit: HEADER_SEARCH_RESULT_LIMIT,
})
try {
const { data, error } = await actions.search.query({
q: query,
limit: HEADER_SEARCH_RESULT_LIMIT,
})
const actionError = normalizeClientActionError(error)

// @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do.
if (error) {
handleScriptError(error, context)
this.clearResults()
this.hideResults()
return
}
// @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do.
if (error) {
if (isForbiddenClientActionError(actionError)) {
this.clearResults()
this.hideResults()
return
}

if (!data) {
this.clearResults()
this.hideResults()
return
}
handleScriptError(error, context)
this.clearResults()
this.hideResults()
return
}

if (requestId !== this.latestRequestId) {
return
}
if (!data) {
this.clearResults()
this.hideResults()
return
}

const hits = (data.hits ?? []) as SearchHit[]
if (hits.length === 0) {
if (requestId !== this.latestRequestId) {
return
}

const hits = (data.hits ?? []) as SearchHit[]
if (hits.length === 0) {
this.clearResults()
this.hideResults()
return
}

this.renderResults(query, hits)
this.showResults()
} catch (error) {
if (isForbiddenClientActionError(normalizeClientActionError(error))) {
this.clearResults()
this.hideResults()
return
}

handleScriptError(error, context)
this.clearResults()
this.hideResults()
return
}

this.renderResults(query, hits)
this.showResults()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { executeRender } from '@test/unit/helpers/litRuntime'

type SearchResultsModule = WebComponentModule<SearchResultsElementInstance>

type ActionResult<TData> = { data?: TData; error?: { message?: string } }
type ActionResult<TData> = {
data?: TData
error?: { code?: string; message?: string; status?: number }
}

const searchQueryMock =
vi.fn<
Expand All @@ -16,6 +19,7 @@ const searchQueryMock =
limit?: number
}) => Promise<ActionResult<{ hits: { title: string; url: string; snippet?: string }[] }>>
>()
const handleScriptErrorMock = vi.hoisted(() => vi.fn())

vi.mock('astro:actions', () => ({
actions: {
Expand All @@ -25,6 +29,10 @@ vi.mock('astro:actions', () => ({
},
}))

vi.mock('@components/scripts/errors/handler', () => ({
handleScriptError: handleScriptErrorMock,
}))

const flushMicrotasks = async () => {
await Promise.resolve()
await Promise.resolve()
Expand All @@ -36,6 +44,7 @@ describe('SearchResults web component', () => {
beforeEach(async () => {
container = await AstroContainer.create()
searchQueryMock.mockReset()
handleScriptErrorMock.mockReset()
})

const runComponentRender = async (
Expand Down Expand Up @@ -197,6 +206,40 @@ describe('SearchResults web component', () => {
})
})

it('silently ignores forbidden action results', async () => {
searchQueryMock.mockResolvedValue({
error: {
code: 'FORBIDDEN',
message: 'HTTP Client Error with status code: 403',
status: 403,
},
})

await runComponentRender({ query: 'blocked' }, async ({ element }) => {
await flushMicrotasks()

expect(handleScriptErrorMock).not.toHaveBeenCalled()
expect(element.querySelector('[data-search-results] li')).toBeNull()

const error = element.querySelector('[data-search-error]')
expect(error?.classList.contains('hidden')).toBe(true)
})
})

it('silently ignores forbidden thrown action errors', async () => {
searchQueryMock.mockRejectedValue(new Error('HTTP Client Error with status code: 403'))

await runComponentRender({ query: 'blocked' }, async ({ element }) => {
await flushMicrotasks()

expect(handleScriptErrorMock).not.toHaveBeenCalled()
expect(element.querySelector('[data-search-results] li')).toBeNull()

const error = element.querySelector('[data-search-error]')
expect(error?.classList.contains('hidden')).toBe(true)
})
})

it('supports a custom limit without rendering the built-in empty state', async () => {
searchQueryMock.mockResolvedValue({
data: {
Expand Down
81 changes: 54 additions & 27 deletions src/components/Search/SearchResults/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { LitElement } from 'lit'
import { actions } from 'astro:actions'
import { defineCustomElement } from '@components/scripts/utils'
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
import {
isForbiddenClientActionError,
normalizeClientActionError,
} from '@components/scripts/errors/actionClient'
import { handleScriptError } from '@components/scripts/errors/handler'
import { addScriptBreadcrumb } from '@components/scripts/errors'
import { addButtonEventListeners } from '@components/scripts/elementListeners'
Expand Down Expand Up @@ -578,39 +582,62 @@ export class SearchResultsElement extends LitElement {
addScriptBreadcrumb(context)
const requestId = ++this.latestRequestId

const { data, error } = await actions.search.query({ q: query, limit: this.limit })
try {
const { data, error } = await actions.search.query({ q: query, limit: this.limit })
const actionError = normalizeClientActionError(error)

// @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do.
if (error) {
const message = error instanceof Error ? error.message : 'Search failed.'
handleScriptError(error, context)
this.renderResults([])
this.clearMeta()
this.showError(message)
return
}
// @TODO: Improve this error handling to be more user friendly. Should look at the types of errors that could occur, and give the user an idea of what to do.
if (error) {
const message = actionError?.message ?? (error instanceof Error ? error.message : 'Search failed.')

if (!data) {
this.renderResults([])
this.clearMeta()
return
}
if (isForbiddenClientActionError(actionError)) {
this.renderResults([])
this.clearMeta()
return
}

if (requestId !== this.latestRequestId) {
return
}
handleScriptError(error, context)
this.renderResults([])
this.clearMeta()
this.showError(message)
return
}

if (!data) {
this.renderResults([])
this.clearMeta()
return
}

const hits = (data.hits ?? []) as SearchHit[]
this.renderResults(hits)
if (hits.length === 0 && this.shouldSuppressMetaFeedback()) {
if (requestId !== this.latestRequestId) {
return
}

const hits = (data.hits ?? []) as SearchHit[]
this.renderResults(hits)
if (hits.length === 0 && this.shouldSuppressMetaFeedback()) {
this.clearMeta()
return
}

this.setMeta(
this.getResultsMetaMessage(query, hits),
hits.length > 0 || !this.shouldSuppressMetaFeedback()
)
} catch (error) {
const actionError = normalizeClientActionError(error)

if (isForbiddenClientActionError(actionError)) {
this.renderResults([])
this.clearMeta()
return
}

handleScriptError(error, context)
this.renderResults([])
this.clearMeta()
return
this.showError(actionError?.message ?? (error instanceof Error ? error.message : 'Search failed.'))
}

this.setMeta(
this.getResultsMetaMessage(query, hits),
hits.length > 0 || !this.shouldSuppressMetaFeedback()
)
}
}

Expand Down
Loading
Loading