Skip to content

Commit ce95208

Browse files
authored
Merge branch 'main' into fix/mercy60-issues-653-654-663-666
2 parents b7d69dd + e3fb806 commit ce95208

44 files changed

Lines changed: 4156 additions & 484 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: BackendAcademy CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'BackendAcademy/**'
8+
- '.github/workflows/backend-academy.yml'
9+
pull_request:
10+
branches: [ main ]
11+
paths:
12+
- 'BackendAcademy/**'
13+
- '.github/workflows/backend-academy.yml'
14+
15+
jobs:
16+
integration-and-ai-tests:
17+
name: Learner journey & AI tests
18+
runs-on: ubuntu-latest
19+
defaults:
20+
run:
21+
working-directory: BackendAcademy
22+
23+
steps:
24+
- name: Checkout code
25+
uses: actions/checkout@v4
26+
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v4
29+
with:
30+
node-version: '20'
31+
32+
- name: Install dependencies
33+
# --legacy-peer-deps: joi-to-typescript (devDependency) pins joi@17
34+
# while the app targets joi@18.
35+
run: npm ci --no-audit --no-fund --legacy-peer-deps
36+
37+
# BA-075: the end-to-end learner submission journey (happy, retry,
38+
# unauthorized, partial-failure paths) plus the AI provider/service
39+
# suites run in CI with isolated in-memory fixtures.
40+
- name: Run learner-journey integration & AI tests
41+
env:
42+
NODE_ENV: test
43+
run: npx jest --config jest.config.ts --runInBand src/integration/learner-journey.spec.ts src/ai

BackendAcademy/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ OPENAI_API_KEY= # Required when AI_PROVIDER=openai
3939
AI_MODEL= # Model override (optional)
4040
AI_MAX_TOKENS=4096
4141
AI_TEMPERATURE=0.7
42+
# BA-078: Retry policy for transient AI provider errors (429/5xx)
43+
AI_RETRY_MAX_ATTEMPTS=3
44+
AI_RETRY_BASE_DELAY_MS=250
45+
AI_RETRY_MAX_DELAY_MS=5000
4246

4347
# Static & uploaded assets
4448
ASSETS_UPLOAD_DIR=./data/uploads # Where uploaded assets are persisted on disk

BackendAcademy/package-lock.json

Lines changed: 126 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

BackendAcademy/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"joi": "^18.0.2",
2828
"multer": "^2.2.0",
2929
"prom-client": "^15.1.3",
30+
"@willsoto/nestjs-prometheus": "^6.0.0",
31+
"axios": "^1.7.9",
3032
"reflect-metadata": "^0.1.13",
3133
"rxjs": "^7.8.1",
3234
"typeorm": "^0.3.30",
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { validateAiConfig } from './ai.module';
3+
4+
function configService(values: Record<string, unknown>): ConfigService {
5+
return { get: <T = unknown>(key: string): T | undefined => values[key] as T } as ConfigService;
6+
}
7+
8+
describe('validateAiConfig — BA-076 startup credential validation', () => {
9+
it('accepts mock mode without any API keys', () => {
10+
expect(() =>
11+
validateAiConfig(configService({ AI_PROVIDER: 'mock' })),
12+
).not.toThrow();
13+
});
14+
15+
it('accepts an unset AI_PROVIDER (defaults to mock)', () => {
16+
expect(() => validateAiConfig(configService({}))).not.toThrow();
17+
});
18+
19+
it('accepts openai when OPENAI_API_KEY is set', () => {
20+
expect(() =>
21+
validateAiConfig(
22+
configService({ AI_PROVIDER: 'openai', OPENAI_API_KEY: 'sk-test' }),
23+
),
24+
).not.toThrow();
25+
});
26+
27+
it('accepts claude when ANTHROPIC_API_KEY is set', () => {
28+
expect(() =>
29+
validateAiConfig(
30+
configService({ AI_PROVIDER: 'claude', ANTHROPIC_API_KEY: 'sk-ant-test' }),
31+
),
32+
).not.toThrow();
33+
});
34+
35+
it('rejects openai without OPENAI_API_KEY with an actionable message', () => {
36+
expect(() => validateAiConfig(configService({ AI_PROVIDER: 'openai' }))).toThrow(
37+
/AI_PROVIDER is "openai" but OPENAI_API_KEY is not set/,
38+
);
39+
});
40+
41+
it('rejects claude without ANTHROPIC_API_KEY with an actionable message', () => {
42+
expect(() => validateAiConfig(configService({ AI_PROVIDER: 'claude' }))).toThrow(
43+
/AI_PROVIDER is "claude" but ANTHROPIC_API_KEY is not set/,
44+
);
45+
});
46+
47+
it('rejects an empty (whitespace) key value', () => {
48+
expect(() =>
49+
validateAiConfig(configService({ AI_PROVIDER: 'openai', OPENAI_API_KEY: ' ' })),
50+
).toThrow(/OPENAI_API_KEY is not set/);
51+
});
52+
53+
it('does not leak credential values in error messages', () => {
54+
let message = '';
55+
try {
56+
validateAiConfig(configService({ AI_PROVIDER: 'claude' }));
57+
} catch (err) {
58+
message = (err as Error).message;
59+
}
60+
expect(message).not.toContain('sk-ant');
61+
expect(message).toContain('ANTHROPIC_API_KEY');
62+
});
63+
});

BackendAcademy/src/ai/ai.module.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,31 @@ import { PromptTemplateService } from './prompt-template.service';
66
import { ClaudeProvider } from './providers/claude.provider';
77
import { OpenaiProvider } from './providers/openai.provider';
88

9-
function validateAiConfig(configService: ConfigService): void {
10-
const provider = configService.get<string>('AI_PROVIDER');
11-
if (provider && !['openai', 'claude'].includes(provider)) {
12-
throw new Error(`Invalid AI_PROVIDER: ${provider}. Must be 'openai' or 'claude'.`);
9+
/**
10+
* BA-076: Validate the AI configuration at startup, before any request
11+
* arrives.
12+
*
13+
* Rules:
14+
* - `AI_PROVIDER=openai` requires `OPENAI_API_KEY`.
15+
* - `AI_PROVIDER=claude` requires `ANTHROPIC_API_KEY`.
16+
* - `AI_PROVIDER=mock` (or unset) requires no credentials.
17+
*
18+
* Error messages are actionable and sanitized: they name the missing
19+
* variable and the fix, but never echo credential values.
20+
*/
21+
export function validateAiConfig(configService: ConfigService): void {
22+
const provider = configService.get<string>('AI_PROVIDER') ?? 'mock';
23+
24+
if (provider === 'openai' || provider === 'claude') {
25+
const credentialsKey =
26+
provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
27+
const apiKey = configService.get<string>(credentialsKey);
28+
if (!apiKey || apiKey.trim().length === 0) {
29+
throw new Error(
30+
`AI_PROVIDER is "${provider}" but ${credentialsKey} is not set. ` +
31+
`Set ${credentialsKey} to your API key, or switch AI_PROVIDER to "mock" for local development.`,
32+
);
33+
}
1334
}
1435

1536
const numericParams: Array<{ key: string; min: number; max: number; integer?: boolean }> = [
@@ -31,7 +52,7 @@ function validateAiConfig(configService: ConfigService): void {
3152
throw new Error(`AI config ${param.key} must be an integer, got "${raw}".`);
3253
}
3354
if (num < param.min || num > param.max) {
34-
throw new Error(`AI config ${param.key} must be tween ${param.min} and ${param.max}, got "${raw}".`);
55+
throw new Error(`AI config ${param.key} must be between ${param.min} and ${param.max}, got "${raw}".`);
3556
}
3657
process.env[param.key] = String(num);
3758
}
@@ -43,7 +64,7 @@ function validateAiConfig(configService: ConfigService): void {
4364
const lower = raw.toLowerCase();
4465
if (['true', '1', 'yes', 'y', 'on'].includes(lower)) {
4566
process.env[key] = 'true';
46-
} else if (['false', '0', 'no' , 'n', 'off'].includes(lower)) {
67+
} else if (['false', '0', 'no', 'n', 'off'].includes(lower)) {
4768
process.env[key] = 'false';
4869
} else {
4970
throw new Error(`AI config ${key} must be a boolean, got "${raw}".`);
@@ -65,7 +86,6 @@ const aiProviderFactory = {
6586

6687
@Module({
6788
controllers: [AiController],
68-
//ai controller
6989
providers: [AiService, PromptTemplateService, aiProviderFactory],
7090
exports: [AiService, PromptTemplateService],
7191
})

0 commit comments

Comments
 (0)