2
0

♻️ (viewer) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 10:28:03 +01:00
committed by Baptiste Arnaud
parent 643571fe7d
commit a9d04798bc
80 changed files with 523 additions and 491 deletions

3
.gitignore vendored
View File

@ -9,7 +9,8 @@ authenticatedState.json
playwright-report
dist
test-results
test/results
**/src/test/results
**/src/test/reporters
test/report
**/api/scripts
.sentryclirc

View File

@ -15,7 +15,6 @@ const config: PlaywrightTestConfig = {
baseURL: process.env.NEXTAUTH_URL,
storageState: path.join(__dirname, 'src/test/storageState.json'),
},
outputDir: path.join(__dirname, 'src/test/results/'),
}
export default config

View File

@ -25,5 +25,19 @@ module.exports = {
plugins: ['react', '@typescript-eslint'],
rules: {
'react/no-unescaped-entities': [0],
'react/display-name': [0],
'no-restricted-imports': [
'error',
{
patterns: [
'*/src/*',
'src/*',
'*/src',
'@/features/*/*',
'!@/features/blocks/*',
'!@/features/*/api',
],
},
],
},
}

View File

@ -4,7 +4,7 @@ import { playwrightBaseConfig } from 'configs/playwright'
const config: PlaywrightTestConfig = {
...playwrightBaseConfig,
testDir: path.join(__dirname, 'playwright/tests'),
testDir: path.join(__dirname, 'src'),
webServer: process.env.CI
? {
...(playwrightBaseConfig.webServer as { command: string }),
@ -15,7 +15,6 @@ const config: PlaywrightTestConfig = {
...playwrightBaseConfig.use,
baseURL: process.env.NEXT_PUBLIC_VIEWER_URL,
},
outputDir: path.join(__dirname, 'playwright/test-results/'),
}
export default config

View File

@ -1,137 +0,0 @@
import test, { expect } from '@playwright/test'
import path from 'path'
import {
importTypebotInDatabase,
createWebhook,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { apiToken } from 'utils/playwright/databaseSetup'
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/api.json'),
{ id: typebotId }
)
await createWebhook(typebotId)
await injectFakeResults({ typebotId, count: 20 })
} catch (err) {
console.log(err)
}
})
test('can list typebots', async ({ request }) => {
expect((await request.get(`/api/typebots`)).status()).toBe(401)
const response = await request.get(`/api/typebots`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
const { typebots } = (await response.json()) as { typebots: unknown[] }
expect(typebots.length).toBeGreaterThanOrEqual(1)
expect(typebots[0]).toMatchObject({
id: typebotId,
publishedTypebotId: null,
name: 'My typebot',
})
})
test('can get webhook blocks', async ({ request }) => {
expect(
(await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { blocks } = await response.json()
expect(blocks).toHaveLength(1)
expect(blocks[0]).toEqual({
blockId: 'webhookBlock',
name: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{ data: { url: 'https://test.com' } }
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url: 'https://test.com' },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can unsubscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can get a sample result', async ({ request }) => {
expect(
(
await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`
)
).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
test('can list results', async ({ request }) => {
expect(
(await request.get(`/api/typebots/${typebotId}/results`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/results?limit=10`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { results } = await response.json()
expect(results).toHaveLength(10)
})

View File

@ -1,64 +0,0 @@
import test, { expect } from '@playwright/test'
import {
defaultSettings,
defaultTextInputOptions,
InputBlockType,
Metadata,
} from 'models'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { typebotViewer } from 'utils/playwright/testHelpers'
test('Should correctly parse metadata', async ({ page }) => {
const typebotId = cuid()
const customMetadata: Metadata = {
description: 'My custom description',
title: 'Custom title',
favIconUrl: 'https://www.baptistearno.com/favicon.png',
imageUrl: 'https://www.baptistearno.com/images/site-preview.png',
customHeadCode: '<meta name="author" content="John Doe">',
}
await createTypebots([
{
id: typebotId,
settings: {
...defaultSettings,
metadata: customMetadata,
},
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/${typebotId}-public`)
expect(
await page.evaluate(`document.querySelector('title').textContent`)
).toBe(customMetadata.title)
expect(
await page.evaluate(
() => (document.querySelector('meta[name="description"]') as any).content
)
).toBe(customMetadata.description)
expect(
await page.evaluate(
() => (document.querySelector('meta[property="og:image"]') as any).content
)
).toBe(customMetadata.imageUrl)
expect(
await page.evaluate(() =>
(document.querySelector('link[rel="icon"]') as any).getAttribute('href')
)
).toBe(customMetadata.favIconUrl)
expect(
await page.evaluate(
() => (document.querySelector('meta[name="author"]') as any).content
)
).toBe('John Doe')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toBeVisible()
})

View File

@ -1,77 +0,0 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/webhook.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
})
test.afterEach(async () => {
await deleteTypebots([typebotId])
await deleteWebhooks([
'failing-webhook',
'partial-body-webhook',
'full-body-webhook',
])
})
test('should execute webhooks properly', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('text=Send failing webhook').click()
await typebotViewer(page)
.locator('[placeholder="Type a name..."]')
.fill('John')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page).locator('[placeholder="Type an age..."]').fill('30')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page).locator('text="Male"').click()
await expect(
typebotViewer(page).getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
typebotViewer(page).getByText('{"name":"John","age":30,"gender":"Male"}')
).toBeVisible()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(
page.locator('text="Webhook successfuly executed." >> nth=1')
).toBeVisible()
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
})

View File

@ -1,95 +0,0 @@
import { User } from 'db'
import prisma from 'libs/prisma'
import {
LogicBlockType,
Typebot,
TypebotLinkBlock,
PublicTypebot,
} from 'models'
import { NextApiRequest } from 'next'
import { isDefined } from 'utils'
import { canReadTypebots } from './dbRules'
export const authenticateUser = async (
req: NextApiRequest
): Promise<User | undefined> => authenticateByToken(extractBearerToken(req))
const authenticateByToken = async (
apiToken?: string
): Promise<User | undefined> => {
if (!apiToken) return
return (await prisma.user.findFirst({
where: { apiTokens: { some: { token: apiToken } } },
})) as User
}
const extractBearerToken = (req: NextApiRequest) =>
req.headers['authorization']?.slice(7)
export const saveErrorLog = (
resultId: string | undefined,
message: string,
details?: any
) => saveLog('error', resultId, message, details)
export const saveSuccessLog = (
resultId: string | undefined,
message: string,
details?: any
) => saveLog('success', resultId, message, details)
const saveLog = (
status: 'error' | 'success',
resultId: string | undefined,
message: string,
details?: any
) => {
if (!resultId || resultId === 'undefined') return
return prisma.log.create({
data: {
resultId,
status,
description: message,
details: formatDetails(details),
},
})
}
const formatDetails = (details: any) => {
try {
return JSON.stringify(details, null, 2).substring(0, 1000)
} catch {
return details
}
}
export const getLinkedTypebots = async (
typebot: Typebot | PublicTypebot,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (
typebot.groups
.flatMap((g) => g.blocks)
.filter(
(s) =>
s.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(s.options.typebotId)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId as string)
if (linkedTypebotIds.length === 0) return []
const typebots = (await ('typebotId' in typebot
? prisma.publicTypebot.findMany({
where: { id: { in: linkedTypebotIds } },
})
: prisma.typebot.findMany({
where: user
? {
AND: [
{ id: { in: linkedTypebotIds } },
canReadTypebots(linkedTypebotIds, user as User),
],
}
: { id: { in: linkedTypebotIds } },
}))) as unknown as (Typebot | PublicTypebot)[]
return typebots
}

View File

@ -1,16 +0,0 @@
import { Result } from 'db'
import { sendRequest } from 'utils'
export const createResult = async (typebotId: string) => {
return sendRequest<{ result: Result; hasReachedLimit: boolean }>({
url: `/api/typebots/${typebotId}/results`,
method: 'POST',
})
}
export const updateResult = async (resultId: string, result: Partial<Result>) =>
sendRequest<Result>({
url: `/api/typebots/t/results/${resultId}`,
method: 'PATCH',
body: result,
})

View File

@ -2,11 +2,11 @@ import { TypebotViewer } from 'bot-engine'
import { Answer, PublicTypebot, Typebot, VariableWithValue } from 'models'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { upsertAnswer } from 'services/answer'
import { isDefined, isNotDefined } from 'utils'
import { SEO } from '../components/Seo'
import { createResult, updateResult } from '../services/result'
import { SEO } from './Seo'
import { ErrorPage } from './ErrorPage'
import { createResultQuery, updateResultQuery } from '@/features/results'
import { upsertAnswerQuery } from '@/features/answers'
export type TypebotPageProps = {
publishedTypebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> & {
@ -67,7 +67,9 @@ export const TypebotPage = ({
const resultIdFromSession = getExistingResultFromSession()
if (resultIdFromSession) setResultId(resultIdFromSession)
else {
const { error, data } = await createResult(publishedTypebot.typebotId)
const { error, data } = await createResultQuery(
publishedTypebot.typebotId
)
if (error) return setError(error)
if (data?.hasReachedLimit)
return setError(new Error('This bot is now closed.'))
@ -97,7 +99,7 @@ export const TypebotPage = ({
const sendNewVariables =
(resultId: string) => async (variables: VariableWithValue[]) => {
const { error } = await updateResult(resultId, { variables })
const { error } = await updateResultQuery(resultId, { variables })
if (error) setError(error)
}
@ -105,17 +107,17 @@ export const TypebotPage = ({
answer: Answer & { uploadedFiles: boolean }
) => {
if (!resultId) return setError(new Error('Error: result was not created'))
const { error } = await upsertAnswer({ ...answer, resultId })
const { error } = await upsertAnswerQuery({ ...answer, resultId })
if (error) setError(error)
if (chatStarted) return
updateResult(resultId, {
updateResultQuery(resultId, {
hasStarted: true,
}).then(({ error }) => (error ? setError(error) : setChatStarted(true)))
}
const handleCompleted = async () => {
if (!resultId) return setError(new Error('Error: result was not created'))
const { error } = await updateResult(resultId, { isCompleted: true })
const { error } = await updateResultQuery(resultId, { isCompleted: true })
if (error) setError(error)
}

View File

@ -0,0 +1 @@
export { upsertAnswerQuery } from './queries/upsertAnswerQuery'

View File

@ -1,7 +1,7 @@
import { Answer } from 'models'
import { sendRequest } from 'utils'
export const upsertAnswer = async (
export const upsertAnswerQuery = async (
answer: Answer & { resultId: string } & { uploadedFiles?: boolean }
) =>
sendRequest<Answer>({

View File

@ -0,0 +1,19 @@
import { User } from 'db'
import { NextApiRequest } from 'next'
import prisma from '@/lib/prisma'
export const authenticateUser = async (
req: NextApiRequest
): Promise<User | undefined> => authenticateByToken(extractBearerToken(req))
const authenticateByToken = async (
apiToken?: string
): Promise<User | undefined> => {
if (!apiToken) return
return (await prisma.user.findFirst({
where: { apiTokens: { some: { token: apiToken } } },
})) as User
}
const extractBearerToken = (req: NextApiRequest) =>
req.headers['authorization']?.slice(7)

View File

@ -0,0 +1 @@
export { authenticateUser } from './authenticateUser'

View File

@ -1,27 +1,27 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
import { parse } from 'papaparse'
import { readFileSync } from 'fs'
import { isDefined } from 'utils'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
// const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
test('should work as expected', async ({ page, browser }) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
await typebotViewer(page)
.locator(`input[type="file"]`)
.setInputFiles([
path.join(__dirname, '../fixtures/typebots/api.json'),
path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
path.join(__dirname, '../fixtures/typebots/hugeGroup.json'),
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(typebotViewer(page).locator(`text="3"`)).toBeVisible()
await typebotViewer(page).locator('text="Upload 3 files"').click()
@ -29,18 +29,16 @@ test('should work as expected', async ({ page, browser }) => {
typebotViewer(page).locator(`text="3 files uploaded"`)
).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="api.json"')).toHaveAttribute(
await expect(page.getByRole('link', { name: 'api.json' })).toHaveAttribute(
'href',
/.+\/api\.json/
)
await expect(page.locator('text="fileUpload.json"')).toHaveAttribute(
'href',
/.+\/fileUpload\.json/
)
await expect(page.locator('text="hugeGroup.json"')).toHaveAttribute(
'href',
/.+\/hugeGroup\.json/
)
await expect(
page.getByRole('link', { name: 'fileUpload.json' })
).toHaveAttribute('href', /.+\/fileUpload\.json/)
await expect(
page.getByRole('link', { name: 'hugeGroup.json' })
).toHaveAttribute('href', /.+\/hugeGroup\.json/)
await page.click('[data-testid="checkbox"] >> nth=0')
const [download] = await Promise.all([
@ -57,9 +55,9 @@ test('should work as expected', async ({ page, browser }) => {
const urls = (
await Promise.all(
[
page.locator('text="api.json"'),
page.locator('text="fileUpload.json"'),
page.locator('text="hugeGroup.json"'),
page.getByRole('link', { name: 'api.json' }),
page.getByRole('link', { name: 'fileUpload.json' }),
page.getByRole('link', { name: 'hugeGroup.json' }),
].map((elem) => elem.getAttribute('href'))
)
).filter(isDefined)

View File

@ -0,0 +1,2 @@
export * from './saveErrorLog'
export * from './saveSuccessLog'

View File

@ -0,0 +1,7 @@
import { saveLog } from './utils'
export const saveErrorLog = (
resultId: string | undefined,
message: string,
details?: unknown
) => saveLog('error', resultId, message, details)

View File

@ -0,0 +1,7 @@
import { saveLog } from './utils'
export const saveSuccessLog = (
resultId: string | undefined,
message: string,
details?: unknown
) => saveLog('success', resultId, message, details)

View File

@ -0,0 +1,26 @@
import prisma from '@/lib/prisma'
export const saveLog = (
status: 'error' | 'success',
resultId: string | undefined,
message: string,
details?: unknown
) => {
if (!resultId || resultId === 'undefined') return
return prisma.log.create({
data: {
resultId,
status,
description: message,
details: formatDetails(details) as string,
},
})
}
const formatDetails = (details: unknown) => {
try {
return JSON.stringify(details, null, 2).substring(0, 1000)
} catch {
return details
}
}

View File

@ -0,0 +1 @@
export { createResultQuery, updateResultQuery } from './queries'

View File

@ -0,0 +1,9 @@
import { Result } from 'models'
import { sendRequest } from 'utils'
export const createResultQuery = async (typebotId: string) => {
return sendRequest<{ result: Result; hasReachedLimit: boolean }>({
url: `/api/typebots/${typebotId}/results`,
method: 'POST',
})
}

View File

@ -0,0 +1,2 @@
export * from './createResultQuery'
export * from './updateResultQuery'

View File

@ -0,0 +1,12 @@
import { Result } from 'models'
import { sendRequest } from 'utils'
export const updateResultQuery = async (
resultId: string,
result: Partial<Result>
) =>
sendRequest<Result>({
url: `/api/typebots/t/results/${resultId}`,
method: 'PATCH',
body: result,
})

View File

@ -1,15 +1,19 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import path from 'path'
import cuid from 'cuid'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import {
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { apiToken } from 'utils/playwright/databaseSetup'
import { typebotViewer } from 'utils/playwright/testHelpers'
test('should work as expected', async ({ page }) => {
test('Big groups should work as expected', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/hugeGroup.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(getTestAsset('typebots/hugeGroup.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Baptiste')
await typebotViewer(page).locator('input').press('Enter')
@ -26,3 +30,22 @@ test('should work as expected', async ({ page }) => {
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
})
test('can list results with API', async ({ request }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await injectFakeResults({ typebotId, count: 20 })
expect(
(await request.get(`/api/typebots/${typebotId}/results`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/results?limit=10`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { results } = await response.json()
expect(results).toHaveLength(10)
})

View File

@ -1,10 +1,10 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../services/databaseActions'
import { createSmtpCredentials } from '../../test/utils/databaseActions'
import cuid from 'cuid'
import path from 'path'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
const mockSmtpCredentials: SmtpCredentialsData = {
from: {
@ -28,10 +28,10 @@ test.beforeAll(async () => {
test('should send an email', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/sendEmail.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
const [response] = await Promise.all([
page.waitForResponse((resp) =>

View File

@ -4,9 +4,11 @@ import {
defaultSettings,
defaultTextInputOptions,
InputBlockType,
Metadata,
} from 'models'
import { createTypebots, updateTypebot } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { typebotViewer } from 'utils/playwright/testHelpers'
test('Result should be in storage by default', async ({ page }) => {
const typebotId = cuid()
@ -120,3 +122,56 @@ test('Show close message', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await expect(page.locator('text=This bot is now closed')).toBeVisible()
})
test('Should correctly parse metadata', async ({ page }) => {
const typebotId = cuid()
const customMetadata: Metadata = {
description: 'My custom description',
title: 'Custom title',
favIconUrl: 'https://www.baptistearno.com/favicon.png',
imageUrl: 'https://www.baptistearno.com/images/site-preview.png',
customHeadCode: '<meta name="author" content="John Doe">',
}
await createTypebots([
{
id: typebotId,
settings: {
...defaultSettings,
metadata: customMetadata,
},
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/${typebotId}-public`)
expect(
await page.evaluate(`document.querySelector('title').textContent`)
).toBe(customMetadata.title)
expect(
await page.evaluate(
() => (document.querySelector('meta[name="description"]') as any).content
)
).toBe(customMetadata.description)
expect(
await page.evaluate(
() => (document.querySelector('meta[property="og:image"]') as any).content
)
).toBe(customMetadata.imageUrl)
expect(
await page.evaluate(() =>
(document.querySelector('link[rel="icon"]') as any).getAttribute('href')
)
).toBe(customMetadata.favIconUrl)
expect(
await page.evaluate(
() => (document.querySelector('meta[name="author"]') as any).content
)
).toBe('John Doe')
await expect(
typebotViewer(page).locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toBeVisible()
})

View File

@ -0,0 +1,41 @@
import prisma from '@/lib/prisma'
import { canReadTypebots } from '@/utils/api/dbRules'
import { User } from 'db'
import {
LogicBlockType,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isDefined } from 'utils'
export const getLinkedTypebots = async (
typebot: Typebot | PublicTypebot,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (
typebot.groups
.flatMap((g) => g.blocks)
.filter(
(s) =>
s.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(s.options.typebotId)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId as string)
if (linkedTypebotIds.length === 0) return []
const typebots = (await ('typebotId' in typebot
? prisma.publicTypebot.findMany({
where: { id: { in: linkedTypebotIds } },
})
: prisma.typebot.findMany({
where: user
? {
AND: [
{ id: { in: linkedTypebotIds } },
canReadTypebots(linkedTypebotIds, user as User),
],
}
: { id: { in: linkedTypebotIds } },
}))) as unknown as (Typebot | PublicTypebot)[]
return typebots
}

View File

@ -0,0 +1 @@
export * from './getLinkedTypebots'

View File

@ -1,5 +1,5 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import path from 'path'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
@ -9,11 +9,11 @@ const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/linkTypebots/1.json'),
getTestAsset('typebots/linkTypebots/1.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/linkTypebots/2.json'),
getTestAsset('typebots/linkTypebots/2.json'),
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
)
} catch (err) {

View File

@ -1,6 +1,5 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
// TODO: uncomment on 1st of November

View File

@ -1,13 +1,13 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import path from 'path'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
test('should correctly be injected', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/predefinedVariables.json'),
getTestAsset('typebots/predefinedVariables.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await page.goto(`/${typebotId}-public`)

View File

@ -0,0 +1 @@
export * from './parseSampleResult'

View File

@ -0,0 +1,198 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { apiToken } from 'utils/playwright/databaseSetup'
import { getTestAsset } from '@/test/utils/playwright'
test.describe('Bot', () => {
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
})
test.afterEach(async () => {
await deleteTypebots([typebotId])
await deleteWebhooks([
'failing-webhook',
'partial-body-webhook',
'full-body-webhook',
])
})
test('should execute webhooks properly', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('text=Send failing webhook').click()
await typebotViewer(page)
.locator('[placeholder="Type a name..."]')
.fill('John')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page)
.locator('[placeholder="Type an age..."]')
.fill('30')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page).locator('text="Male"').click()
await expect(
typebotViewer(page).getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
typebotViewer(page).getByText('{"name":"John","age":30,"gender":"Male"}')
).toBeVisible()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(
page.locator('text="Webhook successfuly executed." >> nth=1')
).toBeVisible()
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
})
})
test.describe('API', () => {
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await createWebhook(typebotId)
} catch (err) {
console.log(err)
}
})
test('can list typebots', async ({ request }) => {
expect((await request.get(`/api/typebots`)).status()).toBe(401)
const response = await request.get(`/api/typebots`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
const { typebots } = (await response.json()) as { typebots: unknown[] }
expect(typebots.length).toBeGreaterThanOrEqual(1)
expect(typebots[0]).toMatchObject({
id: typebotId,
publishedTypebotId: null,
name: 'My typebot',
})
})
test('can get webhook blocks', async ({ request }) => {
expect(
(await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { blocks } = await response.json()
expect(blocks).toHaveLength(1)
expect(blocks[0]).toEqual({
blockId: 'webhookBlock',
name: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{ data: { url: 'https://test.com' } }
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url: 'https://test.com' },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can unsubscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can get a sample result', async ({ request }) => {
expect(
(
await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`
)
).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
})

View File

@ -1,11 +1,11 @@
import { IncomingMessage } from 'http'
import { ErrorPage } from 'layouts/ErrorPage'
import { NotFoundPage } from 'layouts/NotFoundPage'
import { ErrorPage } from '@/components/ErrorPage'
import { NotFoundPage } from '@/components/NotFoundPage'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
import sanitizeHtml from 'sanitize-html'
import { env, getViewerUrl, isDefined, isNotDefined, omit } from 'utils'
import { TypebotPage, TypebotPageProps } from '../layouts/TypebotPage'
import prisma from '../libs/prisma'
import { TypebotPage, TypebotPageProps } from '../components/TypebotPage'
import prisma from '../lib/prisma'
export const getServerSideProps: GetServerSideProps = async (
context: GetServerSidePropsContext

View File

@ -2,11 +2,11 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, initMiddleware, methodNotAllowed } from 'utils/api'
import { hasValue } from 'utils'
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { getAuthenticatedGoogleClient } from 'libs/google-sheets'
import { Cell } from 'models'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { saveErrorLog, saveSuccessLog } from 'services/api/utils'
import { getAuthenticatedGoogleClient } from '@/lib/google-sheets'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -11,7 +11,7 @@ import Stripe from 'stripe'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { PaymentInputOptions, StripeCredentialsData, Variable } from 'models'
import prisma from 'libs/prisma'
import prisma from '@/lib/prisma'
import { parseVariables } from 'bot-engine'
const cors = initMiddleware(Cors())

View File

@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import prisma from '@/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import Cors from 'cors'
import { initMiddleware, methodNotAllowed, notFound } from 'utils/api'

View File

@ -1,7 +1,7 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -1,4 +1,3 @@
import prisma from 'libs/prisma'
import {
defaultWebhookAttributes,
KeyValue,
@ -20,12 +19,10 @@ import { initMiddleware, methodNotAllowed, notFound } from 'utils/api'
import { stringify } from 'qs'
import { withSentry } from '@sentry/nextjs'
import Cors from 'cors'
import { parseSampleResult } from 'services/api/webhooks'
import {
getLinkedTypebots,
saveErrorLog,
saveSuccessLog,
} from 'services/api/utils'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseSampleResult } from '@/features/webhook/api'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -1,8 +1,9 @@
import prisma from 'libs/prisma'
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { parseSampleResult } from '@/features/webhook/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser, getLinkedTypebots } from 'services/api/utils'
import { parseSampleResult } from 'services/api/webhooks'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -1,4 +1,4 @@
import prisma from 'libs/prisma'
import prisma from '@/lib/prisma'
import {
defaultWebhookAttributes,
ResultValues,

View File

@ -1,9 +1,10 @@
import prisma from 'libs/prisma'
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser, getLinkedTypebots } from 'services/api/utils'
import { parseSampleResult } from 'services/api/webhooks'
import { methodNotAllowed } from 'utils/api'
import { parseSampleResult } from '@/features/webhook/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)

View File

@ -1,10 +1,10 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { methodNotAllowed } from 'utils/api'
import { byId } from 'utils'
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)

View File

@ -1,10 +1,10 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { methodNotAllowed } from 'utils/api'
import { byId } from 'utils'
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)

View File

@ -1,6 +1,6 @@
import { withSentry } from '@sentry/nextjs'
import { WorkspaceRole } from 'db'
import prisma from 'libs/prisma'
import prisma from '@/lib/prisma'
import { InputBlockType, PublicTypebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils/api'

View File

@ -1,8 +1,8 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { byId } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,8 +1,8 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { byId } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,4 +1,3 @@
import prisma from 'libs/prisma'
import {
PublicTypebot,
ResultValues,
@ -9,16 +8,14 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport, getTestMessageUrl } from 'nodemailer'
import { isEmpty, isNotDefined, omit, parseAnswers } from 'utils'
import { methodNotAllowed, initMiddleware, decrypt } from 'utils/api'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import {
getLinkedTypebots,
saveErrorLog,
saveSuccessLog,
} from 'services/api/utils'
import Mail from 'nodemailer/lib/mailer'
import { DefaultBotNotificationEmail, render } from 'emails'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
const cors = initMiddleware(Cors())

View File

@ -1,12 +1,12 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { Workspace, WorkspaceRole } from 'db'
import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import prisma from 'libs/prisma'
import { ResultWithAnswers } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { env, getChatsLimit, isDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,5 +1,5 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import prisma from '@/lib/prisma'
import { Result } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,7 +1,7 @@
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Answer } from 'db'
import { got } from 'got'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { isNotDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,8 +1,8 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Group, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { byId, isWebhookBlock } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,8 +1,8 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { Group, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { byId, isNotDefined, isWebhookBlock } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -1,6 +1,6 @@
import { authenticateUser } from '@/features/auth/api'
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { authenticateUser } from 'services/api/utils'
import { isNotDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'

View File

@ -15,6 +15,9 @@
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"composite": true,
"downlevelIteration": true
},

View File

@ -28,7 +28,10 @@ export const playwrightBaseConfig: PlaywrightTestConfig = {
},
retries: process.env.NO_RETRIES ? 0 : 1,
workers: process.env.CI ? 2 : 3,
reporter: [[process.env.CI ? 'github' : 'list'], ['html']],
reporter: [
[process.env.CI ? 'github' : 'list'],
['html', { outputFolder: 'src/test/reporters' }],
],
maxFailures: process.env.CI ? 10 : undefined,
webServer: process.env.CI
? {
@ -37,6 +40,7 @@ export const playwrightBaseConfig: PlaywrightTestConfig = {
reuseExistingServer: true,
}
: undefined,
outputDir: './src/test/results',
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',