Skip to content

Commit e719edb

Browse files
feat: Add error handling via ErrorModule (#20)
1 parent d57e43a commit e719edb

File tree

5 files changed

+221
-57
lines changed

5 files changed

+221
-57
lines changed

src/modules/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AuthenticationClient,
55
AuthModule,
66
CoreModule,
7+
ErrorModule,
78
LoginService,
89
TokenValidator,
910
UserDatabaseService as UserDatabaseServiceBase,
@@ -69,6 +70,11 @@ import { DatabaseModule } from './database.module'
6970
],
7071
exports: [GoogleSheetsService, GoogleLoginService],
7172
}),
73+
ErrorModule.forRoot({
74+
errorCardOptions: {
75+
helpCenterLink: Sheets.HELP_CENTER_LINK,
76+
},
77+
}),
7278
],
7379
providers: [
7480
AdaptiveCardService,

src/services/actions.service.spec.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { Test } from '@nestjs/testing'
55
import { getRepositoryToken } from '@nestjs/typeorm'
66
import MockDate from 'mockdate'
77

8-
import { buildTask, buildUser } from '../../test/fixtures'
8+
import { buildUser } from '../../test/fixtures'
9+
import { setupGetTasks, setupGetToken, setupGetUser } from '../../test/setups'
910
import { CardActions as SheetCardActions } from '../constants/card-actions'
1011
import { User } from '../entities/user.entity'
1112
import * as csvHelpers from '../utils/csv-helpers'
@@ -131,6 +132,9 @@ describe('ActionsService', () => {
131132
content: 'My Project',
132133
contentPlain: 'My Project',
133134
} as ContextMenuData,
135+
inputs: {
136+
'Input.completed': 'true',
137+
},
134138
},
135139
extensionType: 'context-menu',
136140
maximumDoistCardVersion: 0.5,
@@ -293,21 +297,4 @@ describe('ActionsService', () => {
293297
})
294298
})
295299
})
296-
297-
function setupGetUser(user: User | undefined) {
298-
const getUser = jest.spyOn(target['userDatabaseService'], 'getUser')
299-
getUser.mockImplementation(() => Promise.resolve(user))
300-
}
301-
302-
function setupGetToken(token: string | undefined) {
303-
jest.spyOn(target['googleSheetsService'], 'getCurrentOrRefreshedToken').mockImplementation(
304-
() => Promise.resolve(token ? { token, userId: '42' } : undefined),
305-
)
306-
}
307-
308-
function setupGetTasks() {
309-
jest.spyOn(TodoistApi.prototype, 'getTasks').mockImplementation(() =>
310-
Promise.resolve([buildTask()]),
311-
)
312-
}
313300
})

src/services/actions.service.ts

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { formatString } from '@doist/integrations-common'
2-
import { TodoistApi } from '@doist/todoist-api-typescript'
2+
import { Section, Task, TodoistApi } from '@doist/todoist-api-typescript'
33
import {
44
ActionsService as ActionsServiceBase,
55
CardActions,
66
DoistCardBridgeFactory,
7+
IntegrationException,
8+
isForbiddenError,
9+
isUnauthorizedError,
710
Submit,
811
TranslationService,
912
} from '@doist/ui-extensions-server'
1013

11-
import { Injectable } from '@nestjs/common'
14+
import { BadRequestException, Injectable } from '@nestjs/common'
1215

1316
import { getConfiguration } from '../config/configuration'
1417
import { CardActions as SheetsCardActions } from '../constants/card-actions'
@@ -97,48 +100,81 @@ export class ActionsService extends ActionsServiceBase {
97100
return this.logout(request)
98101
}
99102

103+
if (!action.inputs) {
104+
throw new IntegrationException({
105+
error: new BadRequestException(),
106+
})
107+
}
108+
100109
const contextData = action.params as ContextMenuData
101110
const todoistClient = new TodoistApi(getConfiguration().todoistAuthToken)
102111

103112
const exportOptions = getExportOptions(action.inputs)
104113

105-
const tasks = await todoistClient.getTasks({ projectId: contextData.sourceId })
114+
let tasks: Task[] = []
115+
let sections: Section[] = []
106116

107-
if (tasks.length === 0) {
108-
return {
109-
card: this.adaptiveCardsService.noTasksCard({
110-
projectName: contextData.content,
111-
}),
117+
try {
118+
tasks = await todoistClient.getTasks({ projectId: contextData.sourceId })
119+
120+
if (tasks.length === 0) {
121+
return {
122+
card: this.adaptiveCardsService.noTasksCard({
123+
projectName: contextData.content,
124+
}),
125+
}
112126
}
127+
128+
// Only fetch sections if we're using them, otherwise it's a wasted call
129+
sections = exportOptions['section']
130+
? await todoistClient.getSections(contextData.sourceId)
131+
: []
132+
} catch (error: unknown) {
133+
throw new IntegrationException({
134+
error,
135+
overrides: {
136+
retryAction: action,
137+
},
138+
})
113139
}
114140

115-
// Only fetch sections if we're using them, otherwise it's a wasted call
116-
const sections = exportOptions['section']
117-
? await todoistClient.getSections(contextData.sourceId)
118-
: []
141+
try {
142+
const csvData = convertTasksToCsvString({
143+
tasks,
144+
sections,
145+
exportOptions,
146+
})
119147

120-
const csvData = convertTasksToCsvString({
121-
tasks,
122-
sections,
123-
exportOptions,
124-
})
148+
const sheetUrl = await this.googleSheetsService.exportToSheets({
149+
title: this.createSheetName(contextData.content),
150+
csvData: csvData,
151+
authToken: token.token,
152+
})
125153

126-
const sheetUrl = await this.googleSheetsService.exportToSheets({
127-
title: this.createSheetName(contextData.content),
128-
csvData: csvData,
129-
authToken: token.token,
130-
})
154+
return {
155+
bridges: [
156+
DoistCardBridgeFactory.createNotificationBridge({
157+
text: this.translationService.getTranslation(Sheets.EXPORT_COMPLETED),
158+
type: 'success',
159+
actionText: this.translationService.getTranslation(Sheets.VIEW_SHEET),
160+
actionUrl: sheetUrl,
161+
}),
162+
DoistCardBridgeFactory.finished,
163+
],
164+
}
165+
} catch (error: unknown) {
166+
if (isUnauthorizedError(error) || isForbiddenError(error)) {
167+
// If we have become unauthorized, we need to treat this like a sign out
168+
// so tokens need to be removed.
169+
return this.logout(request)
170+
}
131171

132-
return {
133-
bridges: [
134-
DoistCardBridgeFactory.createNotificationBridge({
135-
text: this.translationService.getTranslation(Sheets.EXPORT_COMPLETED),
136-
type: 'success',
137-
actionText: this.translationService.getTranslation(Sheets.VIEW_SHEET),
138-
actionUrl: sheetUrl,
139-
}),
140-
DoistCardBridgeFactory.finished,
141-
],
172+
throw new IntegrationException({
173+
error,
174+
overrides: {
175+
retryAction: action,
176+
},
177+
})
142178
}
143179
}
144180

test/e2e/export.e2e-spec.ts

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import request from 'supertest'
44

55
import { CardActions as SheetCardActions } from '../../src/constants/card-actions'
66
import { GoogleSheetsService } from '../../src/services/google-sheets.service'
7-
import { UserDatabaseService } from '../../src/services/user-database.service'
87
import { buildUser } from '../fixtures'
8+
import { setupGetToken, setupGetUser } from '../setups'
99

1010
import { createTestApp } from './helpers'
1111

@@ -23,12 +23,9 @@ describe('export e2e tests', () => {
2323
})
2424

2525
it('returns the no tasks card if no tasks for the specified project', () => {
26-
jest.spyOn(UserDatabaseService.prototype, 'getUser').mockImplementation(() =>
27-
Promise.resolve(buildUser()),
28-
)
29-
jest.spyOn(GoogleSheetsService.prototype, 'getCurrentOrRefreshedToken').mockImplementation(
30-
() => Promise.resolve({ token: 'token', userId: '42' }),
31-
)
26+
setupGetUser(buildUser())
27+
setupGetToken('token')
28+
3229
jest.spyOn(TodoistApi.prototype, 'getTasks').mockImplementation(() => Promise.resolve([]))
3330

3431
return request(app.getHttpServer())
@@ -45,6 +42,9 @@ describe('export e2e tests', () => {
4542
content: 'My Project',
4643
contentPlain: 'My Project',
4744
},
45+
inputs: {
46+
'Input.completed': 'true',
47+
},
4848
},
4949
})
5050
.expect(200)
@@ -55,4 +55,114 @@ describe('export e2e tests', () => {
5555
expect(JSON.stringify(body)).toMatch(/\*\*My Project\*\* has no tasks to export/)
5656
})
5757
})
58+
59+
it('returns the error card if inputs are not present (should not happen, but you never know)', () => {
60+
setupGetUser(buildUser())
61+
setupGetToken('token')
62+
63+
return request(app.getHttpServer())
64+
.post('/process')
65+
.send({
66+
context: { user: { id: 42 }, theme: 'light' },
67+
action: {
68+
actionType: 'submit',
69+
actionId: SheetCardActions.Export,
70+
params: {
71+
source: 'project',
72+
sourceId: 1234,
73+
url: 'https://google.com',
74+
content: 'My Project',
75+
contentPlain: 'My Project',
76+
},
77+
},
78+
})
79+
.expect(400)
80+
.expect('Content-Type', /json/)
81+
.then((response) => {
82+
const body = response.body as DoistCardResponse
83+
expect(body.card).toBeDefined()
84+
85+
const json = JSON.stringify(body)
86+
expect(json).toMatch(/Unfortunately, it looks like something went wrong./)
87+
expect(json).not.toMatch(/Retry/)
88+
})
89+
})
90+
91+
it('returns the error card if talking to Todoist fails', () => {
92+
setupGetUser(buildUser())
93+
setupGetToken('token')
94+
95+
jest.spyOn(TodoistApi.prototype, 'getTasks').mockImplementation(() => {
96+
throw new Error('Error talking to Todoist')
97+
})
98+
99+
return request(app.getHttpServer())
100+
.post('/process')
101+
.send({
102+
context: { user: { id: 42 }, theme: 'light' },
103+
action: {
104+
actionType: 'submit',
105+
actionId: SheetCardActions.Export,
106+
params: {
107+
source: 'project',
108+
sourceId: 1234,
109+
url: 'https://google.com',
110+
content: 'My Project',
111+
contentPlain: 'My Project',
112+
},
113+
inputs: {
114+
'Input.completed': 'true',
115+
},
116+
},
117+
})
118+
.expect(500)
119+
.expect('Content-Type', /json/)
120+
.then((response) => {
121+
const body = response.body as DoistCardResponse
122+
expect(body.card).toBeDefined()
123+
124+
const json = JSON.stringify(body)
125+
expect(json).toMatch(/Unfortunately, it looks like something went wrong./)
126+
expect(json).toMatch(/Retry/)
127+
})
128+
})
129+
130+
it('returns the error card if talking to Google fails', () => {
131+
setupGetUser(buildUser())
132+
setupGetToken('token')
133+
134+
jest.spyOn(GoogleSheetsService.prototype, 'exportToSheets').mockImplementation(() => {
135+
throw new Error('Generic error talking to Google')
136+
})
137+
138+
return request(app.getHttpServer())
139+
.post('/process')
140+
.send({
141+
context: { user: { id: 42 }, theme: 'light' },
142+
action: {
143+
actionType: 'submit',
144+
actionId: SheetCardActions.Export,
145+
params: {
146+
source: 'project',
147+
sourceId: 1234,
148+
url: 'https://google.com',
149+
content: 'My Project',
150+
contentPlain: 'My Project',
151+
},
152+
inputs: {
153+
'Input.completed': 'true',
154+
},
155+
},
156+
})
157+
.expect(500)
158+
.expect('Content-Type', /json/)
159+
.then((response) => {
160+
const body = response.body as DoistCardResponse
161+
expect(body.card).toBeDefined()
162+
163+
const json = JSON.stringify(body)
164+
expect(json).toMatch(/Unfortunately, it looks like something went wrong./)
165+
expect(json).toMatch(/Retry/)
166+
})
167+
})
58168
})

test/setups.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { TodoistApi } from '@doist/todoist-api-typescript'
2+
3+
import { GoogleSheetsService } from '../src/services/google-sheets.service'
4+
import { UserDatabaseService } from '../src/services/user-database.service'
5+
6+
import { buildTask } from './fixtures'
7+
8+
import type { User } from '../src/entities/user.entity'
9+
10+
export function setupGetUser(user: User | undefined) {
11+
const getUser = jest.spyOn(UserDatabaseService.prototype, 'getUser')
12+
getUser.mockImplementation(() => Promise.resolve(user))
13+
}
14+
15+
export function setupGetToken(token: string | undefined) {
16+
jest.spyOn(GoogleSheetsService.prototype, 'getCurrentOrRefreshedToken').mockImplementation(() =>
17+
Promise.resolve(token ? { token, userId: '42' } : undefined),
18+
)
19+
}
20+
21+
export function setupGetTasks() {
22+
jest.spyOn(TodoistApi.prototype, 'getTasks').mockImplementation(() =>
23+
Promise.resolve([buildTask()]),
24+
)
25+
}

0 commit comments

Comments
 (0)