diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 74a512f85..5e930cf63 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1728,7 +1728,7 @@ export enum SetIntegrationErrorCode { } export type SetIntegrationInput = { - enabled?: InputMaybe; + enabled: Scalars['Boolean']; id?: InputMaybe; token: Scalars['String']; type: IntegrationType; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index af1193142..2dbe9479c 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1252,7 +1252,7 @@ enum SetIntegrationErrorCode { } input SetIntegrationInput { - enabled: Boolean + enabled: Boolean! id: ID token: String! type: IntegrationType! diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 362ee9745..8a9d0b0f6 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -12,7 +12,6 @@ import { analytics } from '../../utils/analytics' import { env } from '../../env' import { validateToken } from '../../services/integrations' import { deleteTask, enqueueSyncWithIntegration } from '../../utils/createTask' -import { AppDataSource } from '../../server' export const setIntegrationResolver = authorized< SetIntegrationSuccess, @@ -29,13 +28,9 @@ export const setIntegrationResolver = authorized< } } - const integrationToSave: Partial = { + let integrationToSave: Partial = { user, - token: input.token, - type: input.type, - enabled: input.enabled === null ? true : input.enabled, } - if (input.id) { // Update const existingIntegration = await getRepository(Integration).findOne({ @@ -53,7 +48,17 @@ export const setIntegrationResolver = authorized< } } - integrationToSave.id = input.id + if (existingIntegration.enabled === input.enabled) { + return { + integration: existingIntegration, + } + } + integrationToSave = { + ...integrationToSave, + id: existingIntegration.id, + taskName: existingIntegration.taskName, + enabled: input.enabled, + } } else { // Create const existingIntegration = await getRepository(Integration).findOneBy({ @@ -73,34 +78,32 @@ export const setIntegrationResolver = authorized< errorCodes: [SetIntegrationErrorCode.InvalidToken], } } + integrationToSave = { + ...integrationToSave, + token: input.token, + type: input.type, + enabled: true, + } } - const integration = await AppDataSource.transaction(async (t) => { - const integration = await t - .getRepository(Integration) - .save(integrationToSave) - if (integration.enabled) { - // create a task to sync all the pages - const taskName = await enqueueSyncWithIntegration( - user.id, - integration.type - ) - log.info('enqueued task', taskName) - await t - .getRepository(Integration) - .update({ id: integration.id }, { taskName }) - } else if (integration.taskName) { - await deleteTask(integration.taskName) - log.info('task deleted', integration.taskName) - } - return integration - }) + if (!integrationToSave.id || integrationToSave.enabled) { + // create a task to sync all the pages if new integration or enable integration + const taskName = await enqueueSyncWithIntegration(user.id, input.type) + log.info('enqueued task', taskName) + integrationToSave.taskName = taskName + } else if (integrationToSave.taskName) { + // delete the task if disable integration and task exists + await deleteTask(integrationToSave.taskName) + integrationToSave.taskName = null + log.info('task deleted', integrationToSave.taskName) + } + const integration = await getRepository(Integration).save(integrationToSave) analytics.track({ userId: uid, event: 'integration_set', properties: { - id: integration.id, + id: integrationToSave.id, env: env.server.apiEnv, }, }) diff --git a/packages/api/src/routers/svc/integrations.ts b/packages/api/src/routers/svc/integrations.ts index a07a6b451..2036a7eab 100644 --- a/packages/api/src/routers/svc/integrations.ts +++ b/packages/api/src/routers/svc/integrations.ts @@ -129,6 +129,9 @@ export function integrationsServiceRouter() { return } } + await getRepository(Integration).update(integration.id, { + taskName: null, + }) } else { logger.info('unknown action', action) res.status(200).send('Unknown action') diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 956312181..1dd776143 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1847,7 +1847,7 @@ const schema = gql` id: ID type: IntegrationType! token: String! - enabled: Boolean + enabled: Boolean! } # Mutations diff --git a/packages/api/src/services/integrations.ts b/packages/api/src/services/integrations.ts index fc94b629e..6b6cd6365 100644 --- a/packages/api/src/services/integrations.ts +++ b/packages/api/src/services/integrations.ts @@ -34,7 +34,7 @@ interface ReadwiseHighlight { highlight_url?: string } -const READWISE_API_URL = 'https://readwise.io/api/v2' +export const READWISE_API_URL = 'https://readwise.io/api/v2' export const validateToken = async ( token: string, diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts new file mode 100644 index 000000000..c262bc6bb --- /dev/null +++ b/packages/api/test/resolvers/integrations.test.ts @@ -0,0 +1,303 @@ +import 'mocha' +import { User } from '../../src/entity/user' +import { createTestUser, deleteTestUser } from '../db' +import { generateFakeUuid, graphqlRequest, request } from '../util' +import { + IntegrationType, + SetIntegrationErrorCode, +} from '../../src/generated/graphql' +import { expect } from 'chai' +import { getRepository } from '../../src/entity/utils' +import { + Integration, + IntegrationType as DataIntegrationType, +} from '../../src/entity/integration' +import nock from 'nock' +import { READWISE_API_URL } from '../../src/services/integrations' + +describe('Integrations resolvers', () => { + let loginUser: User + let authToken: string + + before(async () => { + // create test user and login + loginUser = await createTestUser('loginUser') + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: loginUser.email }) + + authToken = res.body.authToken + }) + + after(async () => { + await deleteTestUser(loginUser.name) + }) + + describe('setIntegration API', () => { + const validToken = 'valid-token' + const query = ( + id = '', + type: IntegrationType = IntegrationType.Readwise, + token: string = 'test token', + enabled = true + ) => ` + mutation { + setIntegration(input: { + id: "${id}", + type: ${type}, + token: "${token}", + enabled: ${enabled}, + }) { + ... on SetIntegrationSuccess { + integration { + id + enabled + } + } + ... on SetIntegrationError { + errorCodes + } + } + } + ` + let integrationId: string + let token: string + let integrationType: IntegrationType + let enabled: boolean + + // mock Readwise Auth API + before(() => { + nock(READWISE_API_URL, { + reqheaders: { Authorization: `Token ${validToken}` }, + }) + .get('/auth') + .reply(204) + .persist() + }) + + after(() => { + nock.cleanAll() + }) + + context('when id is not in the request', () => { + before(() => { + integrationId = '' + }) + + context('when integration exists', () => { + let existingIntegration: Integration + + before(async () => { + existingIntegration = await getRepository(Integration).save({ + user: loginUser, + type: DataIntegrationType.Readwise, + token: 'fakeToken', + }) + integrationType = existingIntegration.type + }) + + after(async () => { + await getRepository(Integration).delete({ + id: existingIntegration.id, + }) + }) + + it('returns AlreadyExists error code', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType), + authToken + ) + expect(res.body.data.setIntegration.errorCodes).to.eql([ + SetIntegrationErrorCode.AlreadyExists, + ]) + }) + }) + + context('when integration does not exist', () => { + context('when token is invalid', () => { + before(() => { + token = 'invalid token' + }) + + it('returns InvalidToken error code', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token), + authToken + ) + expect(res.body.data.setIntegration.errorCodes).to.eql([ + SetIntegrationErrorCode.InvalidToken, + ]) + }) + }) + + context('when token is valid', () => { + before(() => { + token = validToken + }) + + afterEach(async () => { + await getRepository(Integration).delete({ + user: loginUser, + type: integrationType, + }) + }) + + it('creates new integration', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token), + authToken + ) + expect(res.body.data.setIntegration.integration.enabled).to.be.true + }) + + it('creates new cloud task to sync all existing articles and highlights', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token), + authToken + ) + const integration = await getRepository(Integration).findOneBy({ + id: res.body.data.setIntegration.integration.id, + }) + expect(integration?.taskName).not.to.be.null + }) + }) + }) + }) + + context('when id is in the request', () => { + let existingIntegration: Integration + + context('when integration does not exist', () => { + before(() => { + integrationId = generateFakeUuid() + }) + + it('returns NotFound error code', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType), + authToken + ) + expect(res.body.data.setIntegration.errorCodes).to.eql([ + SetIntegrationErrorCode.NotFound, + ]) + }) + }) + + context('when integration exists', () => { + context('when integration does not belong to the user', () => { + let otherUser: User + + before(async () => { + otherUser = await createTestUser('otherUser') + existingIntegration = await getRepository(Integration).save({ + user: otherUser, + type: DataIntegrationType.Readwise, + token: 'fakeToken', + }) + integrationId = existingIntegration.id + }) + + after(async () => { + await deleteTestUser(otherUser.name) + await getRepository(Integration).delete({ + id: existingIntegration.id, + }) + }) + + it('returns Unauthorized error code', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType), + authToken + ) + expect(res.body.data.setIntegration.errorCodes).to.eql([ + SetIntegrationErrorCode.Unauthorized, + ]) + }) + }) + + context('when integration belongs to the user', () => { + before(async () => { + existingIntegration = await getRepository(Integration).save({ + user: loginUser, + type: DataIntegrationType.Readwise, + token: 'fakeToken', + }) + integrationId = existingIntegration.id + }) + + after(async () => { + await getRepository(Integration).delete({ + id: existingIntegration.id, + }) + }) + + context('when enable is false', () => { + before(() => { + enabled = false + }) + + afterEach(async () => { + await getRepository(Integration).update(existingIntegration.id, { + taskName: 'some task name', + enabled: true, + }) + }) + + it('disables integration', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token, enabled), + authToken + ) + expect(res.body.data.setIntegration.integration.enabled).to.be + .false + }) + + it('deletes cloud task', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token, enabled), + authToken + ) + const integration = await getRepository(Integration).findOneBy({ + id: res.body.data.setIntegration.integration.id, + }) + expect(integration?.taskName).to.be.null + }) + }) + + context('when enable is true', () => { + before(() => { + enabled = true + }) + + afterEach(async () => { + await getRepository(Integration).update(existingIntegration.id, { + taskName: null, + enabled: false, + }) + }) + + it('enables integration', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token, enabled), + authToken + ) + expect(res.body.data.setIntegration.integration.enabled).to.be + .true + }) + + it('creates new cloud task to sync all existing articles and highlights', async () => { + const res = await graphqlRequest( + query(integrationId, integrationType, token, enabled), + authToken + ) + const integration = await getRepository(Integration).findOneBy({ + id: res.body.data.setIntegration.integration.id, + }) + expect(integration?.taskName).not.to.be.null + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/api/test/routers/integrations.test.ts b/packages/api/test/routers/integrations.test.ts new file mode 100644 index 000000000..e69de29bb