diff --git a/backend/src/controllers/entry/entry.ts b/backend/src/controllers/entry/entry.ts index a75494e..ab87148 100644 --- a/backend/src/controllers/entry/entry.ts +++ b/backend/src/controllers/entry/entry.ts @@ -1,14 +1,8 @@ -import * as CdGptServices from '../../models/services/CdGpt.js'; import * as EntryServices from '../../models/services/entry/entry.js'; -import { - Entry, - EntryAnalysis, - EntryConversation, - Journal, -} from '../../models/index.js'; import { NextFunction, Request, Response } from 'express'; +import { ChatMessage } from '../../models/entry/entryConversation.js'; import ExpressError from '../../utils/ExpressError.js'; -import mongoose from 'mongoose'; +import { Journal } from '../../models/index.js'; /** * Request body for create and update Entry operations, @@ -25,6 +19,10 @@ export interface UpdateEntryRequestBody { }; } +export interface UpdateChatRequestBody { + messages: ChatMessage[]; +} + /** * Get all entries in a specific journal. */ @@ -138,41 +136,17 @@ export const deleteEntry = async ( next: NextFunction ) => { const { entryId } = req.params; - - // Start a session and transaction for atomicity - const session = await mongoose.startSession(); - session.startTransaction(); - try { - // Delete the entry - const response = await Entry.findByIdAndDelete(entryId, { session }); - - if (!response) { - return next(new ExpressError('Entry not found.', 404)); + await EntryServices.deleteEntry(entryId); + req.flash('success', 'Successfully deleted entry.'); + res.status(200).json({ flash: req.flash() }); + } catch (err) { + const errMessage = (err as Error).message; + if (errMessage === 'Entry not found.') { + return next(new ExpressError((err as Error).message, 404)); } - - // Delete associated documents - await EntryConversation.deleteMany({ entry: entryId }, { session }); - await EntryAnalysis.deleteMany({ entry: entryId }, { session }); - - // Commit the transaction - await session.commitTransaction(); - } catch { - // If an error occurs, abort the transaction - await session.abortTransaction(); - return next( - new ExpressError( - 'An error occurred while attempting to delete the entry.', - 500 - ) - ); - } finally { - // End the session - session.endSession(); + return next(new ExpressError('An error occurred while attempting to delete the entry.', 500)); } - - req.flash('success', 'Successfully deleted entry.'); - res.status(200).json({ flash: req.flash() }); }; /** @@ -204,54 +178,31 @@ export const updateEntryAnalysis = async ( ) => { const { entryId, journalId } = req.params; - // Ensure that the journal exists - const journal = await Journal.findById(journalId); - if (!journal) { - return next(new ExpressError('Journal not found.', 404)); - } - - const entry = await Entry.findById(entryId); - - if (!entry) { - return next(new ExpressError('Entry not found.', 404)); - } - - const entryAnalysis = await EntryAnalysis.findOne({ entry: entryId }); - if (!entryAnalysis) { - return next(new ExpressError('Entry not found.', 404)); - } - if (!journal.config) { - return next(new ExpressError('Journal config not found.', 404)); - } try { - const analysis = await CdGptServices.getAnalysisContent( - journal.config.toString(), - entry.content - ); - - // Complete the entry and analysis with the analysis content if available - if (analysis) { - entry.title = analysis.title; - entry.mood = analysis.mood; - entry.tags = analysis.tags; - - entryAnalysis.analysis_content = analysis.analysis_content; + const configId = await verifyJournalExists(journalId); - entryAnalysis.save(); - entry.save(); + try { + const { errMessage, entry, entryAnalysis } = await EntryServices.updateEntryAnalysis(entryId, configId); + + if (errMessage) { + req.flash('info', errMessage); + } req.flash('success', 'Successfully generated a new analysis.'); + res + .status(200) + .json({ + ...entryAnalysis.toObject(), + entry: entry.toObject(), + flash: req.flash(), + }); + } catch (updateError) { + // Possible error from failing to find matching documents for entryId + // Setting appropriate error code here + throw new ExpressError((updateError as Error).message, 404); } - } catch (err) { - req.flash('info', (err as Error).message); + } catch (error) { + return next(error); } - - res - .status(200) - .json({ - ...entryAnalysis.toObject(), - entry: entry.toObject(), - flash: req.flash(), - }); }; /** @@ -277,7 +228,7 @@ export const createEntryConversation = async ( next: NextFunction ) => { const { entryId, journalId } = req.params; - const messageData = req.body; + const messageData: UpdateChatRequestBody = req.body; // Create new EntryConversation try { @@ -307,64 +258,16 @@ export const updateEntryConversation = async ( next: NextFunction ) => { const { chatId, journalId } = req.params; - const messageData = req.body; - - // Get the config from the journal - const journal = await Journal.findById(journalId); - if (!journal) { - return next(new ExpressError('Journal not found.', 404)); - } - - // Get the conversation from the database - const conversation = await EntryConversation.findById(chatId); - if (!conversation) { - return next(new ExpressError('Entry conversation not found.', 404)); - } - if (!conversation.messages) { - return next( - new ExpressError('Entry conversation messages not found.', 404) - ); - } + const messageData: UpdateChatRequestBody = req.body; - // Get the analysis associated with the entry - const analysis = await EntryAnalysis.findOne({ entry: conversation.entry }); - if (!analysis) { - return next(new ExpressError('Entry analysis not found.', 404)); - } - - if (!journal.config) { - return next(new ExpressError('Journal config not found.', 404)); - } try { - const llmResponse = await CdGptServices.getChatContent( - journal.config.toString(), - analysis.id, - messageData.messages[0].message_content, - conversation.messages - ); - - // If the chat is not empty, update the llm_response - if (llmResponse) { - messageData.messages[0].llm_response = llmResponse; - } - } catch (err) { - return next(err); - } + const configId = await verifyJournalExists(journalId); - const response = await EntryConversation.findOneAndUpdate( - { _id: chatId }, - { - $push: { - ...messageData, - }, - }, - { new: true } - ); - if (!response) { - return next(new ExpressError('Failed to update entry conversation.', 500)); + const response = await EntryServices.updateEntryConversation(chatId, configId, messageData); + res.status(200).json({ ...response.toObject(), flash: req.flash() }); + } catch (error) { + return next(error); } - - res.status(200).json({ ...response.toObject(), flash: req.flash() }); }; /** diff --git a/backend/src/models/services/entry/entry.ts b/backend/src/models/services/entry/entry.ts index 878b6c7..ce7dab6 100644 --- a/backend/src/models/services/entry/entry.ts +++ b/backend/src/models/services/entry/entry.ts @@ -4,24 +4,12 @@ import { EntryAnalysis, EntryConversation, } from '../../index.js'; +import { UpdateChatRequestBody, UpdateEntryRequestBody } from '../../../controllers/entry/entry.js'; +import mongoose, { HydratedDocument } from 'mongoose'; import { EntryAnalysisType } from '../../entry/entryAnalysis.js'; import { EntryConversationType } from '../../entry/entryConversation.js'; import { EntryType } from '../../entry/entry.js'; import ExpressError from '../../../utils/ExpressError.js'; -import { HydratedDocument } from 'mongoose'; -import { UpdateEntryRequestBody } from '../../../controllers/entry/entry.js'; - -/** - * Shape of data in request body when creating EntryConversation - * TODO: consider moving this somewhere else. Might not be best fit here - * given that it's for enforcing user input - */ -interface MessageData { - messages: { - message_content: string, - llm_response: string - }[] -} /** * Return value of operations that create or update Entry @@ -173,60 +161,71 @@ export async function updateEntry( entryData: UpdateEntryRequestBody, ) { const { title: entryTitle, content: entryContent } = entryData; - const updatedEntry = await getEntryById(entryId); - if (!updatedEntry) { - throw new Error('Entry not found.'); - } - const oldAnalysis = await getEntryAnalysisById(entryId); - if (!oldAnalysis) { - throw new Error('Entry analysis not found.'); - } - + const { entry, entryAnalysis } = await _verifyEntry(entryId); + if (entryContent) { - updatedEntry.content = entryContent; - return await _updateEntry(updatedEntry, oldAnalysis, configId); + entry.content = entryContent; + return await _updateEntry(entry, entryAnalysis, configId); } else if (entryTitle) { - updatedEntry.title = entryTitle; - await updatedEntry.save(); + entry.title = entryTitle; + await entry.save(); } - return { entry: updatedEntry }; + return { entry: entry }; } -async function _updateEntry( - updatedEntry: HydratedDocument, - oldAnalysis: HydratedDocument, - configId: string +/** + * + * @param entryId + * @param configId + * @returns + */ +export async function updateEntryAnalysis( + entryId: string, + configId: string, ) { - // Creating return object to push error handling into service rather than controller - const updateEntryResult: EntryUpdateResponse = { - entry: updatedEntry, + const { entry, entryAnalysis } = await _verifyEntry(entryId); + return { + ...await _updateEntry(entry, entryAnalysis, configId), + entryAnalysis: entryAnalysis }; +} + +/** + * Deletes Entry by ID and associated EntryConversation and EntryAnalysis. + * @param entryId id of entry to delete + */ +export async function deleteEntry( + entryId: string +): Promise { + // Start a session and transaction for atomicity + const session = await mongoose.startSession(); + session.startTransaction(); + try { - const analysis = await CdGptServices.getAnalysisContent( - configId, - updatedEntry.content - ); - - // Complete the entry and analysis with the analysis content if available - if (analysis) { - updatedEntry.title = analysis.title; - updatedEntry.mood = analysis.mood; - updatedEntry.tags = analysis.tags; - - // TODO: you might validate here instead, not use middleware - oldAnalysis.analysis_content = analysis.analysis_content; + // Delete the entry + const response = await Entry.findByIdAndDelete(entryId, { session }); + + if (!response) { + throw new Error('Entry not found.'); } - } catch (analysisError) { - updateEntryResult.errMessage = (analysisError as Error).message; + + // Delete associated documents + await EntryConversation.deleteMany({ entry: entryId }, { session }); + await EntryAnalysis.deleteMany({ entry: entryId }, { session }); + + // Commit the transaction + await session.commitTransaction(); + } catch (error) { + // If an error occurs, abort the transaction + await session.abortTransaction(); + throw error; } finally { - await updatedEntry.save(); - await oldAnalysis.save(); + // End the session + session.endSession(); } - return updateEntryResult; } /** - * TODO: continue breaking up this function * Creates new EntryConversation for an Entry and populates with LLM response * * This function throws errors to replicate how the original function @@ -241,44 +240,135 @@ async function _updateEntry( export async function createEntryConversation( entryId: string, configId: string, - messageData: MessageData + messageData: UpdateChatRequestBody ) { - // Get an entry with the analysis - const entry = await Entry.findById(entryId); - if (!entry) { - throw new ExpressError('Entry not found.', 404); // TODO: reconsider having HTTP codes in domain logic - } - if (!entry.analysis) { - throw new ExpressError('Entry analysis not found.', 404); - } - - const newConversation = new EntryConversation({ - entry: entryId, - ...messageData, - }); /** * I don't think this case will ever get used because joi validation * rejects empty messages, and that happens before hitting this function, but it's defensive */ - if (!newConversation.messages || newConversation.messages.length === 0) { + if (!messageData.messages || messageData.messages.length === 0) { + // TODO: try removing HTTP stuff from here throw new ExpressError('No message to get completion for.', 404); } + // Get an entry with the analysis + const { entry, entryAnalysis } = await _verifyEntry(entryId); + const newConversation = new EntryConversation({ + entry: entryId, + messages: messageData.messages, + }); + // Associate the conversation with the entry entry.conversation = newConversation.id; - await entry.save(); + // TODO: try to use _populateChatContent. Can't currently because this doesn't append; it modifies in place const llmResponse = await CdGptServices.getChatContent( configId, - entry.analysis.toString(), + entryAnalysis.id, messageData.messages[0].message_content ); - // If the chat is not empty, update the llm_response if (llmResponse) { - newConversation.messages[0].llm_response = llmResponse; + // messages defined because messageData.mesages defined + newConversation.messages![0].llm_response = llmResponse; } await newConversation.save(); + await entry.save(); return newConversation; +} + +export async function updateEntryConversation( + chatId: string, + configId: string, + messageData: UpdateChatRequestBody +) { + const { conversation, analysis } = await _verifyEntryConversation(chatId); + + return await _populateChatContent(configId, analysis, messageData, conversation); +} + +async function _verifyEntry(entryId: string) { + const entry = await getEntryById(entryId); + if (!entry) { + throw new Error('Entry not found.'); + } + const entryAnalysis = await getEntryAnalysisById(entryId); + if (!entryAnalysis) { + throw new Error('Entry analysis not found.'); + } + return { entry, entryAnalysis }; +} + +async function _updateEntry( + updatedEntry: HydratedDocument, + oldAnalysis: HydratedDocument, + configId: string +) { + // Creating return object to push error handling into service rather than controller + const updateEntryResult: EntryUpdateResponse = { + entry: updatedEntry, + }; + try { + const analysis = await CdGptServices.getAnalysisContent( + configId, + updatedEntry.content + ); + + // Complete the entry and analysis with the analysis content if available + if (analysis) { + updatedEntry.title = analysis.title; + updatedEntry.mood = analysis.mood; + updatedEntry.tags = analysis.tags; + + // TODO: you might validate here instead, not use middleware + oldAnalysis.analysis_content = analysis.analysis_content; + } + } catch (analysisError) { + updateEntryResult.errMessage = (analysisError as Error).message; + } finally { + await updatedEntry.save(); + await oldAnalysis.save(); + } + return updateEntryResult; +} + +async function _populateChatContent( + configId: string, + analysis: HydratedDocument, + messageData: UpdateChatRequestBody, + conversation: HydratedDocument +) { + const llmResponse = await CdGptServices.getChatContent( + configId, + analysis.id, + messageData.messages[0].message_content, + conversation.messages + ); + + // If the chat is not empty, update the llm_response + if (llmResponse) { // TODO: this will drop chats that fail to get llm response. Is that fine? + messageData.messages[0].llm_response = llmResponse; + conversation.messages?.push(messageData.messages[0]); + await conversation.save(); + } + return conversation; +} + +async function _verifyEntryConversation( + chatId: string +) { + // TODO: try removing HTTP stuff from here + const conversation = await EntryConversation.findById(chatId); + if (!conversation) { + throw new ExpressError('Entry conversation not found.', 404); + } + if (!conversation.messages) { + throw new ExpressError('Entry conversation messages not found.', 404); + } + const analysis = await EntryAnalysis.findOne({ entry: conversation.entry }); + if (!analysis) { + throw new ExpressError('Entry analysis not found.', 404); + } + return { conversation, analysis }; } \ No newline at end of file diff --git a/backend/tests/db.test.ts b/backend/tests/db.test.ts index 4b3935b..6fc6df7 100644 --- a/backend/tests/db.test.ts +++ b/backend/tests/db.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable jest/no-disabled-tests */ -/* eslint-disable sort-imports */ - import 'dotenv/config'; import connectDB from '../src/db.js'; @@ -27,8 +24,9 @@ describe('connectDB', () => { process.env = originalEnv; }); - it.skip('should connect to MongoDB Atlas in production environment', async () => { + it('should connect to MongoDB Atlas in production environment', async () => { process.env.NODE_ENV = 'production'; + process.env.ATLAS_URI = 'testuri'; const atlasUri = process.env.ATLAS_URI; await connectDB('testdb'); diff --git a/backend/tests/jest.setup.cjs b/backend/tests/jest.setup.cjs index 4bc0f2b..c825699 100644 --- a/backend/tests/jest.setup.cjs +++ b/backend/tests/jest.setup.cjs @@ -1,13 +1,13 @@ require('dotenv').config(); -const MongoMemoryServer = require('mongodb-memory-server').MongoMemoryServer; +const { MongoMemoryReplSet } = require('mongodb-memory-server'); const mongoose = require('mongoose'); module.exports = async () => { console.log('\nSetup: config mongodb for testing...'); // https://typegoose.github.io/mongodb-memory-server/docs/guides/integration-examples/test-runners/ - const instance = await MongoMemoryServer.create(); - const uri = instance.getUri(); - global.__MONGOINSTANCE = instance; + const replSet = await MongoMemoryReplSet.create({ replSet: { count: 3 } }); + const uri = replSet.getUri(); + global.__MONGOREPLSET = replSet; process.env.MONGO_URI = uri.slice(0, uri.lastIndexOf('/')); // Make sure the database is empty before running tests. diff --git a/backend/tests/jest.teardown.cjs b/backend/tests/jest.teardown.cjs index 4a76974..ec67f91 100644 --- a/backend/tests/jest.teardown.cjs +++ b/backend/tests/jest.teardown.cjs @@ -3,6 +3,6 @@ require('dotenv').config(); module.exports = async () => { console.log('Teardown: Dropping test database...'); // https://typegoose.github.io/mongodb-memory-server/docs/guides/integration-examples/test-runners/ - const instance = global.__MONGOINSTANCE; - await instance.stop(); + const replSet = global.__MONGOREPLSET; + await replSet.stop(); }; diff --git a/backend/tests/models/services/entry/entry.test.ts b/backend/tests/models/services/entry/entry.test.ts index 7ae0cda..ebb4b46 100644 --- a/backend/tests/models/services/entry/entry.test.ts +++ b/backend/tests/models/services/entry/entry.test.ts @@ -6,7 +6,6 @@ import * as CdGptServices from '../../../../src/models/services/CdGpt.js'; import * as EntryServices from '../../../../src/models/services/entry/entry.js'; import { Config, Entry, EntryAnalysis, EntryConversation, Journal, User } from '../../../../src/models/index.js'; import mongoose, { HydratedDocument } from 'mongoose'; -import { ChatMessage } from '../../../../src/models/entry/entryConversation.js'; import { JournalType } from '../../../../src/models/journal.js'; import { UserType } from '../../../../src/models/user.js'; import connectDB from '../../../../src/db.js'; @@ -33,283 +32,286 @@ describe('Entry service tests', () => { await mongoose.disconnect(); }); - it('gets no entries in an empty journal', async () => { - const entries = await EntryServices.getAllEntriesInJournal(mockJournal.id); + describe('Get Entry service operation tests', () => { + it('gets no entries in an empty journal', async () => { + const entries = await EntryServices.getAllEntriesInJournal(mockJournal.id); - expect(entries).toHaveLength(0); - }); + expect(entries).toHaveLength(0); + }); - it('returns empty list on error when getting all entries', async () => { - const entries = await EntryServices.getAllEntriesInJournal('bad id'); + it('returns empty list on error when getting all entries', async () => { + const entries = await EntryServices.getAllEntriesInJournal('bad id'); - expect(entries).toHaveLength(0); - }); + expect(entries).toHaveLength(0); + }); - it('gets all entries in a journal', async () => { - const mockEntry1 = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockEntry2 = new Entry({ journal: mockJournal.id, content: 'mock content' }); - await mockEntry1.save(); - await mockEntry2.save(); + it('gets all entries in a journal', async () => { + const mockEntry1 = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntry2 = new Entry({ journal: mockJournal.id, content: 'mock content' }); + await mockEntry1.save(); + await mockEntry2.save(); - const entries = await EntryServices.getAllEntriesInJournal(mockJournal.id); + const entries = await EntryServices.getAllEntriesInJournal(mockJournal.id); - expect(entries).toHaveLength(2); - }); + expect(entries).toHaveLength(2); + }); - it('gets entries from only one journal', async () => { - const mockJournal2 = new Journal({ user: mockUser.id }); - const mockEntry1 = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockEntry2 = new Entry({ journal: mockJournal2.id, content: 'mock content' }); - await mockEntry1.save(); - await mockEntry2.save(); + it('gets entries from only one journal', async () => { + const mockJournal2 = new Journal({ user: mockUser.id }); + const mockEntry1 = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntry2 = new Entry({ journal: mockJournal2.id, content: 'mock content' }); + await mockEntry1.save(); + await mockEntry2.save(); - const entries1 = await EntryServices.getAllEntriesInJournal(mockJournal.id); - const entries2 = await EntryServices.getAllEntriesInJournal(mockJournal2.id); + const entries1 = await EntryServices.getAllEntriesInJournal(mockJournal.id); + const entries2 = await EntryServices.getAllEntriesInJournal(mockJournal2.id); - expect(entries1).toHaveLength(1); - expect(entries2).toHaveLength(1); - }); + expect(entries1).toHaveLength(1); + expect(entries2).toHaveLength(1); + }); - it('gets Entry by entryId populated with EntryAnalysis and EntryConversation', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); - const mockConversation = new EntryConversation({ entry: mockEntry, messages: [] }); - mockEntry.analysis = mockAnalysis.id; - mockEntry.conversation = mockConversation.id; - await mockAnalysis.save(); - await mockConversation.save(); - await mockEntry.save(); - - const sut = await EntryServices.getPopulatedEntry(mockEntry.id); - - expect(sut?.id).toBe(mockEntry.id); - expect(sut?.analysis.entry.toString()).toBe(mockEntry.id); - expect(sut?.analysis.analysis_content).toBe('test content'); - expect(sut?.analysis.created_at).toBeDefined(); - expect(sut?.analysis.updated_at).toBeDefined(); - expect(sut?.conversation.entry.toString()).toBe(mockEntry.id); - expect(sut?.conversation.messages).toBeDefined(); - }); + it('gets Entry by entryId populated with EntryAnalysis and EntryConversation', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + const mockConversation = new EntryConversation({ entry: mockEntry, messages: [] }); + mockEntry.analysis = mockAnalysis.id; + mockEntry.conversation = mockConversation.id; + await mockAnalysis.save(); + await mockConversation.save(); + await mockEntry.save(); - it('gets EntryAnalysis by entryId entry populated with Entry', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); - mockEntry.analysis = mockAnalysis.id; - await mockAnalysis.save(); - await mockEntry.save(); + const sut = await EntryServices.getPopulatedEntry(mockEntry.id); - const sut = await EntryServices.getPopluatedEntryAnalysis(mockEntry.id); + expect(sut!.id).toBe(mockEntry.id); + expect(sut!.analysis.entry.toString()).toBe(mockEntry.id); + expect(sut!.analysis.analysis_content).toBe('test content'); + expect(sut!.analysis.created_at).toBeDefined(); + expect(sut!.analysis.updated_at).toBeDefined(); + expect(sut!.conversation.entry.toString()).toBe(mockEntry.id); + expect(sut!.conversation.messages).toBeDefined(); + }); - expect(sut?.id).toBe(mockAnalysis.id); - expect(sut?.entry.content).toBe('mock content'); - expect(sut?.entry.journal.toString()).toBe(mockJournal.id); - }); + it('gets EntryAnalysis by entryId entry populated with Entry', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + mockEntry.analysis = mockAnalysis.id; + await mockAnalysis.save(); + await mockEntry.save(); - it('returns null on error when getting populated EntryAnalysis', async () => { - const sut = await EntryServices.getPopluatedEntryAnalysis('bad id'); + const sut = await EntryServices.getPopluatedEntryAnalysis(mockEntry.id); + + expect(sut!.id).toBe(mockAnalysis.id); + expect(sut!.entry.content).toBe('mock content'); + expect(sut!.entry.journal.toString()).toBe(mockJournal.id); + }); + + it('returns null on error when getting populated EntryAnalysis', async () => { + const sut = await EntryServices.getPopluatedEntryAnalysis('bad id'); - expect(sut).toBeNull(); - }); + expect(sut).toBeNull(); + }); - it('gets EntryConversation by entryId', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockConversation = new EntryConversation({ entry: mockEntry, messages: [] }); - await mockConversation.save(); - await mockEntry.save(); + it('gets EntryConversation by entryId', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockConversation = new EntryConversation({ entry: mockEntry, messages: [] }); + await mockConversation.save(); + await mockEntry.save(); - const sut = await EntryServices.getEntryConversation(mockEntry.id); + const sut = await EntryServices.getEntryConversation(mockEntry.id); - expect(sut?.id).toBe(mockConversation.id); - }); + expect(sut!.id).toBe(mockConversation.id); + }); - it('returns null on error when getting EntryConversation', async () => { - const sut = await EntryServices.getEntryConversation('bad id'); + it('returns null on error when getting EntryConversation', async () => { + const sut = await EntryServices.getEntryConversation('bad id'); - expect(sut).toBeNull(); + expect(sut).toBeNull(); + }); }); - it('creates Entry with valid journal id, config id, and content', async () => { - const mockEntryContent = { - content: 'mock content', - }; - const mockConfig = await Config.create({ model: {} }); - mockedCdGptServices.getAnalysisContent.mockResolvedValue(undefined); - - const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); - const testAnalysis = await EntryAnalysis.findById(sut.analysis); - - expect(errMessage).toBeUndefined(); - expect(sut.title).toBe('Untitled'); - expect(sut.journal.toString()).toBe(mockJournal.id); - expect(sut.content).toBe('mock content'); - expect(sut.tags).toStrictEqual([]); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe('Analysis not available'); - }); + describe('Create operation Entry service tests', () => { + it('creates Entry with valid journal id, config id, and content', async () => { + const mockEntryContent = { + content: 'mock content', + }; + const mockConfig = await Config.create({ model: {} }); + mockedCdGptServices.getAnalysisContent.mockResolvedValue(undefined); - it('creates Entry with valid journal id, config id, and content with analysis returned', async () => { - const mockEntryContent = { - content: 'mock content', - }; - const mockConfig = await Config.create({ model: {} }); - const mockAnalysisContent = { - title: 'Mock Title', - mood: 'mock mood', - tags: ['test', 'mock'], - analysis_content: 'mock analysis content', - }; - mockedCdGptServices.getAnalysisContent.mockResolvedValue(mockAnalysisContent); - - const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); - const testAnalysis = await EntryAnalysis.findById(sut.analysis); - - expect(errMessage).toBeUndefined(); - expect(sut.title).toBe(mockAnalysisContent.title); - expect(sut.journal.toString()).toBe(mockJournal.id); - expect(sut.mood).toBe(mockAnalysisContent.mood); - expect(sut.content).toBe('mock content'); - expect(sut.tags).toStrictEqual(mockAnalysisContent.tags); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe(mockAnalysisContent.analysis_content); - }); + const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); + const testAnalysis = await EntryAnalysis.findById(sut.analysis); - it('returns error message when getting analysis content throws error', async () => { - const mockEntryContent = { - journal: mockJournal.id, - content: 'mock content', - }; - const mockConfig = await Config.create({ model: {} }); - mockedCdGptServices.getAnalysisContent.mockRejectedValue(new Error('test error message')); - - const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); - const testAnalysis = await EntryAnalysis.findById(sut.analysis); - - expect(errMessage).toBe('test error message'); - expect(sut.title).toBe('Untitled'); - expect(sut.journal.toString()).toBe(mockJournal.id); - expect(sut.mood).toBeUndefined(); - expect(sut.content).toBe('mock content'); - expect(sut.tags).toStrictEqual([]); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe('Analysis not available'); - }); + expect(errMessage).toBeUndefined(); + expect(sut.title).toBe('Untitled'); + expect(sut.journal.toString()).toBe(mockJournal.id); + expect(sut.content).toBe('mock content'); + expect(sut.tags).toStrictEqual([]); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe('Analysis not available'); + }); + + it('creates Entry with valid journal id, config id, and content with analysis returned', async () => { + const mockEntryContent = { + content: 'mock content', + }; + const mockConfig = await Config.create({ model: {} }); + const mockAnalysisContent = { + title: 'Mock Title', + mood: 'mock mood', + tags: ['test', 'mock'], + analysis_content: 'mock analysis content', + }; + mockedCdGptServices.getAnalysisContent.mockResolvedValue(mockAnalysisContent); + + const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); + const testAnalysis = await EntryAnalysis.findById(sut.analysis); + + expect(errMessage).toBeUndefined(); + expect(sut.title).toBe(mockAnalysisContent.title); + expect(sut.journal.toString()).toBe(mockJournal.id); + expect(sut.mood).toBe(mockAnalysisContent.mood); + expect(sut.content).toBe('mock content'); + expect(sut.tags).toStrictEqual(mockAnalysisContent.tags); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe(mockAnalysisContent.analysis_content); + }); + + it('returns error message when getting analysis content throws error', async () => { + const mockEntryContent = { + journal: mockJournal.id, + content: 'mock content', + }; + const mockConfig = await Config.create({ model: {} }); + mockedCdGptServices.getAnalysisContent.mockRejectedValue(new Error('test error message')); + + const { errMessage, entry: sut } = await EntryServices.createEntry(mockJournal.id, mockConfig.id, mockEntryContent); + const testAnalysis = await EntryAnalysis.findById(sut.analysis); + + expect(errMessage).toBe('test error message'); + expect(sut.title).toBe('Untitled'); + expect(sut.journal.toString()).toBe(mockJournal.id); + expect(sut.mood).toBeUndefined(); + expect(sut.content).toBe('mock content'); + expect(sut.tags).toStrictEqual([]); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe('Analysis not available'); + }); - it('creates and saves EntryConversation with valid input', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); - mockEntry.analysis = mockEntryAnalysis.id; - await mockEntry.save(); - const mockConfig = await Config.create({ model: {} }); - const mockMessageData = { - messages: [ - { - message_content: 'test message', - llm_response: 'mock llm response' - }, - ] - }; - const mockLlmContent = 'mock llm chat response'; - jest.spyOn(CdGptServices, 'getChatContent').mockResolvedValue(mockLlmContent); + it('creates and saves EntryConversation with valid input', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); + mockEntry.analysis = mockEntryAnalysis.id; + await mockEntry.save(); + const mockConfig = await Config.create({ model: {} }); + const mockMessageData = { + messages: [ + { + message_content: 'test message', + llm_response: 'mock llm response' + }, + ] + }; + const mockLlmContent = 'mock llm chat response'; + mockedCdGptServices.getChatContent.mockResolvedValue(mockLlmContent); - const sut = await EntryServices.createEntryConversation( - mockEntry.id, - mockConfig.id, - mockMessageData - ); - - expect(sut.entry.toString()).toBe(mockEntry.id); - expect(sut.messages?.length).toBe(1); - expect((sut.messages as ChatMessage[])[0].message_content).toBe(mockMessageData.messages[0].message_content); - expect((sut.messages as ChatMessage[])[0].llm_response).toBe(mockLlmContent); - }); + const sut = await EntryServices.createEntryConversation( + mockEntry.id, + mockConfig.id, + mockMessageData + ); + + expect(sut.entry.toString()).toBe(mockEntry.id); + expect(sut.messages!).toHaveLength(1); + expect(sut.messages![0].message_content).toBe(mockMessageData.messages[0].message_content); + expect(sut.messages![0].llm_response).toBe(mockLlmContent); + }); - it('throws error on missing entry when creating EntryConversation', async () => { - const nonexistentEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockConfig = new Config({ model: {} }); - const mockMessageData = { - messages: [ - { - message_content: 'test message', - llm_response: 'mock llm response' - }, - ] - }; + it('throws error on missing entry when creating EntryConversation', async () => { + const nonexistentEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockConfig = new Config({ model: {} }); + const mockMessageData = { + messages: [ + { + message_content: 'test message', + llm_response: 'mock llm response' + }, + ] + }; - await expect(EntryServices.createEntryConversation( - nonexistentEntry.id, - mockConfig.id, - mockMessageData - )).rejects.toThrow('Entry not found.'); - }); + await expect(EntryServices.createEntryConversation( + nonexistentEntry.id, + mockConfig.id, + mockMessageData + )).rejects.toThrow('Entry not found.'); + }); - it('throws error on missing entry.analysis when creating EntryConversation', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - await mockEntry.save(); - const mockConfig = new Config({ model: {} }); - const mockMessageData = { - messages: [ - { - message_content: 'test message', - llm_response: 'mock llm response' - }, - ] - }; + it('throws error on missing entry.analysis when creating EntryConversation', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + await mockEntry.save(); + const mockConfig = new Config({ model: {} }); + const mockMessageData = { + messages: [ + { + message_content: 'test message', + llm_response: 'mock llm response' + }, + ] + }; - await expect(EntryServices.createEntryConversation( - mockEntry.id, - mockConfig.id, - mockMessageData - )).rejects.toThrow('Entry analysis not found.'); - }); + await expect(EntryServices.createEntryConversation( + mockEntry.id, + mockConfig.id, + mockMessageData + )).rejects.toThrow('Entry analysis not found.'); + }); - it('throws error when new EntryConversation has empty messages', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); - mockEntry.analysis = mockEntryAnalysis.id; - await mockEntry.save(); - const mockConfig = new Config({ model: {} }); - const mockMessageData = { - messages: [ - ] - }; + it('throws error when new EntryConversation has empty messages', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); + mockEntry.analysis = mockEntryAnalysis.id; + await mockEntry.save(); + const mockConfig = new Config({ model: {} }); + const mockMessageData = { + messages: [ + ] + }; - await expect(EntryServices.createEntryConversation( - mockEntry.id, - mockConfig.id, - mockMessageData - )).rejects.toThrow('No message to get completion for.'); - }); + await expect(EntryServices.createEntryConversation( + mockEntry.id, + mockConfig.id, + mockMessageData + )).rejects.toThrow('No message to get completion for.'); + }); - it('does not update EntryConversation if llm response is empty', async () => { - const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); - const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); - mockEntry.analysis = mockEntryAnalysis.id; - await mockEntry.save(); - const mockConfig = await Config.create({ model: {} }); - const mockMessageData = { - messages: [ - { - message_content: 'test message', - llm_response: 'mock llm response' - }, - ] - }; - const mockLlmContent = ''; - jest.spyOn(CdGptServices, 'getChatContent').mockResolvedValue(mockLlmContent); + it('does not update EntryConversation if llm response is empty', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); + mockEntry.analysis = mockEntryAnalysis.id; + await mockEntry.save(); + const mockConfig = await Config.create({ model: {} }); + const mockMessageData = { + messages: [ + { + message_content: 'test message', + llm_response: 'mock llm response' + }, + ] + }; + const mockLlmContent = ''; + mockedCdGptServices.getChatContent.mockResolvedValue(mockLlmContent); - const sut = await EntryServices.createEntryConversation( - mockEntry.id, - mockConfig.id, - mockMessageData - ); - - expect(sut.entry.toString()).toBe(mockEntry.id); - expect(sut.messages?.length).toBe(1); - expect((sut.messages as ChatMessage[])[0].message_content).toBe(mockMessageData.messages[0].message_content); - expect((sut.messages as ChatMessage[])[0].llm_response).toBe(mockMessageData.messages[0].llm_response); + const sut = await EntryServices.createEntryConversation( + mockEntry.id, + mockConfig.id, + mockMessageData + ); + + expect(sut.entry.toString()).toBe(mockEntry.id); + expect(sut.messages!).toHaveLength(1); + expect(sut.messages![0].message_content).toBe(mockMessageData.messages[0].message_content); + expect(sut.messages![0].llm_response).toBe(mockMessageData.messages[0].llm_response); + }); }); - describe('updateEntry tests', () => { it('updates Entry with valid journal id, config id, and content', async () => { const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); @@ -331,8 +333,8 @@ describe('Entry service tests', () => { expect(sut.journal.toString()).toBe(mockJournal.id); expect(sut.content).toBe('mock content'); expect(sut.tags).toStrictEqual([]); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe('Analysis not available'); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe('Analysis not available'); }); it('updates Entry with valid journal id, config id, and content with analysis returned', async () => { @@ -362,8 +364,8 @@ describe('Entry service tests', () => { expect(sut.mood).toBe(mockAnalysisContent.mood); expect(sut.content).toBe('mock content'); expect(sut.tags).toStrictEqual(mockAnalysisContent.tags); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe(mockAnalysisContent.analysis_content); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe(mockAnalysisContent.analysis_content); }); it('returns error message when getting analysis content throws error', async () => { @@ -387,8 +389,8 @@ describe('Entry service tests', () => { expect(sut.mood).toBeUndefined(); expect(sut.content).toBe('mock content'); expect(sut.tags).toStrictEqual([]); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe('Analysis not available'); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe('Analysis not available'); }); it('updates title when content is empty', async () => { @@ -410,8 +412,108 @@ describe('Entry service tests', () => { expect(sut.mood).toBeUndefined(); expect(sut.content).toBe('mock content'); expect(sut.tags).toStrictEqual([]); - expect(sut.analysis?.toString()).toBe(testAnalysis?.id); - expect(testAnalysis?.analysis_content).toBe('Analysis not available'); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe('Analysis not available'); + }); + }); + + describe('Update EntryAnalysis tests', () => { + it('updates EntryAnalysis', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockEntryAnalysis = await EntryAnalysis.create({ entry: mockEntry.id }); + mockEntry.analysis = mockEntryAnalysis.id; + await mockEntry.save(); + const updateContent = { + analysis_content: 'llm updated content', + title: 'llm updated title', + mood: 'llm updated mood', + tags: ['new tag'], + }; + const mockConfig = await Config.create({ model: {} }); + mockedCdGptServices.getAnalysisContent.mockResolvedValue(updateContent); + + const { errMessage, entry: sut } = await EntryServices.updateEntryAnalysis(mockEntry.id, mockConfig.id); + const testAnalysis = await EntryAnalysis.findById(sut.analysis); + + expect(errMessage).toBeUndefined(); + expect(sut.title).toBe(updateContent.title); + expect(sut.journal.toString()).toBe(mockJournal.id); + expect(sut.content).toBe('mock content'); + expect(sut.tags).toStrictEqual(updateContent.tags); + expect(sut.analysis!.toString()).toBe(testAnalysis!.id); + expect(testAnalysis!.analysis_content).toBe(updateContent.analysis_content); + expect(sut.mood).toBe(updateContent.mood); + }); + }); + + describe('Update EntryConversation tests', () => { + it('updates EntryConversation with valid input', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + const mockChat = new EntryConversation({ entry: mockEntry, messages: [] }); + await mockChat.save(); + await mockAnalysis.save(); + await mockEntry.save(); + const mockConfig = await Config.create({ model: {} }); + const mockMessageData = { + messages: [ + { + message_content: 'message_content', + llm_response: 'llm_response', + }, + ] + }; + const mockLlmResponse = 'test chat llm response'; + mockedCdGptServices.getChatContent.mockResolvedValue(mockLlmResponse); + + const sut = await EntryServices.updateEntryConversation(mockChat.id, mockConfig.id, mockMessageData); + + expect(sut.messages![0].message_content).toBe('message_content'); + expect(sut.messages![0].llm_response).toBe('test chat llm response'); + }); + }); + + describe('Delete Entry tests', () => { + it('deletes Entry, EntryAnalysis, and EntryConversation by ID', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + const mockChat = new EntryConversation({ entry: mockEntry, messages: [] }); + await mockChat.save(); + await mockAnalysis.save(); + await mockEntry.save(); + + await EntryServices.deleteEntry(mockEntry.id); + + await expect(Entry.findById(mockEntry.id)).resolves.toBeNull(); + await expect(EntryAnalysis.findById(mockAnalysis.id)).resolves.toBeNull(); + await expect(EntryConversation.findById(mockChat.id)).resolves.toBeNull(); + }); + + it('throws error if entry not found', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + const mockChat = new EntryConversation({ entry: mockEntry, messages: [] }); + await mockChat.save(); + await mockAnalysis.save(); + + await expect(EntryServices.deleteEntry(mockEntry.id)).rejects.toThrow('Entry not found.'); + await expect(EntryAnalysis.findById(mockAnalysis.id)).resolves.toBeDefined(); + await expect(EntryConversation.findById(mockChat.id)).resolves.toBeDefined(); + }); + + it('aborts delete transcation on error', async () => { + const mockEntry = new Entry({ journal: mockJournal.id, content: 'mock content' }); + const mockAnalysis = new EntryAnalysis({ entry: mockEntry, analysis_content: 'test content', created_at: new Date(0), updated_at: new Date(0) }); + const mockChat = new EntryConversation({ entry: mockEntry, messages: [] }); + await mockChat.save(); + await mockAnalysis.save(); + await mockEntry.save(); + jest.spyOn(EntryAnalysis, 'deleteMany').mockRejectedValue(new Error('test transaction atomicity')); + + await expect(EntryServices.deleteEntry(mockEntry.id)).rejects.toThrow('test transaction atomicity'); + await expect(Entry.findById(mockEntry.id)).resolves.toBeDefined(); + await expect(EntryAnalysis.findById(mockAnalysis.id)).resolves.toBeDefined(); + await expect(EntryConversation.findById(mockChat.id)).resolves.toBeDefined(); }); }); }); \ No newline at end of file