mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Every first-party Python caller and test now names the mount the API actually answers on. Mechanical: URL literals only, and only where the /api/ was ours -- a preceding word character means it was a module path like insights/api/ or a third-party host like slack.com/api/, and those are untouched. Left saying /api/, each for a reason rather than for safety: - products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints - services/llm-gateway deploys separately and is under a code freeze, so moving its two calls would break it during the skew - the mcp_store and Slack/Teams redirect_uris are registered with third parties and must byte-match what was registered - the Wizard and Raycast client-metadata paths are OAuth client_ids - /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the ingress routes to the Rust fleets, held by released SDK versions Test URLconfs moved with the requests that call them, and the Datastore user-selection fixtures now carry the leading slash that request.path has.
373 lines
14 KiB
TypeScript
373 lines
14 KiB
TypeScript
import { MOCK_TEAM_ID } from 'lib/api.mock'
|
|
|
|
import { router } from 'kea-router'
|
|
import { expectLogic } from 'kea-test-utils'
|
|
import insights from 'insights-js'
|
|
|
|
import { useMocks } from '~/mocks/jest'
|
|
import { initKeaTests } from '~/test/init'
|
|
|
|
import {
|
|
RecurrenceIntervalEnumApi,
|
|
ResourceTypeEnumApi,
|
|
SubscriptionsDeliveriesListStatus,
|
|
TargetTypeEnumApi,
|
|
} from 'products/subscriptions/frontend/generated/api.schemas'
|
|
import type { SubscriptionApi } from 'products/subscriptions/frontend/generated/api.schemas'
|
|
|
|
import { subscriptionSceneLogic } from './subscriptionSceneLogic'
|
|
|
|
const MOCK_USER = {
|
|
id: 1,
|
|
uuid: '01863799-062b-0000-8a61-b2842d5f8642',
|
|
email: '[email protected]',
|
|
first_name: 'Test',
|
|
last_name: 'User',
|
|
mascot_config: null,
|
|
} as const
|
|
|
|
const MOCK_SUBSCRIPTION: SubscriptionApi = {
|
|
id: 1,
|
|
resource_type: ResourceTypeEnumApi.Insight,
|
|
insight: 101,
|
|
dashboard: null,
|
|
insight_short_id: 'abc123',
|
|
resource_name: 'North star metric',
|
|
title: 'Weekly rollup',
|
|
dashboard_export_insights: [],
|
|
target_type: TargetTypeEnumApi.Email,
|
|
target_value: '[email protected]',
|
|
frequency: RecurrenceIntervalEnumApi.Weekly,
|
|
interval: 1,
|
|
start_date: '2022-01-01T00:00:00Z',
|
|
created_at: '2023-04-27T10:04:37.977401Z',
|
|
created_by: MOCK_USER,
|
|
summary: 'sent every week',
|
|
next_delivery_date: '2026-04-07T17:00:00Z',
|
|
deleted: false,
|
|
}
|
|
|
|
const MOCK_AI_SUBSCRIPTION: SubscriptionApi = {
|
|
id: 2,
|
|
resource_type: ResourceTypeEnumApi.AiPrompt,
|
|
insight: null,
|
|
dashboard: null,
|
|
insight_short_id: null,
|
|
resource_name: null,
|
|
prompt: 'Summarize weekly signups and flag any anomalies',
|
|
title: 'Weekly AI digest',
|
|
dashboard_export_insights: [],
|
|
target_type: TargetTypeEnumApi.Email,
|
|
target_value: '[email protected]',
|
|
frequency: RecurrenceIntervalEnumApi.Weekly,
|
|
interval: 1,
|
|
start_date: '2022-01-01T00:00:00Z',
|
|
created_at: '2023-04-27T10:04:37.977401Z',
|
|
created_by: MOCK_USER,
|
|
summary: 'sent every week',
|
|
next_delivery_date: '2026-04-07T17:00:00Z',
|
|
deleted: false,
|
|
}
|
|
|
|
describe('subscriptionSceneLogic', () => {
|
|
let deliveriesRequestUrls: string[]
|
|
|
|
beforeEach(() => {
|
|
deliveriesRequestUrls = []
|
|
// deliveryFeedback persists to localStorage; clear it so recorded feedback can't leak between tests.
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('includes status in deliveries list request when status filter is set', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/`]: [200, MOCK_SUBSCRIPTION],
|
|
[`/v1/environments/${MOCK_TEAM_ID}/subscriptions/1/deliveries/`]: ({ request }) => {
|
|
deliveriesRequestUrls.push(request.url)
|
|
return [200, { results: [], next: null, previous: null }]
|
|
},
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/deliveries/`]: ({ request }) => {
|
|
deliveriesRequestUrls.push(request.url)
|
|
return [200, { results: [], next: null, previous: null }]
|
|
},
|
|
},
|
|
})
|
|
initKeaTests()
|
|
|
|
const logic = subscriptionSceneLogic({ id: '1' })
|
|
logic.mount()
|
|
await expectLogic(logic).toFinishAllListeners()
|
|
expect(deliveriesRequestUrls).toHaveLength(1)
|
|
expect(deliveriesRequestUrls[0]).not.toContain('status=')
|
|
|
|
await expectLogic(logic, () => {
|
|
logic.actions.setDeliveryStatusFilter(SubscriptionsDeliveriesListStatus.Failed)
|
|
}).toFinishAllListeners()
|
|
|
|
expect(deliveriesRequestUrls).toHaveLength(2)
|
|
expect(deliveriesRequestUrls[1]).toContain('status=failed')
|
|
logic.unmount()
|
|
})
|
|
|
|
it('loads delivery history once the subscription has loaded', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/`]: [200, MOCK_SUBSCRIPTION],
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/deliveries/`]: () => {
|
|
deliveriesRequestUrls.push('deliveries')
|
|
return [200, { results: [], next: null, previous: null }]
|
|
},
|
|
},
|
|
})
|
|
initKeaTests()
|
|
|
|
const logic = subscriptionSceneLogic({ id: '1' })
|
|
logic.mount()
|
|
|
|
await expectLogic(logic).toFinishAllListeners()
|
|
expect(deliveriesRequestUrls).toHaveLength(1)
|
|
logic.unmount()
|
|
})
|
|
|
|
// The failure path matters too: the header button's double-submit guard would stick
|
|
// if deliveringSubscriptionId reset only on success.
|
|
it.each([
|
|
{ name: 'success', status: 202, terminalAction: 'deliverSubscriptionSuccess' },
|
|
{ name: 'failure', status: 500, terminalAction: 'deliverSubscriptionFailure' },
|
|
])('test delivery ($name) flips deliveringSubscriptionId then resets it', async ({ status, terminalAction }) => {
|
|
let testDeliveryCalls = 0
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/`]: [200, MOCK_SUBSCRIPTION],
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/deliveries/`]: [
|
|
200,
|
|
{ results: [], next: null, previous: null },
|
|
],
|
|
},
|
|
post: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/1/test-delivery/`]: () => {
|
|
testDeliveryCalls += 1
|
|
return [status, {}]
|
|
},
|
|
},
|
|
})
|
|
initKeaTests()
|
|
|
|
const logic = subscriptionSceneLogic({ id: '1' })
|
|
logic.mount()
|
|
await expectLogic(logic).toFinishAllListeners()
|
|
|
|
await expectLogic(logic, () => {
|
|
logic.actions.deliverSubscription(1)
|
|
}).toMatchValues({ deliveringSubscriptionId: 1 })
|
|
|
|
await expectLogic(logic).toDispatchActions([terminalAction]).toMatchValues({
|
|
deliveringSubscriptionId: null,
|
|
})
|
|
expect(testDeliveryCalls).toEqual(1)
|
|
logic.unmount()
|
|
})
|
|
|
|
it('loads an AI prompt subscription and its deliveries', async () => {
|
|
useMocks({
|
|
get: {
|
|
// Function form, not the `[200, body]` shorthand: useMocks serializes a bare array as
|
|
// the whole response body, so only a function-returned tuple delivers the object itself.
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/deliveries/`]: () => {
|
|
deliveriesRequestUrls.push('deliveries')
|
|
return [200, { results: [], next: null, previous: null }]
|
|
},
|
|
},
|
|
})
|
|
initKeaTests()
|
|
|
|
const logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
|
|
await expectLogic(logic).toFinishAllListeners()
|
|
expect(logic.values.subscription?.resource_type).toEqual(ResourceTypeEnumApi.AiPrompt)
|
|
expect(logic.values.subscription?.prompt).toBeTruthy()
|
|
expect(deliveriesRequestUrls).toHaveLength(1)
|
|
logic.unmount()
|
|
})
|
|
|
|
it.each([
|
|
['positive' as const, 'email'],
|
|
['negative' as const, 'slack'],
|
|
])('captures ai_report_feedback (%s, %s) from URL params and strips them', async (feedback, source) => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
},
|
|
})
|
|
initKeaTests()
|
|
const captureSpy = jest.spyOn(insights, 'capture')
|
|
|
|
const logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
await expectLogic(logic, () => {
|
|
router.actions.push('/subscriptions/2', {
|
|
feedback_delivery: 'd-123',
|
|
feedback,
|
|
feedback_source: source,
|
|
})
|
|
}).toFinishAllListeners()
|
|
|
|
expect(captureSpy).toHaveBeenCalledWith('ai_report_feedback', {
|
|
subscription_id: 2,
|
|
delivery_id: 'd-123',
|
|
feedback,
|
|
source,
|
|
previous_feedback: null,
|
|
})
|
|
// A feedback landing is also a click-through on the delivered report.
|
|
expect(captureSpy).toHaveBeenCalledWith('ai_report_clicked', {
|
|
subscription_id: 2,
|
|
delivery_id: 'd-123',
|
|
link: 'feedback',
|
|
source,
|
|
})
|
|
// The replace must remove the params so a refresh doesn't double-capture.
|
|
expect(router.values.searchParams).toEqual({})
|
|
expect(captureSpy.mock.calls.filter(([event]) => event === 'ai_report_feedback')).toHaveLength(1)
|
|
|
|
logic.unmount()
|
|
captureSpy.mockRestore()
|
|
})
|
|
|
|
it('captures ai_report_clicked from the report CTA delivery param and strips it', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
},
|
|
})
|
|
initKeaTests()
|
|
const captureSpy = jest.spyOn(insights, 'capture')
|
|
|
|
const logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
await expectLogic(logic, () => {
|
|
router.actions.push('/subscriptions/2', { delivery: 'd-123', utm_medium: 'email' })
|
|
}).toFinishAllListeners()
|
|
|
|
expect(captureSpy).toHaveBeenCalledWith('ai_report_clicked', {
|
|
subscription_id: 2,
|
|
delivery_id: 'd-123',
|
|
link: 'manage',
|
|
source: 'email',
|
|
})
|
|
expect(captureSpy.mock.calls.filter(([event]) => event === 'ai_report_feedback')).toHaveLength(0)
|
|
// Only the delivery param is consumed — utm params stay for insights-js.
|
|
expect(router.values.searchParams).toEqual({ utm_medium: 'email' })
|
|
|
|
logic.unmount()
|
|
captureSpy.mockRestore()
|
|
})
|
|
|
|
it('does not re-capture from a feedback link for an already-recorded delivery', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
},
|
|
})
|
|
initKeaTests()
|
|
const captureSpy = jest.spyOn(insights, 'capture')
|
|
|
|
const logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
await expectLogic(logic, () => {
|
|
logic.actions.submitDeliveryFeedback('d-123', 'positive', 'in_app')
|
|
}).toFinishAllListeners()
|
|
captureSpy.mockClear()
|
|
|
|
await expectLogic(logic, () => {
|
|
router.actions.push('/subscriptions/2', {
|
|
feedback_delivery: 'd-123',
|
|
feedback: 'negative',
|
|
feedback_source: 'email',
|
|
})
|
|
}).toFinishAllListeners()
|
|
|
|
expect(captureSpy.mock.calls.filter(([event]) => event === 'ai_report_feedback')).toHaveLength(0)
|
|
// Params are still stripped, and the originally recorded feedback wins.
|
|
expect(router.values.searchParams).toEqual({})
|
|
expect(logic.values.deliveryFeedback).toEqual({ 'd-123': 'positive' })
|
|
|
|
logic.unmount()
|
|
captureSpy.mockRestore()
|
|
})
|
|
|
|
it('persists recorded feedback across remounts', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
},
|
|
})
|
|
initKeaTests()
|
|
|
|
let logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
await expectLogic(logic, () => {
|
|
logic.actions.submitDeliveryFeedback('d-9', 'positive', 'in_app')
|
|
}).toFinishAllListeners()
|
|
logic.unmount()
|
|
|
|
logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
expect(logic.values.deliveryFeedback).toEqual({ 'd-9': 'positive' })
|
|
// The thanks flash is transient — after a remount the row goes straight to the recorded option.
|
|
expect(logic.values.recentlyThankedDeliveries).toEqual({})
|
|
|
|
logic.unmount()
|
|
})
|
|
|
|
it('captures in-app thumbs feedback and records it per delivery', async () => {
|
|
useMocks({
|
|
get: {
|
|
[`/v1/projects/${MOCK_TEAM_ID}/subscriptions/2/`]: () => [200, MOCK_AI_SUBSCRIPTION],
|
|
},
|
|
})
|
|
initKeaTests()
|
|
const captureSpy = jest.spyOn(insights, 'capture')
|
|
|
|
const logic = subscriptionSceneLogic({ id: '2' })
|
|
logic.mount()
|
|
await expectLogic(logic, () => {
|
|
logic.actions.submitDeliveryFeedback('d-9', 'negative', 'in_app')
|
|
}).toFinishAllListeners()
|
|
|
|
expect(captureSpy).toHaveBeenCalledWith('ai_report_feedback', {
|
|
subscription_id: 2,
|
|
delivery_id: 'd-9',
|
|
feedback: 'negative',
|
|
source: 'in_app',
|
|
previous_feedback: null,
|
|
})
|
|
expect(logic.values.deliveryFeedback).toEqual({ 'd-9': 'negative' })
|
|
// Thanks flashes first, then expiry settles the row into the recorded option.
|
|
expect(logic.values.recentlyThankedDeliveries).toEqual({ 'd-9': true })
|
|
await expectLogic(logic, () => {
|
|
logic.actions.expireDeliveryThanks('d-9')
|
|
}).toFinishAllListeners()
|
|
expect(logic.values.recentlyThankedDeliveries).toEqual({})
|
|
expect(logic.values.deliveryFeedback).toEqual({ 'd-9': 'negative' })
|
|
|
|
// Switching the vote captures again with the previous value, and the latest one wins.
|
|
await expectLogic(logic, () => {
|
|
logic.actions.submitDeliveryFeedback('d-9', 'positive', 'in_app')
|
|
}).toFinishAllListeners()
|
|
expect(captureSpy).toHaveBeenCalledWith('ai_report_feedback', {
|
|
subscription_id: 2,
|
|
delivery_id: 'd-9',
|
|
feedback: 'positive',
|
|
source: 'in_app',
|
|
previous_feedback: 'negative',
|
|
})
|
|
expect(logic.values.deliveryFeedback).toEqual({ 'd-9': 'positive' })
|
|
|
|
logic.unmount()
|
|
captureSpy.mockRestore()
|
|
})
|
|
})
|