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
88 changes: 88 additions & 0 deletions apps/backend/src/db/activities.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,5 +526,93 @@ describe('Activities Integration Tests', () => {
expect(updated).not.toBeNull()
expect(updated?.title).toBe('Morning meditation')
})

test('updates data field on activity', async () => {
const user = getTestUser()
const activityId = randomUUID()

await insertActivity(user, {
activity_type: 'exercise',
end_time: new Date('2024-01-15T11:00:00Z'),
id: activityId,
source: 'manual',
start_time: new Date('2024-01-15T10:00:00Z'),
})

const updated = await updateActivity(user, activityId, {
data: { exerciseType: 81, exerciseTypeName: 'weightlifting' },
})

expect(updated).not.toBeNull()
expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' })
})

test('replaces entire data field (no partial merge at db level)', async () => {
const user = getTestUser()
const activityId = randomUUID()

await insertActivity(user, {
activity_type: 'exercise',
data: { calories: 300, exerciseType: 70, exerciseTypeName: 'strength_training' },
end_time: new Date('2024-01-15T11:00:00Z'),
id: activityId,
source: 'manual',
start_time: new Date('2024-01-15T10:00:00Z'),
})

// DB layer replaces data entirely; merging is done in the service layer
const updated = await updateActivity(user, activityId, {
data: { exerciseType: 81, exerciseTypeName: 'weightlifting' },
})

expect(updated).not.toBeNull()
expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' })
})

test('preserves data when updating other fields', async () => {
const user = getTestUser()
const activityId = randomUUID()

await insertActivity(user, {
activity_type: 'exercise',
data: { exerciseType: 81, exerciseTypeName: 'weightlifting' },
end_time: new Date('2024-01-15T11:00:00Z'),
id: activityId,
source: 'manual',
start_time: new Date('2024-01-15T10:00:00Z'),
})

const updated = await updateActivity(user, activityId, {
title: 'Heavy lifting session',
})

expect(updated).not.toBeNull()
expect(updated?.title).toBe('Heavy lifting session')
expect(updated?.data).toEqual({ exerciseType: 81, exerciseTypeName: 'weightlifting' })
})

test('updates data and other fields together', async () => {
const user = getTestUser()
const activityId = randomUUID()

await insertActivity(user, {
activity_type: 'exercise',
end_time: new Date('2024-01-15T11:00:00Z'),
id: activityId,
source: 'manual',
start_time: new Date('2024-01-15T10:00:00Z'),
})

const updated = await updateActivity(user, activityId, {
data: { exerciseType: 56, exerciseTypeName: 'running' },
notes: 'Morning run in the park',
title: 'Morning Run',
})

expect(updated).not.toBeNull()
expect(updated?.title).toBe('Morning Run')
expect(updated?.notes).toBe('Morning run in the park')
expect(updated?.data).toEqual({ exerciseType: 56, exerciseTypeName: 'running' })
})
})
})
1 change: 1 addition & 0 deletions apps/backend/src/db/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export const updateActivity = async (
if (updates.end_time !== undefined) fields.push({ column: 'end_time', value: updates.end_time })
if (updates.title !== undefined) fields.push({ column: 'title', value: updates.title })
if (updates.notes !== undefined) fields.push({ column: 'notes', value: updates.notes })
if (updates.data !== undefined) fields.push({ column: 'data', value: JSON.stringify(updates.data) })

if (fields.length === 0) return getActivityById(user, id)

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export interface ActivityUpdate {
end_time?: Date
title?: string
notes?: string
data?: Record<string, unknown>
}

// ============================================================================
Expand Down
199 changes: 199 additions & 0 deletions apps/backend/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ vi.mock('./services/mutations', () => ({
addCustomMetric: vi.fn(),
addMetric: vi.fn(),
addTag: vi.fn(),
deleteActivity: vi.fn(),
deleteCustomMetric: vi.fn(),
deleteTag: vi.fn(),
getCustomMetrics: vi.fn().mockResolvedValue([]),
restoreActivity: vi.fn(),
updateActivity: vi.fn(),
}))

// Mock db for sync status and stored detected locations
Expand Down Expand Up @@ -1182,6 +1185,202 @@ describe('MCP Server', () => {
})
})

describe('Tool: update_activity', () => {
async function initializeSession(app: express.Express, token: string) {
const response = await mcpPost(app)
.set('Authorization', `Bearer ${token}`)
.send({
id: 1,
jsonrpc: '2.0',
method: 'initialize',
params: {
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
protocolVersion: '2024-11-05',
},
})
return response.headers['mcp-session-id'] as string
}

async function callTool(
app: express.Express,
token: string,
sessionId: string,
toolName: string,
args: Record<string, unknown>,
) {
const response = await mcpPost(app)
.set('Authorization', `Bearer ${token}`)
.set('Mcp-Session-Id', sessionId)
.send({
id: 2,
jsonrpc: '2.0',
method: 'tools/call',
params: { arguments: args, name: toolName },
})

const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } }
return {
...response,
parsed,
toolResult: JSON.parse(parsed.result.content[0].text),
}
}

const testActivityId = '00000000-0000-4000-a000-000000000001'

test('updates activity with exercise_type', async () => {
const app = createTestApp()
const token = auth.createToken('testuser')
const sessionId = await initializeSession(app, token)

vi.mocked(mutations.updateActivity).mockResolvedValue({
activity_type: 'exercise',
end_time: '2024-03-15T11:00:00.000Z',
id: testActivityId,
start_time: '2024-03-15T10:00:00.000Z',
success: true,
title: 'Workout',
})

const response = await callTool(app, token, sessionId, 'update_activity', {
exercise_type: 'weightlifting',
id: testActivityId,
title: 'Workout',
})

expect(response.status).toBe(200)
expect(response.toolResult.success).toBe(true)
expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, {
data: {
exerciseType: 81,
exerciseTypeName: 'weightlifting',
},
end_time: undefined,
notes: undefined,
start_time: undefined,
title: 'Workout',
})
})

test('updates activity without exercise_type', async () => {
const app = createTestApp()
const token = auth.createToken('testuser')
const sessionId = await initializeSession(app, token)

vi.mocked(mutations.updateActivity).mockResolvedValue({
activity_type: 'exercise',
end_time: '2024-03-15T11:00:00.000Z',
id: testActivityId,
notes: 'Great session',
start_time: '2024-03-15T10:00:00.000Z',
success: true,
})

const response = await callTool(app, token, sessionId, 'update_activity', {
id: testActivityId,
notes: 'Great session',
})

expect(response.status).toBe(200)
expect(response.toolResult.success).toBe(true)
expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, {
data: undefined,
end_time: undefined,
notes: 'Great session',
start_time: undefined,
title: undefined,
})
})

test('returns error for invalid exercise_type', async () => {
const app = createTestApp()
const token = auth.createToken('testuser')
const sessionId = await initializeSession(app, token)

const response = await mcpPost(app)
.set('Authorization', `Bearer ${token}`)
.set('Mcp-Session-Id', sessionId)
.send({
id: 2,
jsonrpc: '2.0',
method: 'tools/call',
params: {
arguments: {
exercise_type: 'not_a_real_exercise',
id: testActivityId,
},
name: 'update_activity',
},
})

expect(response.status).toBe(200)
const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } }
expect(parsed.result.content[0].text).toContain('Invalid exercise_type')
expect(mutations.updateActivity).not.toHaveBeenCalled()
})

test('passes time updates as Date objects', async () => {
const app = createTestApp()
const token = auth.createToken('testuser')
const sessionId = await initializeSession(app, token)

vi.mocked(mutations.updateActivity).mockResolvedValue({
activity_type: 'exercise',
end_time: '2024-03-15T12:00:00.000Z',
id: testActivityId,
start_time: '2024-03-15T09:00:00.000Z',
success: true,
})

await callTool(app, token, sessionId, 'update_activity', {
end_time: '2024-03-15T12:00:00Z',
id: testActivityId,
start_time: '2024-03-15T09:00:00Z',
})

expect(mutations.updateActivity).toHaveBeenCalledWith('testuser', testActivityId, {
data: undefined,
end_time: expect.any(Date),
notes: undefined,
start_time: expect.any(Date),
title: undefined,
})
})

test('returns error from service on failure', async () => {
const app = createTestApp()
const token = auth.createToken('testuser')
const sessionId = await initializeSession(app, token)

vi.mocked(mutations.updateActivity).mockResolvedValue({
error: 'Activity not found',
id: testActivityId,
success: false,
})

const response = await mcpPost(app)
.set('Authorization', `Bearer ${token}`)
.set('Mcp-Session-Id', sessionId)
.send({
id: 2,
jsonrpc: '2.0',
method: 'tools/call',
params: {
arguments: {
id: testActivityId,
title: 'New title',
},
name: 'update_activity',
},
})

expect(response.status).toBe(200)
const parsed = parseSSEResponse(response.text) as { result: { content: { text: string }[] } }
expect(parsed.result.content[0].text).toContain('Activity not found')
})
})

describe('Session Persistence', () => {
function createTestAppWithStore(sessionStore: McpSessionStore) {
const app = express()
Expand Down
25 changes: 23 additions & 2 deletions apps/backend/src/mcp/activity-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,34 @@ export const registerActivityTools = (server: McpServer, user: string) => {
// Tool: update_activity
server.tool(
'update_activity',
'Update an existing activity. Can modify start_time, end_time, title, and notes. Only provided fields will be updated. Validates that end_time is after start_time (considering both new and existing values).',
'Update an existing activity. Can modify start_time, end_time, title, notes, and exercise_type. Only provided fields will be updated. Validates that end_time is after start_time (considering both new and existing values).',
{
id: z.string().uuid().describe('The ID of the activity to update'),
...updateActivityBodySchema.shape,
// Override enum with z.string() to allow handler-level validation with friendlier error message
exercise_type: z
.string()
.optional()
.describe(
`New exercise type name (e.g., "weightlifting", "running"). Only for exercise activities. Valid types: ${exerciseTypeNames.slice(0, 10).join(', ')}...`,
),
},
async ({ id, start_time, end_time, title, notes }) => {
async ({ id, start_time, end_time, title, notes, exercise_type }) => {
let data: Record<string, unknown> | undefined
if (exercise_type !== undefined) {
if (!isValidExerciseType(exercise_type)) {
return errorResponse(
`Invalid exercise_type "${exercise_type}". Valid types include: ${exerciseTypeNames.slice(0, 15).join(', ')}...`,
)
}
data = {
exerciseType: getExerciseTypeValue(exercise_type),
exerciseTypeName: exercise_type,
}
}

const result = await updateActivity(user, id, {
data,
end_time: end_time ? new Date(end_time) : undefined,
notes,
start_time: start_time ? new Date(start_time) : undefined,
Expand Down
18 changes: 17 additions & 1 deletion apps/backend/src/routes/activities-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,26 @@ export const createActivitiesRouter = (
validateBody(updateActivityBodySchema),
async (req, res) => {
const { id } = req.params
const { start_time, end_time, title, notes } = req.body
const { start_time, end_time, title, notes, exercise_type } = req.body
const user = req.user!

// Convert exercise_type name to data object if provided
let data: Record<string, unknown> | undefined
if (exercise_type !== undefined) {
if (!isValidExerciseType(exercise_type)) {
return res.status(400).json({
error: `Invalid exercise_type "${exercise_type}"`,
success: false,
})
}
data = {
exerciseType: getExerciseTypeValue(exercise_type),
exerciseTypeName: exercise_type,
}
}

const result = await updateActivity(user, id, {
data,
end_time: end_time ? new Date(end_time) : undefined,
notes,
start_time: start_time ? new Date(start_time) : undefined,
Expand Down
Loading
Loading