Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add region to concept #260

Merged
merged 3 commits into from
Jan 26, 2025
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
126 changes: 126 additions & 0 deletions __tests__/api/concepts/[id]/reservation/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import { GET } from '@/app/api/concepts/[id]/reservation/route'
import { Concept } from '@/concept/domain/Aggregate'
import { App } from '@/concept/service/Service'

// Mock the Sentry module
jest.mock('@sentry/nextjs', () => ({
setTag: jest.fn(),
setContext: jest.fn(),
captureException: jest.fn(),
}))

describe('GET /api/concepts/[id]/reservation', () => {
const mockRequest = new Request('http://localhost:3000')
const mockParams = { id: 'test-concept-id' }

const mockTargetAudience = [
{
segment: 'Young Professionals',
description: 'People aged 25-35',
challenges: ['Time management', 'Work-life balance'],
},
]

beforeEach(() => {
jest.clearAllMocks()
})

it('should return concept data when concept is evaluated and not archived', async () => {
// Mock concept data
const mockConcept = {
isEvaluated: () => true,
isArchived: () => false,
getProblem: () => ({ getValue: () => 'Test Problem' }),
getRegion: () => ({ getValue: () => 'North America' }),
getEvaluation: () => ({
getMarketExistence: () => 'existing',
getTargetAudience: () => mockTargetAudience,
}),
} as unknown as Concept

// Mock the GetConcept query
jest.spyOn(App.Queries.GetConcept, 'handle').mockResolvedValue(mockConcept)

const response = await GET(mockRequest, { params: mockParams })
const responseData = await response.json()

expect(response).toBeInstanceOf(NextResponse)
expect(response.status).toBe(200)
expect(responseData).toEqual({
success: true,
message: 'Concept is ready for the reservation',
content: {
problem: 'Test Problem',
region: 'North America',
market_existence: 'existing',
target_audience: mockTargetAudience,
},
})
})

it('should return error when concept is not evaluated', async () => {
const mockConcept = {
isEvaluated: () => false,
} as unknown as Concept

jest.spyOn(App.Queries.GetConcept, 'handle').mockResolvedValue(mockConcept)

const response = await GET(mockRequest, { params: mockParams })
const responseData = await response.json()

expect(response.status).toBe(400)
expect(responseData).toEqual({
error: `Concept ${mockParams.id} was not evaluated`,
})
})

it('should return error when concept is archived', async () => {
const mockConcept = {
isEvaluated: () => true,
isArchived: () => true,
} as unknown as Concept

jest.spyOn(App.Queries.GetConcept, 'handle').mockResolvedValue(mockConcept)

const response = await GET(mockRequest, { params: mockParams })
const responseData = await response.json()

expect(response.status).toBe(400)
expect(responseData).toEqual({
error: `Concept ${mockParams.id} was archived`,
})
})

it('should return error when concept evaluation is missing', async () => {
const mockConcept = {
isEvaluated: () => true,
isArchived: () => false,
getEvaluation: () => null,
} as unknown as Concept

jest.spyOn(App.Queries.GetConcept, 'handle').mockResolvedValue(mockConcept)

const response = await GET(mockRequest, { params: mockParams })
const responseData = await response.json()

expect(response.status).toBe(400)
expect(responseData).toEqual({
error: `Concept ${mockParams.id} does not have evaluation`,
})
})

it('should return error when GetConcept query fails', async () => {
jest
.spyOn(App.Queries.GetConcept, 'handle')
.mockRejectedValue(new Error('Database error'))

const response = await GET(mockRequest, { params: mockParams })
const responseData = await response.json()

expect(response.status).toBe(500)
expect(responseData).toEqual({
error: 'Error while getting the concept for reservation.',
})
})
})
31 changes: 24 additions & 7 deletions __tests__/concept/adapters/OpenAIService/ConceptEvaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe('ConceptEvaluator', () => {
const mockApiKey = 'test-api-key'
const mockConceptId = 'test-concept-id'
const mockProblem = 'test problem'
const mockRegion = 'worldwide'

beforeEach(() => {
// Reset mocks
Expand Down Expand Up @@ -94,7 +95,11 @@ describe('ConceptEvaluator', () => {

describe('evaluateConcept', () => {
it('should clean empty strings from array responses and maintain all other fields', async () => {
const result = await evaluator.evaluateConcept(mockConceptId, mockProblem)
const result = await evaluator.evaluateConcept(
mockConceptId,
mockProblem,
mockRegion
)

// Test status
expect(result.status).toBe('well-defined')
Expand Down Expand Up @@ -222,7 +227,11 @@ describe('ConceptEvaluator', () => {

evaluator = new ConceptEvaluator(mockApiKey)

const result = await evaluator.evaluateConcept(mockConceptId, mockProblem)
const result = await evaluator.evaluateConcept(
mockConceptId,
mockProblem,
mockRegion
)

// Verify not-well-defined response
expect(result.status).toBe('not-well-defined')
Expand Down Expand Up @@ -316,7 +325,11 @@ describe('ConceptEvaluator', () => {
)

evaluator = new ConceptEvaluator(mockApiKey)
const result = await evaluator.evaluateConcept(mockConceptId, mockProblem)
const result = await evaluator.evaluateConcept(
mockConceptId,
mockProblem,
mockRegion
)

expect(result.status).toBe('requires_changes')
// Verify all fields are present and correctly transformed
Expand Down Expand Up @@ -346,7 +359,7 @@ describe('ConceptEvaluator', () => {
evaluator = new ConceptEvaluator(mockApiKey)

await expect(
evaluator.evaluateConcept(mockConceptId, mockProblem)
evaluator.evaluateConcept(mockConceptId, mockProblem, mockRegion)
).rejects.toThrow('API Error')
})

Expand Down Expand Up @@ -381,7 +394,7 @@ describe('ConceptEvaluator', () => {
evaluator = new ConceptEvaluator(mockApiKey)

await expect(
evaluator.evaluateConcept(mockConceptId, mockProblem)
evaluator.evaluateConcept(mockConceptId, mockProblem, mockRegion)
).rejects.toThrow()
})

Expand All @@ -405,7 +418,7 @@ describe('ConceptEvaluator', () => {
evaluator = new ConceptEvaluator(mockApiKey)

await expect(
evaluator.evaluateConcept(mockConceptId, mockProblem)
evaluator.evaluateConcept(mockConceptId, mockProblem, mockRegion)
).rejects.toThrow('Test Error')

expect(Sentry.captureException).toHaveBeenCalledWith(mockError)
Expand Down Expand Up @@ -464,7 +477,11 @@ describe('ConceptEvaluator', () => {
)

evaluator = new ConceptEvaluator(mockApiKey)
const result = await evaluator.evaluateConcept(mockConceptId, mockProblem)
const result = await evaluator.evaluateConcept(
mockConceptId,
mockProblem,
mockRegion
)

expect(result.marketExistence).toBe('')
})
Expand Down
66 changes: 66 additions & 0 deletions __tests__/concept/domain/Region.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Region } from '@/concept/domain/Region'

describe('Region Class', () => {
const validRegions = [
'worldwide',
'north_america',
'south_america',
'europe',
'asia',
'africa',
'oceania',
]

describe('Successful Creation', () => {
it.each(validRegions)(
'should create a Region instance with %s',
(region) => {
const regionObj = Region.New(region)
expect(regionObj).toBeInstanceOf(Region)
expect(regionObj.getValue()).toBe(region)
}
)

it('should handle uppercase input', () => {
const regionObj = Region.New('WORLDWIDE')
expect(regionObj.getValue()).toBe('worldwide')
})

it('should handle whitespace', () => {
const regionObj = Region.New(' europe ')
expect(regionObj.getValue()).toBe('europe')
})
})

describe('Validation Errors', () => {
it('should throw an error when value is null', () => {
expect(() => {
Region.New(null as unknown as string)
}).toThrow('Region must be defined.')
})

it('should throw an error when value is undefined', () => {
expect(() => {
Region.New(undefined as unknown as string)
}).toThrow('Region must be defined.')
})

it('should throw an error when value is empty string', () => {
expect(() => {
Region.New('')
}).toThrow('Region must be defined.')
})

it('should throw an error when value is whitespace only', () => {
expect(() => {
Region.New(' ')
}).toThrow('Region must be defined.')
})

it('should throw an error for invalid region', () => {
expect(() => {
Region.New('invalid_region')
}).toThrow(`Invalid region. Must be one of: ${validRegions.join(', ')}`)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "concepts" ADD COLUMN "region" TEXT DEFAULT 'worldwide';
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ model Concept {
id String @id @default(uuid())
problem String
evaluation String?
region String? @default("worldwide")
// We should validate uniqueness in the repository
ideaId String? @map(name: "idea_id")
createdAt DateTime @default(now()) @map(name: "created_at")
Expand Down
1 change: 1 addition & 0 deletions prompts/00-problem-evaluation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Based on the classification, follow the guidelines below.
- **Use Specific Data**: Incorporate relevant statistics, data points, or industry insights to support your analysis. Cite specific sources or reports if possible.
- **Avoid Generic Statements**: Do not use overused phrases or follow a template; ensure the analysis is specific and tailored to the user's problem.
- **Use 2025 Data**: Use data from 2025 to make the analysis more relevant and accurate.
- **Region**: Use the region provided by the user to make the analysis more relevant and accurate.
- **Scoring Guidelines**: All numerical scores must be between 0 and 10, where 0 represents complete absence/failure and 10 represents perfect execution
- **Language Analysis**: Focus on identifying specific instances of unclear language rather than general observations
- **Validation Metrics**: When scoring target audience metrics:
Expand Down
30 changes: 30 additions & 0 deletions src/app/api/concepts/[id]/reservation/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export class ApplicationError extends Error {
constructor(
message: string,
public readonly statusCode: number = 500
) {
super(message)
this.name = 'ApplicationError'
}
}

export class ConceptNotEvaluatedError extends ApplicationError {
constructor(conceptId: string) {
super(`Concept ${conceptId} was not evaluated`, 400)
this.name = 'ConceptNotEvaluatedError'
}
}

export class ConceptArchivedError extends ApplicationError {
constructor(conceptId: string) {
super(`Concept ${conceptId} was archived`, 400)
this.name = 'ConceptArchivedError'
}
}

export class ConceptEvaluationMissingError extends ApplicationError {
constructor(conceptId: string) {
super(`Concept ${conceptId} does not have evaluation`, 400)
this.name = 'ConceptEvaluationMissingError'
}
}
23 changes: 18 additions & 5 deletions src/app/api/concepts/[id]/reservation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import * as Sentry from '@sentry/nextjs'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { App } from '@/concept/service/Service'
import {
ApplicationError,
ConceptArchivedError,
ConceptEvaluationMissingError,
ConceptNotEvaluatedError,
} from './errors'

const ConceptForReservationResponseSchema = z.object({
success: z.boolean(),
message: z.string(),
content: z
.object({
problem: z.string().min(1),
region: z.string().min(1),
market_existence: z.string().min(1),
target_audience: z.array(
z.object({
Expand Down Expand Up @@ -40,24 +47,25 @@ export async function GET(_: Request, { params }: { params: { id: string } }) {
})

if (!concept.isEvaluated()) {
throw new Error(`Concept ${params.id} was not evaluated`)
throw new ConceptNotEvaluatedError(params.id)
}

if (concept.isArchived()) {
throw new Error(`Concept ${params.id} was archived`)
throw new ConceptArchivedError(params.id)
}

const evaluation = concept.getEvaluation()

if (!evaluation) {
throw new Error(`Concept ${params.id} does not have evaluation`)
throw new ConceptEvaluationMissingError(params.id)
}

const response: ConceptForReservationResponse = {
success: true,
message: 'Concept is ready for the reservation',
content: {
problem: concept.getProblem().getValue(),
region: concept.getRegion().getValue(),
market_existence: evaluation.getMarketExistence(),
target_audience: evaluation
.getTargetAudience()
Expand All @@ -73,10 +81,15 @@ export async function GET(_: Request, { params }: { params: { id: string } }) {

return NextResponse.json(response, { status: 200 })
} catch (error) {
console.error('Error while getting the concept for reservation:', error)

Sentry.captureException(error)

if (error instanceof ApplicationError) {
return NextResponse.json(
{ error: error.message },
{ status: error.statusCode }
)
}

return NextResponse.json(
{ error: 'Error while getting the concept for reservation.' },
{ status: 500 }
Expand Down
Loading
Loading