Skip to content
Merged
21 changes: 18 additions & 3 deletions frontend/taskdeck-web/src/api/cardsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,23 @@ export const cardsApi = {
await http.delete(`/boards/${boardId}/cards/${cardId}`)
},

async getCardProvenance(boardId: string, cardId: string): Promise<CardCaptureProvenance> {
const { data } = await http.get<CardCaptureProvenance>(`/boards/${boardId}/cards/${cardId}/provenance`)
return data
async getCardProvenance(boardId: string, cardId: string): Promise<CardCaptureProvenance | null> {
try {
const { data } = await http.get<CardCaptureProvenance>(`/boards/${boardId}/cards/${cardId}/provenance`)
return data
} catch (e: unknown) {
const candidate = e as { response?: { status?: number; data?: { message?: string } } } | null
if (
candidate?.response?.status === 404 &&
typeof candidate.response.data?.message === 'string' &&
candidate.response.data.message.startsWith('Capture provenance not found')
) {
// Manual cards have no capture provenance — treat only that specific absence as
// empty state, not an error. Other 404s (e.g. card not found in board) are rethrown
// so callers can surface them as genuine errors.
return null
}
throw e
}
},
}
2 changes: 1 addition & 1 deletion frontend/taskdeck-web/src/components/board/CardModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ onBeforeUnmount(() => {
Triage run: {{ captureProvenance.triageRunId }}
</p>
</div>
<p v-else class="text-xs text-on-surface-variant italic">No capture provenance available.</p>
<p v-else-if="loadedCaptureProvenanceCardId === card.id" class="text-xs text-on-surface-variant italic" data-testid="provenance-empty-state">Created manually — no capture provenance.</p>
</div>
</div>
</div>
Expand Down
6 changes: 2 additions & 4 deletions frontend/taskdeck-web/src/store/board/cardStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,10 @@ export function createCardActions(state: BoardState, helpers: BoardHelpers) {
): Promise<CardCaptureProvenance | null> {
if (helpers.isDemoMode) return null
try {
// cardsApi.getCardProvenance already returns null for 404 (manual cards have no
// capture provenance — absence is expected, not exceptional).
return await cardsApi.getCardProvenance(boardId, cardId)
} catch (e: unknown) {
if (helpers.isHttpNotFound(e)) {
return null
}

helpers.handleApiError(e, 'Failed to fetch card provenance')
throw e
}
Expand Down
33 changes: 33 additions & 0 deletions frontend/taskdeck-web/src/tests/api/cardsApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,38 @@ describe('cardsApi', () => {
expect(http.get).toHaveBeenCalledWith('/boards/board-1/cards/card-1/provenance')
expect(result).toEqual(provenance)
})

it('should return null for manually-created cards with no capture provenance (404 + provenance message)', async () => {
const notFoundError = {
response: {
status: 404,
data: { errorCode: 'NotFound', message: 'Capture provenance not found for card manual-card-1' },
},
}
vi.mocked(http.get).mockRejectedValue(notFoundError)

const result = await cardsApi.getCardProvenance('board-1', 'manual-card-1')

expect(result).toBeNull()
})

it('should rethrow 404 when the card itself is not found (card-not-found, not provenance-absent)', async () => {
const cardNotFoundError = {
response: {
status: 404,
data: { errorCode: 'NotFound', message: 'Card with ID stale-id not found in board board-1' },
},
}
vi.mocked(http.get).mockRejectedValue(cardNotFoundError)

await expect(cardsApi.getCardProvenance('board-1', 'stale-id')).rejects.toEqual(cardNotFoundError)
})

it('should rethrow non-404 errors from the provenance endpoint', async () => {
const serverError = { response: { status: 500, data: { errorCode: 'InternalError' } } }
vi.mocked(http.get).mockRejectedValue(serverError)

await expect(cardsApi.getCardProvenance('board-1', 'card-1')).rejects.toEqual(serverError)
})
})
})
10 changes: 7 additions & 3 deletions frontend/taskdeck-web/src/tests/components/CardModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ describe('CardModal', () => {
})
})

it('should render fallback message when capture provenance is unavailable', async () => {
it('should render "Created manually" empty state when capture provenance is unavailable (manual card)', async () => {
mockStore.fetchCardProvenance.mockResolvedValue(null)

const wrapper = mount(CardModal, {
Expand All @@ -403,7 +403,11 @@ describe('CardModal', () => {
await Promise.resolve()
await wrapper.vm.$nextTick()

expect(wrapper.text()).toContain('No capture provenance available.')
const emptyState = wrapper.find('[data-testid="provenance-empty-state"]')
expect(emptyState.exists()).toBe(true)
expect(emptyState.text()).toContain('Created manually')
// No error alert should be shown for an expected empty state
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})

it('should render a provenance error message when capture provenance fetch fails', async () => {
Expand All @@ -423,6 +427,6 @@ describe('CardModal', () => {

expect(mockStore.fetchCardProvenance).toHaveBeenCalledWith('board-1', 'card-1')
expect(wrapper.text()).toContain('Unable to load capture provenance.')
expect(wrapper.text()).not.toContain('No capture provenance available.')
expect(wrapper.find('[data-testid="provenance-empty-state"]').exists()).toBe(false)
})
})
Loading