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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FormAgentSessionsModule } from "./domains/agents/form-agent-sessions/fo
import { AgentMessageFeedbackModule } from "./domains/agents/shared/agent-session-messages/feedback/agent-message-feedback.module"
import { StreamingModule } from "./domains/agents/shared/agent-session-messages/streaming/streaming.module"
import { InvitationsModule } from "./domains/agents/shared/memberships/invitations.module"
import { ProjectsAnalyticsModule } from "./domains/analytics/projects-analytics/projects-analytics.module"
import { AuthModule } from "./domains/auth/auth.module"
import { DocumentsModule } from "./domains/documents/documents.module"
import { StorageModule } from "./domains/documents/storage/storage.module"
Expand Down Expand Up @@ -46,6 +47,7 @@ import { UsersModule } from "./domains/users/users.module"
OrganizationsModule,
ProjectsModule,
ProjectsModule,
ProjectsAnalyticsModule,
StorageModule,
StreamingModule,
UsersModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { randomUUID } from "node:crypto"
import { AnalyticsRoutes } from "@caseai-connect/api-contracts"
import type { INestApplication } from "@nestjs/common"
import type { App } from "supertest/types"
import { AUTH_ERRORS } from "@/common/errors/auth-errors"
import { clearTestDatabase, RandomUuid } from "@/common/test/test-database"
import {
type AllRepositories,
setupTransactionalTestDatabase,
teardownTestDatabase,
} from "@/common/test/test-transaction-manager"
import { removeNullish } from "@/common/utils/remove-nullish"
import { createOrganizationWithProject } from "@/domains/organizations/organization.factory"
import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers"
import { expectResponse, type Requester, testRequester } from "../../../../../test/request"
import { ProjectsAnalyticsModule } from "../projects-analytics.module"

describe("Projects Analytics - Auth", () => {
let app: INestApplication<App>
let request: Requester
let setup: Awaited<ReturnType<typeof setupTransactionalTestDatabase>>
let repositories: AllRepositories

let organizationId: string | null = RandomUuid.Organization
let projectId: string | null = RandomUuid.Project
let accessToken: string | null = "token"
let auth0Id = "auth0|123"

const dateRange = {
startAt: new Date("2026-01-01T00:00:00.000Z").getTime(),
endAt: new Date("2026-01-03T23:59:59.999Z").getTime(),
}

beforeAll(async () => {
setup = await setupTransactionalTestDatabase({
additionalImports: [ProjectsAnalyticsModule],
applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id),
})
repositories = setup.getAllRepositories()
app = setup.module.createNestApplication()
await app.init()
request = testRequester(app)
})

beforeEach(async () => {
await clearTestDatabase(setup.dataSource)
organizationId = RandomUuid.Organization
projectId = RandomUuid.Project
accessToken = "token"
auth0Id = "auth0|123"
})

afterAll(async () => {
await teardownTestDatabase(setup)
await app.close()
})

const createContextForRole = async (role: "owner" | "admin" | "member" = "owner") => {
const { organization, project, user } = await createOrganizationWithProject(repositories, {
projectMembership: { role },
})
organizationId = organization.id
projectId = project.id
accessToken = "token"
auth0Id = user.auth0Id
return { organization, project, user }
}

const analyticsDateRangeQuery = {
startAt: String(dateRange.startAt),
endAt: String(dateRange.endAt),
}

const subjectConversations = async () =>
request({
route: AnalyticsRoutes.getConversationsPerDay,
pathParams: removeNullish({ organizationId, projectId }),
token: accessToken ?? undefined,
query: analyticsDateRangeQuery,
})

const subjectAvg = async () =>
request({
route: AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay,
pathParams: removeNullish({ organizationId, projectId }),
token: accessToken ?? undefined,
query: analyticsDateRangeQuery,
})

it("requires an authentication token", async () => {
accessToken = null
expectResponse(await subjectConversations(), 401, AUTH_ERRORS.NO_ACCESS_TOKEN)
expectResponse(await subjectAvg(), 401, AUTH_ERRORS.NO_ACCESS_TOKEN)
})

it("requires a valid organization ID", async () => {
organizationId = null
expectResponse(await subjectConversations(), 400, AUTH_ERRORS.NO_ORGANIZATION_ID)
expectResponse(await subjectAvg(), 400, AUTH_ERRORS.NO_ORGANIZATION_ID)
})

it("requires the user to be a member of the organization", async () => {
await createContextForRole("owner")
auth0Id = "auth0|456"
expectResponse(await subjectConversations(), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG)
expectResponse(await subjectAvg(), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG)
})

it("allows admins to list analytics for a project", async () => {
await createContextForRole("admin")
expectResponse(await subjectConversations(), 200)
expectResponse(await subjectAvg(), 200)
})

it("doesn't allow owners to list analytics for a project", async () => {
await createContextForRole("owner")
expectResponse(await subjectConversations(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE)
expectResponse(await subjectAvg(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE)
})

it("doesn't allow members to list analytics for a project", async () => {
await createContextForRole("member")
expectResponse(await subjectConversations(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE)
expectResponse(await subjectAvg(), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE)
})

it("requires an existing project ID", async () => {
await createContextForRole("owner")
projectId = randomUUID()
expectResponse(await subjectConversations(), 404)
expectResponse(await subjectAvg(), 404)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { AnalyticsRoutes } from "@caseai-connect/api-contracts"
import type { INestApplication } from "@nestjs/common"
import type { App } from "supertest/types"
import { clearTestDatabase } from "@/common/test/test-database"
import {
type AllRepositories,
setupTransactionalTestDatabase,
teardownTestDatabase,
} from "@/common/test/test-transaction-manager"
import { removeNullish } from "@/common/utils/remove-nullish"
import { agentFactory } from "@/domains/agents/agent.factory"
import { conversationAgentSessionFactory } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.factory"
import { agentMessageFactory } from "@/domains/agents/shared/agent-session-messages/agent-messages.factory"
import { createOrganizationWithProject } from "@/domains/organizations/organization.factory"
import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers"
import { expectResponse, type Requester, testRequester } from "../../../../../test/request"
import { ProjectsAnalyticsModule } from "../projects-analytics.module"

describe("Projects Analytics - getAvgUserQuestionsPerSessionPerDay", () => {
let app: INestApplication<App>
let request: Requester
let setup: Awaited<ReturnType<typeof setupTransactionalTestDatabase>>
let repositories: AllRepositories

let organizationId: string
let projectId: string
let accessToken: string | undefined = "token"
let auth0Id = "auth0|123"

const day1Start = new Date("2026-01-01T00:00:00.000Z")
const day2Start = new Date("2026-01-02T00:00:00.000Z")
const day3Start = new Date("2026-01-03T00:00:00.000Z")
const day3End = new Date(day3Start.getTime() + 24 * 60 * 60 * 1000 - 1)

const expectedDays = [
{ date: day1Start.toISOString().slice(0, 10), value: 1 },
{ date: day2Start.toISOString().slice(0, 10), value: 4 },
{ date: day3Start.toISOString().slice(0, 10), value: 0 },
]

beforeAll(async () => {
setup = await setupTransactionalTestDatabase({
additionalImports: [ProjectsAnalyticsModule],
applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id),
})
repositories = setup.getAllRepositories()
app = setup.module.createNestApplication()
await app.init()
request = testRequester(app)
})

beforeEach(async () => {
await clearTestDatabase(setup.dataSource)
accessToken = "token"
auth0Id = "auth0|123"
})

afterAll(async () => {
await teardownTestDatabase(setup)
await app.close()
})

const createContext = async () => {
const { organization, project, user } = await createOrganizationWithProject(repositories, {
projectMembership: { role: "admin" },
})
organizationId = organization.id
projectId = project.id
auth0Id = user.auth0Id

const agent = agentFactory.transient({ organization, project }).build()
await repositories.agentRepository.save(agent)

const session1Day1 = conversationAgentSessionFactory
.transient({ organization, project, agent, user })
.build({ createdAt: new Date(day1Start.getTime() + 3600 * 1000), updatedAt: new Date() })
const session2Day1 = conversationAgentSessionFactory
.transient({ organization, project, agent, user })
.build({ createdAt: new Date(day1Start.getTime() + 2 * 3600 * 1000), updatedAt: new Date() })
const session3Day2 = conversationAgentSessionFactory
.transient({ organization, project, agent, user })
.build({ createdAt: new Date(day2Start.getTime() + 3600 * 1000), updatedAt: new Date() })

await repositories.conversationAgentSessionRepository.save([
session1Day1,
session2Day1,
session3Day2,
])

await repositories.agentMessageRepository.save([
agentMessageFactory
.user()
.transient({ organization, project, session: session1Day1 })
.build({ createdAt: new Date(day1Start.getTime() + 10 * 60 * 1000) }),
agentMessageFactory
.user()
.transient({ organization, project, session: session1Day1 })
.build({ createdAt: new Date(day1Start.getTime() + 20 * 60 * 1000) }),
...Array.from({ length: 4 }, (_value, messageIndex) =>
agentMessageFactory
.user()
.transient({ organization, project, session: session3Day2 })
.build({
createdAt: new Date(day2Start.getTime() + (messageIndex + 1) * 5 * 60 * 1000),
}),
),
])
}

const subject = async () =>
request({
route: AnalyticsRoutes.getAvgUserQuestionsPerSessionPerDay,
pathParams: removeNullish({ organizationId, projectId }),
token: accessToken,
query: {
startAt: String(day1Start.getTime()),
endAt: String(day3End.getTime()),
},
})

it("returns avg user questions per session per day including zeros", async () => {
await createContext()
const response = await subject()
expectResponse(response, 200)
expect(response.body.data).toEqual(expectedDays)
})
})
Loading
Loading