Skip to content

Commit

Permalink
feat(api): add subscription server and client services and interfaces (
Browse files Browse the repository at this point in the history
…#1470)

* feat(api): add subscription server and client services and interfaces

* fix(api): linter issues

* feat(models): add subscription invitations

* feat(api): add subscriptions invitation operations on server side

* fix(api): linter issues
  • Loading branch information
Karol Sójko committed Aug 31, 2022
1 parent 370ce39 commit 089d3a2
Show file tree
Hide file tree
Showing 37 changed files with 533 additions and 0 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
"prebuild": "yarn clean",
"build": "tsc -p tsconfig.json",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"test": "jest spec --coverage"
},
"devDependencies": {
"@types/jest": "^28.1.5",
"@types/lodash": "^4.14.182",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"eslint": "^8.23.0",
"eslint-plugin-prettier": "*",
"jest": "^28.1.2",
"ts-jest": "^28.0.5"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SubscriptionInviteResponse } from '../../Response/Subscription/SubscriptionInviteResponse'
import { SubscriptionServerInterface } from '../../Server/Subscription/SubscriptionServerInterface'

import { SubscriptionApiService } from './SubscriptionApiService'

describe('SubscriptionApiService', () => {
let subscriptionServer: SubscriptionServerInterface

const createService = () => new SubscriptionApiService(subscriptionServer)

beforeEach(() => {
subscriptionServer = {} as jest.Mocked<SubscriptionServerInterface>
subscriptionServer.invite = jest.fn().mockReturnValue({
data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' },
} as jest.Mocked<SubscriptionInviteResponse>)
})

it('should invite a user', async () => {
const response = await createService().invite('test@test.te')

expect(response).toEqual({
data: {
success: true,
sharedSubscriptionInvitationUuid: '1-2-3',
},
})
expect(subscriptionServer.invite).toHaveBeenCalledWith({
api: '20200115',
identifier: 'test@test.te',
})
})

it('should not invite a user if it is already inviting', async () => {
const service = createService()
Object.defineProperty(service, 'inviting', {
get: () => true,
})

let error = null
try {
await service.invite('test@test.te')
} catch (caughtError) {
error = caughtError
}

expect(error).not.toBeNull()
})

it('should not invite a user if the server fails', async () => {
subscriptionServer.invite = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})

let error = null
try {
await createService().invite('test@test.te')
} catch (caughtError) {
error = caughtError
}

expect(error).not.toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ErrorMessage } from '../../Error/ErrorMessage'
import { ApiCallError } from '../../Error/ApiCallError'
import { ApiVersion } from '../../Api/ApiVersion'
import { ApiEndpointParam } from '../../Request/ApiEndpointParam'
import { SubscriptionApiServiceInterface } from './SubscriptionApiServiceInterface'
import { SubscriptionServerInterface } from '../../Server/Subscription/SubscriptionServerInterface'
import { SubscriptionInviteResponse } from '../../Response/Subscription/SubscriptionInviteResponse'

export class SubscriptionApiService implements SubscriptionApiServiceInterface {
private inviting: boolean

constructor(private subscriptionServer: SubscriptionServerInterface) {
this.inviting = false
}

async invite(inviteeEmail: string): Promise<SubscriptionInviteResponse> {
if (this.inviting) {
throw new ApiCallError(ErrorMessage.InvitingInProgress)
}
this.inviting = true

try {
const response = await this.subscriptionServer.invite({
[ApiEndpointParam.ApiVersion]: ApiVersion.v0,
identifier: inviteeEmail,
})

this.inviting = false

return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SubscriptionInviteResponse } from '../../Response/Subscription/SubscriptionInviteResponse'

export interface SubscriptionApiServiceInterface {
invite(inviteeEmail: string): Promise<SubscriptionInviteResponse>
}
2 changes: 2 additions & 0 deletions packages/api/src/Domain/Client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './Subscription/SubscriptionApiService'
export * from './Subscription/SubscriptionApiServiceInterface'
export * from './User/UserApiService'
export * from './User/UserApiServiceInterface'
2 changes: 2 additions & 0 deletions packages/api/src/Domain/Error/ErrorMessage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export enum ErrorMessage {
InvitingInProgress = 'An existing invitation request is already in progress.',
RegistrationInProgress = 'An existing registration request is already in progress.',
GenericRegistrationFail = 'A server error occurred while trying to register. Please try again.',
RateLimited = 'Too many successive server requests. Please wait a few minutes and try again.',
InsufficientPasswordMessage = 'Your password must be at least %LENGTH% characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.',
PasscodeRequired = 'Your passcode is required in order to register for an account.',
GenericFail = 'A server error occurred. Please try again.',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Uuid } from '@standardnotes/common'

import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'

export type SubscriptionInviteAcceptRequestParams = {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
inviteUuid: Uuid
[additionalParam: string]: unknown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Uuid } from '@standardnotes/common'

import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'

export type SubscriptionInviteCancelRequestParams = {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
inviteUuid: Uuid
[additionalParam: string]: unknown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Uuid } from '@standardnotes/common'

import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'

export type SubscriptionInviteDeclineRequestParams = {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
inviteUuid: Uuid
[additionalParam: string]: unknown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'

export type SubscriptionInviteListRequestParams = {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
[additionalParam: string]: unknown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'

export type SubscriptionInviteRequestParams = {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
identifier: string
[additionalParam: string]: unknown
}
5 changes: 5 additions & 0 deletions packages/api/src/Domain/Request/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export * from './ApiEndpointParam'
export * from './Subscription/SubscriptionInviteAcceptRequestParams'
export * from './Subscription/SubscriptionInviteCancelRequestParams'
export * from './Subscription/SubscriptionInviteDeclineRequestParams'
export * from './Subscription/SubscriptionInviteListRequestParams'
export * from './Subscription/SubscriptionInviteRequestParams'
export * from './User/UserRegistrationRequestParams'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SubscriptionInviteAcceptResponseBody } from './SubscriptionInviteAcceptResponseBody'

export interface SubscriptionInviteAcceptResponse extends HttpResponse {
data: SubscriptionInviteAcceptResponseBody | HttpErrorResponseBody
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SubscriptionInviteAcceptResponseBody = {
success: boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SubscriptionInviteCancelResponseBody } from './SubscriptionInviteCancelResponseBody'

export interface SubscriptionInviteCancelResponse extends HttpResponse {
data: SubscriptionInviteCancelResponseBody | HttpErrorResponseBody
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SubscriptionInviteCancelResponseBody = {
success: boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SubscriptionInviteDeclineResponseBody } from './SubscriptionInviteDeclineResponseBody'

export interface SubscriptionInviteDeclineResponse extends HttpResponse {
data: SubscriptionInviteDeclineResponseBody | HttpErrorResponseBody
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SubscriptionInviteDeclineResponseBody = {
success: boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SubscriptionInviteListResponseBody } from './SubscriptionInviteListResponseBody'

export interface SubscriptionInviteListResponse extends HttpResponse {
data: SubscriptionInviteListResponseBody | HttpErrorResponseBody
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Invitation } from '@standardnotes/models'

export type SubscriptionInviteListResponseBody = {
invitations: Array<Invitation>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { SubscriptionInviteResponseBody } from './SubscriptionInviteResponseBody'

export interface SubscriptionInviteResponse extends HttpResponse {
data: SubscriptionInviteResponseBody | HttpErrorResponseBody
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Uuid } from '@standardnotes/common'

export type SubscriptionInviteResponseBody =
| {
success: true
sharedSubscriptionInvitationUuid: Uuid
}
| {
success: false
}
10 changes: 10 additions & 0 deletions packages/api/src/Domain/Response/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
export * from './Subscription/SubscriptionInviteAcceptResponse'
export * from './Subscription/SubscriptionInviteAcceptResponseBody'
export * from './Subscription/SubscriptionInviteCancelResponse'
export * from './Subscription/SubscriptionInviteCancelResponseBody'
export * from './Subscription/SubscriptionInviteDeclineResponse'
export * from './Subscription/SubscriptionInviteDeclineResponseBody'
export * from './Subscription/SubscriptionInviteListResponse'
export * from './Subscription/SubscriptionInviteListResponseBody'
export * from './Subscription/SubscriptionInviteResponse'
export * from './Subscription/SubscriptionInviteResponseBody'
export * from './User/UserRegistrationResponse'
export * from './User/UserRegistrationResponseBody'
15 changes: 15 additions & 0 deletions packages/api/src/Domain/Server/Subscription/Paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Uuid } from '@standardnotes/common'

const SharingPaths = {
invite: '/v1/subscription-invites',
acceptInvite: (inviteUuid: Uuid) => `/v1/subscription-invites/${inviteUuid}/accept`,
declineInvite: (inviteUuid: Uuid) => `/v1/subscription-invites/${inviteUuid}/decline`,
cancelInvite: (inviteUuid: Uuid) => `/v1/subscription-invites/${inviteUuid}`,
listInvites: '/v1/subscription-invites',
}

export const Paths = {
v1: {
...SharingPaths,
},
}
105 changes: 105 additions & 0 deletions packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Invitation } from '@standardnotes/models'

import { ApiVersion } from '../../Api'
import { HttpServiceInterface } from '../../Http'
import { SubscriptionInviteResponse } from '../../Response'
import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse'
import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse'
import { SubscriptionInviteDeclineResponse } from '../../Response/Subscription/SubscriptionInviteDeclineResponse'
import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse'
import { SubscriptionServer } from './SubscriptionServer'

describe('SubscriptionServer', () => {
let httpService: HttpServiceInterface

const createServer = () => new SubscriptionServer(httpService)

beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
})

it('should invite a user to a shared subscription', async () => {
httpService.post = jest.fn().mockReturnValue({
data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' },
} as jest.Mocked<SubscriptionInviteResponse>)

const response = await createServer().invite({
api: ApiVersion.v0,
identifier: 'test@test.te',
})

expect(response).toEqual({
data: {
success: true,
sharedSubscriptionInvitationUuid: '1-2-3',
},
})
})

it('should accept an invite to a shared subscription', async () => {
httpService.get = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteAcceptResponse>)

const response = await createServer().acceptInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})

expect(response).toEqual({
data: {
success: true,
},
})
})

it('should decline an invite to a shared subscription', async () => {
httpService.get = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteDeclineResponse>)

const response = await createServer().declineInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})

expect(response).toEqual({
data: {
success: true,
},
})
})

it('should cancel an invite to a shared subscription', async () => {
httpService.delete = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteCancelResponse>)

const response = await createServer().cancelInvite({
api: ApiVersion.v0,
inviteUuid: '1-2-3',
})

expect(response).toEqual({
data: {
success: true,
},
})
})

it('should list invitations to a shared subscription', async () => {
httpService.get = jest.fn().mockReturnValue({
data: { invitations: [{} as jest.Mocked<Invitation>] },
} as jest.Mocked<SubscriptionInviteListResponse>)

const response = await createServer().listInvites({
api: ApiVersion.v0,
})

expect(response).toEqual({
data: {
invitations: [{} as jest.Mocked<Invitation>],
},
})
})
})

0 comments on commit 089d3a2

Please sign in to comment.