Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/lib/src/tlm-ai/aiController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { getAiService } from './aiService.js';
import { ConversationNotFoundError } from './exceptions.js';

export async function createConversation(req: Request, res: Response) {
try {
Expand Down Expand Up @@ -60,6 +61,10 @@ export async function sendMessage(req: Request, res: Response) {
const messages = await getAiService().sendMessage(conversationId, content);
res.json(messages);
} catch (err: any) {
if (err instanceof ConversationNotFoundError) {
res.status(404).json({ error: err.message });
return;
}
res.status(500).json({ error: err.message });
}
}
3 changes: 2 additions & 1 deletion packages/lib/src/tlm-ai/aiService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import OpenAI from 'openai';
import { OasTlmConfig } from '../config/config.types.js';
import { agent } from './agent.js';
import { ConversationNotFoundError } from './exceptions.js';

type Message = { role: 'user' | 'assistant' | 'function' | 'system'; content: string; name?: string; timestamp: string };
type Conversation = { id: string; messages: Message[]; name?: string };
Expand Down Expand Up @@ -40,7 +41,7 @@ class AIService {

async sendMessage(conversationId: string, content: string, model?: string): Promise<Message[]> {
const conversation = this.conversations.get(conversationId);
if (!conversation) throw new Error('Conversation not found');
if (!conversation) throw new ConversationNotFoundError();
conversation.messages.push({
role: 'system',
timestamp: new Date().toISOString(),
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/src/tlm-ai/exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class ConversationNotFoundError extends Error {
constructor(message: string = 'Conversation not found') {
super(message);
this.name = 'ConversationNotFoundError';
Object.setPrototypeOf(this, ConversationNotFoundError.prototype);
}
}
52 changes: 52 additions & 0 deletions packages/lib/test/e2e/definitions/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import axios from "axios";
import jwt from "jsonwebtoken";
import { E2ETestConfig } from "../index.test";
import { startServer } from "../utils/serverStarter";
import { ChildProcess } from "child_process";
Expand Down Expand Up @@ -96,5 +97,56 @@ export function defineAuthApiTests(config: E2ETestConfig) {
const response = await axios.get(`${baseUrl}/api/v1/pets`);
expect(response.status).toBe(200);
});

it("[e2e][Auth][-] should reject access token signed with a different JWT secret", async () => {
const invalidToken = jwt.sign({ type: "access" }, "different_secret", { expiresIn: 300 });
const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, {
headers: { Cookie: `oas-tlm-access-token=${invalidToken}` }
}).catch((err) => err.response);
expect(response.status).toBe(401);
});

it("[e2e][Auth][-] should reject refresh token signed with a different JWT secret", async () => {
const invalidRefreshToken = jwt.sign({ type: "refresh" }, "different_secret", { expiresIn: 300 });
const response = await axios.post(`${authUrl}/refresh`, {}, {
headers: { Cookie: `oas-tlm-refresh-token=${invalidRefreshToken}` },
withCredentials: true
}).catch((err) => err.response);
expect(response.status).toBe(401);
});

it("[e2e][Auth][-] should reject access token with incorrect payload type", async () => {
const wrongTypeToken = jwt.sign({ type: "refresh" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: 300 });
const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, {
headers: { Cookie: `oas-tlm-access-token=${wrongTypeToken}` }
}).catch((err) => err.response);
expect(response.status).toBe(401);
});

it("[e2e][Auth][-] should reject refresh token with incorrect payload type", async () => {
const wrongTypeRefreshToken = jwt.sign({ type: "access" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: 300 });
const response = await axios.post(`${authUrl}/refresh`, {}, {
headers: { Cookie: `oas-tlm-refresh-token=${wrongTypeRefreshToken}` },
withCredentials: true
}).catch((err) => err.response);
expect(response.status).toBe(401);
});

it("[e2e][Auth][-] should reject expired access token", async () => {
const expiredToken = jwt.sign({ type: "access" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: -10 });
const response = await axios.get(`${baseUrl}${telemetryPath}/logs`, {
headers: { Cookie: `oas-tlm-access-token=${expiredToken}` }
}).catch((err) => err.response);
expect(response.status).toBe(401);
});

it("[e2e][Auth][-] should reject expired refresh token", async () => {
const expiredRefreshToken = jwt.sign({ type: "refresh" }, additionalEnv.OASTLM_CONFIG_AUTH_JWT_SECRET, { expiresIn: -10 });
const response = await axios.post(`${authUrl}/refresh`, {}, {
headers: { Cookie: `oas-tlm-refresh-token=${expiredRefreshToken}` },
withCredentials: true
}).catch((err) => err.response);
expect(response.status).toBe(401);
});
});
}
19 changes: 19 additions & 0 deletions packages/lib/test/e2e/fixtures/valid-spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"openapi": "3.0.0",
"info": {
"title": "Valid Spec for Testing",
"version": "1.0.0"
},
"paths": {
"/api/v1/pets": {
"get": {
"summary": "Get pets",
"responses": {
"200": {
"description": "Success"
}
}
}
}
}
}
1 change: 1 addition & 0 deletions packages/lib/test/e2e/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ definePluginsApiTests(esmConfig);
defineAuthApiTests(cjsConfig);
defineAuthApiTests(esmConfig);


export interface E2ETestConfig {
label: string;
serverScript: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/lib/test/e2e/servers/otTestServer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ const spec = {
}


app.use(oasTelemetry({ general: { spec: JSON.stringify(spec) } }));
let specValue = JSON.stringify(spec);
if (process.env.OASTLM_TEST_INVALID_SPEC) {
specValue = ": : :";
}
const userConfig = process.env.OASTLM_TEST_NO_SPEC ? {} : { general: { spec: specValue } };
app.use(oasTelemetry(userConfig));

const logger = logs.getLogger('PetClinic', '1.0.0');
const meter = metrics.getMeter('PetClinic', '1.0.0');
Expand Down
7 changes: 6 additions & 1 deletion packages/lib/test/e2e/servers/otTestServer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ const spec = {
}
}

app.use(oasTelemetry({ general: { spec: JSON.stringify(spec) } }));
let specValue = JSON.stringify(spec);
if (process.env.OASTLM_TEST_INVALID_SPEC) {
specValue = ": : :";
}
const userConfig = process.env.OASTLM_TEST_NO_SPEC ? {} : { general: { spec: specValue } };
app.use(oasTelemetry(userConfig));

const logger = logs.getLogger('PetClinic', '1.0.0');
const meter = metrics.getMeter('PetClinic', '1.0.0');
Expand Down
9 changes: 7 additions & 2 deletions packages/lib/test/e2e/servers/otTestServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@ const spec = {
}
}

let specValue = JSON.stringify(spec);
if (process.env.OASTLM_TEST_INVALID_SPEC) {
specValue = ": : :";
}

const oasTlmConfig: UserConfig = {
general: {
spec: JSON.stringify(spec),
general: process.env.OASTLM_TEST_NO_SPEC ? {} : {
spec: specValue,
},
traces: {
// extraExporters: [new ConsoleSpanExporter()],
Expand Down
130 changes: 130 additions & 0 deletions packages/lib/test/unit/ai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { vi, describe, expect, it, beforeAll, afterAll } from "vitest";
import express from "express";
import axios from "axios";
import { getAIRoutes } from "../../src/tlm-ai/aiRoutes";
import { Server } from "http";

vi.mock("openai", () => {
return {
default: class MockOpenAI {
chat = {
completions: {
create: vi.fn().mockResolvedValue({
choices: [
{
index: 0,
message: {
role: "assistant",
content: "Mocked AI Response",
},
finish_reason: "stop",
},
],
}),
},
};
},
};
});

describe("AI API Unit/Integration Tests", () => {
let app: express.Express;
let server: Server;
let port: number;
let baseUrl: string;

const mockConfig: any = {
ai: {
openAIKey: "mock-key",
openAIModel: "gpt-3.5-turbo",
extraContextPrompts: []
}
};

beforeAll(() => {
app = express();
app.use(express.json());
app.use("/ai", getAIRoutes(mockConfig));

return new Promise<void>((resolve) => {
server = app.listen(0, () => {
const addr = server.address();
if (addr && typeof addr !== "string") {
port = addr.port;
baseUrl = `http://localhost:${port}/ai`;
}
resolve();
});
});
});

afterAll(() => {
return new Promise<void>((resolve) => {
server.close(() => resolve());
});
});

it("should report AI service as healthy", async () => {
const response = await axios.get(`${baseUrl}/chat/health`);
expect(response.status).toBe(200);
expect(response.data).toBe("AI service is healthy");
});

it("should create, list, retrieve, message, and delete a conversation", async () => {
// 1. Create conversation
const createResponse = await axios.post(`${baseUrl}/chat`);
expect(createResponse.status).toBe(201);
expect(createResponse.data).toHaveProperty("id");
expect(Array.isArray(createResponse.data.messages)).toBe(true);
const conversationId = createResponse.data.id;

// 2. List conversations
const listResponse = await axios.get(`${baseUrl}/chat`);
expect(listResponse.status).toBe(200);
expect(Array.isArray(listResponse.data)).toBe(true);
expect(listResponse.data.some((c: any) => c.id === conversationId)).toBe(true);

// 3. Get conversation history
const historyResponse = await axios.get(`${baseUrl}/chat/${conversationId}`);
expect(historyResponse.status).toBe(200);
expect(historyResponse.data.id).toBe(conversationId);

// 4. Send message
const messagePayload = { content: "Tell me about this API" };
const messageResponse = await axios.post(`${baseUrl}/chat/${conversationId}/message`, messagePayload);
expect(messageResponse.status).toBe(200);
expect(Array.isArray(messageResponse.data)).toBe(true);
expect(messageResponse.data.some((m: any) => m.content === "Mocked AI Response")).toBe(true);

// 5. Delete conversation
const deleteResponse = await axios.delete(`${baseUrl}/chat/${conversationId}`);
expect(deleteResponse.status).toBe(204);

// 6. Verify deleted (should return 404)
const getDeletedResponse = await axios.get(`${baseUrl}/chat/${conversationId}`).catch((err) => err.response);
expect(getDeletedResponse.status).toBe(404);
});

it("should return 400 when sending message with missing content", async () => {
const createResponse = await axios.post(`${baseUrl}/chat`);
const conversationId = createResponse.data.id;

const response = await axios.post(`${baseUrl}/chat/${conversationId}/message`, {}).catch((err) => err.response);
expect(response.status).toBe(400);
expect(response.data.error).toBe("Missing content");

await axios.delete(`${baseUrl}/chat/${conversationId}`);
});

it("should return 404 when sending message to a non-existent conversation", async () => {
const response = await axios.post(`${baseUrl}/chat/non-existent-conv/message`, { content: "hello" }).catch((err) => err.response);
expect(response.status).toBe(404);
expect(response.data.error).toContain("Conversation not found");
});

it("should return 404 when deleting a non-existent conversation", async () => {
const response = await axios.delete(`${baseUrl}/chat/non-existent-conv`).catch((err) => err.response);
expect(response.status).toBe(404);
expect(response.data.error).toBe("Not found");
});
});
Loading