fix(server): session update check (#12877)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Bug Fixes**
- Improved validation to prevent updates to sessions with action prompts
and restrict certain updates on forked sessions.
- **Tests**
- Expanded and clarified test coverage for session updates, pinning
behavior, and session type conversions, with more explicit error
handling and validation scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-06-20 16:53:48 +08:00
committed by GitHub
parent c7113b0195
commit 13b64c6780
4 changed files with 191 additions and 43 deletions

View File

@@ -105,6 +105,52 @@ Generated by [AVA](https://avajs.dev).
},
]
## should handle session updates and validations
> should unpin existing when pinning new session
[
{
docId: null,
id: 'session-update-id',
pinned: true,
},
{
docId: null,
id: 'existing-pinned-session-id',
pinned: false,
},
]
> session type conversion steps
[
{
session: {
docId: 'doc-update-id',
pinned: false,
},
step: 'pinned_to_doc',
type: 'doc',
},
{
session: {
docId: null,
pinned: false,
},
step: 'doc_to_workspace',
type: 'workspace',
},
{
session: {
docId: null,
pinned: true,
},
step: 'workspace_to_pinned',
type: 'pinned',
},
]
## session updates and type conversions
> session states after pinning - should unpin existing

View File

@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { PrismaClient, User, Workspace } from '@prisma/client';
import ava, { ExecutionContext, TestFn } from 'ava';
import { CopilotPromptInvalid } from '../../base';
import { CopilotPromptInvalid, CopilotSessionInvalidInput } from '../../base';
import {
CopilotSessionModel,
UpdateChatSessionData,
@@ -289,56 +289,153 @@ test('should pin and unpin sessions', async t => {
}
});
test('session updates and type conversions', async t => {
test('should handle session updates and validations', async t => {
const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db);
const sessionId = 'session-update-id';
const actionSessionId = 'action-session-id';
const parentSessionId = 'parent-session-id';
const forkedSessionId = 'forked-session-id';
const docId = 'doc-update-id';
await createTestSession(t, { sessionId });
await createTestSession(t, {
sessionId: actionSessionId,
promptName: 'action-prompt',
promptAction: 'edit',
docId: 'some-doc',
});
await createTestSession(t, {
sessionId: parentSessionId,
docId: 'parent-doc',
});
await db.aiSession.create({
data: {
id: forkedSessionId,
workspaceId: workspace.id,
userId: user.id,
docId: 'forked-doc',
pinned: false,
promptName: 'test-prompt',
promptAction: null,
parentSessionId: parentSessionId,
},
});
// should unpin existing pinned session
const assertUpdateThrows = async (
t: ExecutionContext<Context>,
sessionId: string,
updateData: UpdateChatSessionData,
message: string
) => {
await t.throwsAsync(
t.context.copilotSession.update(user.id, sessionId, updateData),
{ instanceOf: CopilotSessionInvalidInput },
message
);
};
const assertUpdate = async (
t: ExecutionContext<Context>,
sessionId: string,
updateData: UpdateChatSessionData,
message: string
) => {
await t.notThrowsAsync(
t.context.copilotSession.update(user.id, sessionId, updateData),
message
);
};
// case 1: action sessions should reject all updates
{
const actionUpdates = [
{ docId: 'new-doc' },
{ pinned: true },
{ promptName: 'test-prompt' },
];
for (const data of actionUpdates) {
await assertUpdateThrows(
t,
actionSessionId,
data,
`action session should reject update: ${JSON.stringify(data)}`
);
}
}
// case 2: forked sessions should reject docId updates but allow others
{
await assertUpdate(
t,
forkedSessionId,
{ pinned: true },
'forked session should allow pinned update'
);
await assertUpdate(
t,
forkedSessionId,
{ promptName: 'test-prompt' },
'forked session should allow promptName update'
);
await assertUpdateThrows(
t,
forkedSessionId,
{ docId: 'new-doc' },
'forked session should reject docId update'
);
}
{
// case 3: prompt update validation
await assertUpdate(
t,
sessionId,
{ promptName: 'test-prompt' },
'should allow valid non-action prompt'
);
await assertUpdateThrows(
t,
sessionId,
{ promptName: 'action-prompt' },
'should reject action prompt'
);
await assertUpdateThrows(
t,
sessionId,
{ promptName: 'non-existent-prompt' },
'should reject non-existent prompt'
);
}
// cest 4: session type conversions and pinning behavior
{
const existingPinnedId = 'existing-pinned-session-id';
await createTestSession(t, { sessionId: existingPinnedId, pinned: true });
// should unpin existing when pinning new session
await copilotSession.update(user.id, sessionId, { pinned: true });
const sessionStatesAfterPin = await Promise.all([
getSessionState(db, sessionId),
getSessionState(db, existingPinnedId),
]);
t.snapshot(
sessionStatesAfterPin,
'session states after pinning - should unpin existing'
'should unpin existing when pinning new session'
);
}
// should unpin the session
{
await copilotSession.update(user.id, sessionId, { pinned: false });
const sessionStateAfterUnpin = await getSessionState(db, sessionId);
t.snapshot(sessionStateAfterUnpin, 'session state after unpinning');
}
// should convert session types
// test type conversions
{
const conversionSteps: any[] = [];
let session = await db.aiSession.findUnique({
where: { id: sessionId },
select: { docId: true, pinned: true },
});
const convertSession = async (
step: string,
data: UpdateChatSessionData
) => {
await copilotSession.update(user.id, sessionId, data);
session = await db.aiSession.findUnique({
const session = await db.aiSession.findUnique({
where: { id: sessionId },
select: { docId: true, pinned: true },
});
@@ -349,23 +446,14 @@ test('session updates and type conversions', async t => {
});
};
{
await convertSession('workspace_to_doc', { docId }); // Workspace → Doc session
await convertSession('doc_to_pinned', { pinned: true }); // Doc → Pinned session
await convertSession('pinned_to_workspace', {
pinned: false,
docId: null,
}); // Pinned → Workspace session
await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session
}
const conversions = [
['pinned_to_doc', { docId, pinned: false }],
['doc_to_workspace', { docId: null }],
['workspace_to_pinned', { pinned: true }],
] as const;
// not allow convert to action prompt
{
await t.throwsAsync(
copilotSession.update(user.id, sessionId, {
promptName: 'action-prompt',
})
);
for (const [step, data] of conversions) {
await convertSession(step, data);
}
t.snapshot(conversionSteps, 'session type conversion steps');

View File

@@ -293,18 +293,32 @@ export class CopilotSessionModel extends BaseModel {
): Promise<string> {
const session = await this.getExists(
sessionId,
{ id: true, workspaceId: true, docId: true, pinned: true, prompt: true },
{
id: true,
workspaceId: true,
docId: true,
parentSessionId: true,
pinned: true,
prompt: true,
},
{ userId }
);
if (!session) {
throw new CopilotSessionNotFound();
}
// not allow to update action session
if (session.prompt.action) {
throw new CopilotSessionInvalidInput(
`Cannot update action: ${session.id}`
);
} else if (data.docId && session.parentSessionId) {
throw new CopilotSessionInvalidInput(
`Cannot update docId for forked session: ${session.id}`
);
}
if (data.promptName) {
if (session.prompt.action) {
throw new CopilotSessionInvalidInput(
`Cannot update prompt for action: ${session.id}`
);
}
const prompt = await this.db.aiPrompt.findFirst({
where: { name: data.promptName },
});