diff --git a/src/common/data/table-data/table-data-form-data-builder/table-data-form-data-builder.ts b/src/common/data/table-data/table-data-form-data-builder/table-data-form-data-builder.ts index 38712a1..4af98ee 100644 --- a/src/common/data/table-data/table-data-form-data-builder/table-data-form-data-builder.ts +++ b/src/common/data/table-data/table-data-form-data-builder/table-data-form-data-builder.ts @@ -77,20 +77,6 @@ export class TableDataFormDataBuilder { return this; } - withApplications(applications: Array | undefined): TableDataFormDataBuilder { - if (applications && applications.length) { - this._formParts.appNames = applications.join(','); - } - return this; - } - - withVersions(versions: Array | undefined): TableDataFormDataBuilder { - if (versions && versions.length) { - this._formParts.versions = versions.join(','); - } - return this; - } - entries(): Record { return this._formParts; } diff --git a/src/index.ts b/src/index.ts index fbed4fd..862c7cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export * from './events'; export * from './post'; export * from './summary'; export * from './versions'; -export * from './symbols'; \ No newline at end of file +export * from './symbols'; +export * from './users'; \ No newline at end of file diff --git a/src/summary/summary-api-client/summary-api-client.spec.ts b/src/summary/summary-api-client/summary-api-client.spec.ts index 2768951..9408577 100644 --- a/src/summary/summary-api-client/summary-api-client.spec.ts +++ b/src/summary/summary-api-client/summary-api-client.spec.ts @@ -1,7 +1,7 @@ import { createFakeBugSplatApiClient } from '@spec/fakes/common/bugsplat-api-client'; import { createFakeFormData } from '@spec/fakes/common/form-data'; import { createFakeResponseBody } from '@spec/fakes/common/response'; -import * as SummaryTableDataClientModule from '../summary-table-data/summary-table-data-client'; +import * as TableDataClientModule from '../../common/data/table-data/table-data-client/table-data-client'; import { SummaryApiClient } from './summary-api-client'; describe('SummaryApiClient', () => { @@ -31,24 +31,30 @@ describe('SummaryApiClient', () => { pageData = { coffee: 'black rifle' }; rows = [{ stackKeyId, subKeyDepth, userSum }]; tableDataClientResponse = createFakeResponseBody(200, { pageData, rows }); - tableDataClient = jasmine.createSpyObj('SummaryTableDataClient', ['postGetData']); + tableDataClient = jasmine.createSpyObj('TableDataClient', ['postGetData']); tableDataClient.postGetData.and.resolveTo(tableDataClientResponse); - spyOn(SummaryTableDataClientModule, 'SummaryTableDataClient').and.returnValue(tableDataClient); + spyOn(TableDataClientModule, 'TableDataClient').and.returnValue(tableDataClient); sut = new SummaryApiClient(apiClient); }); describe('getSummary', () => { + let applications; + let versions; let result; let request; beforeEach(async () => { - request = { database }; + applications = ['☕️', '🍵']; + versions = ['1.0.0', '2.0.0']; + request = { database, applications, versions }; result = await sut.getSummary(request); }); - it('should call postGetData with request', () => { - expect(tableDataClient.postGetData).toHaveBeenCalledWith(request); + it('should call postGetData with request and initial formParts', () => { + const expectedAppNames = applications.join(','); + const expectedVersions = versions.join(','); + expect(tableDataClient.postGetData).toHaveBeenCalledWith(request, { appNames: expectedAppNames, versions: expectedVersions }); }); it('should return value with stackKeyId, subKeyDepth, and userSum values mapped to numbers', () => { diff --git a/src/summary/summary-api-client/summary-api-client.ts b/src/summary/summary-api-client/summary-api-client.ts index af55ef6..6473c28 100644 --- a/src/summary/summary-api-client/summary-api-client.ts +++ b/src/summary/summary-api-client/summary-api-client.ts @@ -1,21 +1,27 @@ -import { ApiClient, TableDataResponse } from '@common'; +import { ApiClient, TableDataClient, TableDataResponse } from '@common'; import { SummaryApiResponseRow, SummaryApiRow } from '../summary-api-row/summary-api-row'; -import { SummaryTableDataClient } from '../summary-table-data/summary-table-data-client'; import { SummaryTableDataRequest } from '../summary-table-data/summary-table-data-request'; export class SummaryApiClient { - private _tableDataClient: SummaryTableDataClient; + private _tableDataClient: TableDataClient; constructor(private _client: ApiClient) { - this._tableDataClient = new SummaryTableDataClient(this._client, '/summary?data'); + this._tableDataClient = new TableDataClient(this._client, '/summary?data'); } async getSummary(request: SummaryTableDataRequest): Promise> { - const response = await this._tableDataClient.postGetData(request); + const formParts = {}; + if (request.applications && request.applications.length) { + formParts['appNames'] = request.applications.join(','); + } + if (request.versions && request.versions.length) { + formParts['versions'] = request.versions.join(','); + } + const response = await this._tableDataClient.postGetData(request, formParts); const json = await response.json(); const pageData = json.pageData; - const rows = json.rows.map((row: SummaryApiResponseRow) => new SummaryApiRow( + const rows = json.rows.map(row => new SummaryApiRow( row.stackKey, Number(row.stackKeyId), row.firstReport, @@ -28,7 +34,7 @@ export class SummaryApiClient { row.comments, Number(row.subKeyDepth), Number(row.userSum) - ) + ) ); return { diff --git a/src/summary/summary-table-data/summary-table-data-client.ts b/src/summary/summary-table-data/summary-table-data-client.ts deleted file mode 100644 index 1af89fb..0000000 --- a/src/summary/summary-table-data/summary-table-data-client.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ApiClient, BugSplatResponse, TableDataFormDataBuilder, TableDataResponse } from '@common'; -import { RawResponse } from 'src/common/data/table-data/table-data-client/table-data-client'; -import { SummaryApiResponseRow } from '../summary-api-row/summary-api-row'; -import { SummaryTableDataRequest } from './summary-table-data-request'; - -export class SummaryTableDataClient { - - constructor(private _apiClient: ApiClient, private _url: string) { } - - // We use POST to get data in most cases because it supports longer queries - async postGetData(request: SummaryTableDataRequest): Promise>> { - const factory = () => this._apiClient.createFormData(); - const formData = new TableDataFormDataBuilder(factory) - .withDatabase(request.database) - .withApplications(request.applications) - .withVersions(request.versions) - .withFilterGroups(request.filterGroups) - .withColumnGroups(request.columnGroups) - .withPage(request.page) - .withPageSize(request.pageSize) - .withSortColumn(request.sortColumn) - .withSortOrder(request.sortOrder) - .build(); - const requestInit = { - method: 'POST', - body: formData, - cache: 'no-cache', - credentials: 'include', - redirect: 'follow', - duplex: 'half' - } as RequestInit; - return this.makeRequest(this._url, requestInit); - } - - private async makeRequest(url: string, init: RequestInit): Promise>> { - const response = await this._apiClient.fetch>>(url, init); - const responseData = await response.json(); - const rows = responseData ? responseData[0]?.Rows : []; - const pageData = responseData ? responseData[0]?.PageData : {}; - - const body = response.body; - const status = response.status; - const payload = { rows, pageData }; - const json = async () => payload; - const text = async () => JSON.stringify(payload); - return { - status, - body, - json, - text - }; - } -} - - - diff --git a/src/users/index.ts b/src/users/index.ts new file mode 100644 index 0000000..61b4aa3 --- /dev/null +++ b/src/users/index.ts @@ -0,0 +1,3 @@ +export { UsersApiClient, UsersApiResponse, AddUserResponse } from './users-api-client/users-api-client'; +export { UsersTableDataRequest } from './users-api-client/users-table-data-request'; +export { UsersApiRow } from './users-api-client/users-api-row'; \ No newline at end of file diff --git a/src/users/users-api-client/users-api-client.e2e.ts b/src/users/users-api-client/users-api-client.e2e.ts new file mode 100644 index 0000000..8be3745 --- /dev/null +++ b/src/users/users-api-client/users-api-client.e2e.ts @@ -0,0 +1,102 @@ +import { BugSplatApiClient } from '@common'; +import { config } from '@spec/config'; +import { UserApiResponseStatus, UsersApiClient } from './users-api-client'; + +describe('UsersApiClient', () => { + let companyId: number; + let testEmail: string; + let usersClient: UsersApiClient; + + beforeEach(async () => { + const { host, email, password } = config; + const bugsplat = await BugSplatApiClient.createAuthenticatedClientForNode(email, password, host); + companyId = await getCompanyId(bugsplat, config.database); + usersClient = new UsersApiClient(bugsplat); + testEmail = 'bobby+unittests@bugsplat.com'; + }); + + describe('getUsers', () => { + it('should return 200 and a specific user', async () => { + const database = config.database; + + const { rows } = await usersClient.getUsers({ + database, + email: config.email + }); + + const userRow = rows[0]; + expect(rows).toBeTruthy(); + expect(userRow).toBeTruthy(); + }); + + it('should return 200 and array of users', async () => { + const database = config.database; + + const { rows } = await usersClient.getUsers({ + database, + }); + + const userRow = rows.find(row => row.username === config.email); + expect(rows).toBeTruthy(); + expect(userRow).toBeTruthy(); + }); + }); + + describe('addUserToDatabase', () => { + it('should return 200 and message', async () => { + const response = await usersClient.addUserToDatabase(config.database, testEmail); + const body = await response.json(); + expect(response.status).toEqual(200); + expect(body.status).toEqual(UserApiResponseStatus.success); + }); + }); + + describe('removeUserFromDatabase', () => { + it('should return 200 and message', async () => { + const { uId } = await await usersClient.addUserToDatabase(config.database, testEmail).then(response => response.json()); + const response = await usersClient.removeUserFromDatabase(config.database, uId); + const body = await response.json(); + expect(response.status).toEqual(200); + expect(body.status).toEqual(UserApiResponseStatus.success); + }); + }); + + describe('updateUserForDatabase', () => { + it('should return 200 and message', async () => { + const { uId: uIdAdded } = await await usersClient.addUserToDatabase(config.database, testEmail).then(response => response.json()); + const response = await usersClient.updateUserForDatabase(config.database, testEmail, false); + const body = await response.json(); + expect(response.status).toEqual(200); + expect(body.status).toEqual(UserApiResponseStatus.success); + expect(uIdAdded).toEqual(body.uId); + }); + }); + + describe('addUserToCompany', () => { + it('should return 200 and message', async () => { + const response = await usersClient.addUserToCompany(companyId, testEmail); + const body = await response.json(); + expect(response.status).toEqual(200); + expect(body.status).not.toEqual(UserApiResponseStatus.fail); + }); + }); + + describe('removeUserFromCompany', () => { + it('should return 200 and message', async () => { + const { uId: uIdAdded } = await await usersClient.addUserToCompany(companyId, testEmail).then(response => response.json()); + const response = await usersClient.removeUserFromCompany(companyId, uIdAdded); + const body = await response.json(); + expect(response.status).toEqual(200); + expect(body.status).not.toEqual(UserApiResponseStatus.fail); + }); + }); +}); + +async function getCompanyId(apiClient: BugSplatApiClient, database: string): Promise { + const rows = await apiClient.fetch>('/api/databases.php').then(response => response.json()); + const row = rows.find(row => row.dbName === database); + if (!row?.companyId) { + throw new Error(`Could not find database ${database}`); + } + return Number(row.companyId); +} \ No newline at end of file diff --git a/src/users/users-api-client/users-api-client.spec.ts b/src/users/users-api-client/users-api-client.spec.ts new file mode 100644 index 0000000..e23ac2b --- /dev/null +++ b/src/users/users-api-client/users-api-client.spec.ts @@ -0,0 +1,182 @@ +import { createFakeBugSplatApiClient } from '@spec/fakes/common/bugsplat-api-client'; +import { createFakeFormData } from '@spec/fakes/common/form-data'; +import { createFakeResponseBody } from '@spec/fakes/common/response'; +import { UsersApiClient } from './users-api-client'; +import { UsersApiRow } from './users-api-row'; + +describe('UsersApiClient', () => { + let sut: UsersApiClient; + + let apiClient; + let apiClientResponse; + let companyId; + let database; + let fakeFormData; + let rows; + + beforeEach(() => { + companyId = 123; + database = 'zzz'; + rows = [new UsersApiRow(1, 'x', 1, 'y', 'z', 1, 1, 'a', 'b')]; + fakeFormData = createFakeFormData(); + apiClientResponse = createFakeResponseBody(200, rows); + apiClient = createFakeBugSplatApiClient(fakeFormData, apiClientResponse); + + sut = new UsersApiClient(apiClient); + }); + + describe('getUsers', () => { + let result; + let request; + + beforeEach(async () => { + request = { database }; + result = await sut.getUsers(request); + }); + + it('should throw if both database and companyId are specified', async () => { + request = { database, companyId: 1 }; + await expectAsync(sut.getUsers(request)).toBeRejectedWithError('Cannot specify both database and companyId'); + }); + + it('should throw if neither database nor companyId are specified', async () => { + request = {}; + await expectAsync(sut.getUsers(request)).toBeRejectedWithError('Must specify either database or companyId'); + }); + + it('should call fetch with url containing database param', () => { + expect(apiClient.fetch).toHaveBeenCalledWith(`/api/user/users.php?database=${database}`); + }); + + it('should call fetch with url containing companyId param', async () => { + request = { companyId: 1 }; + await sut.getUsers(request); + expect(apiClient.fetch).toHaveBeenCalledWith(`/api/user/users.php?companyId=${request.companyId}`); + }); + + it('should return rows from response', () => { + expect(result.rows).toEqual(rows); + }); + }); + + describe('addUserToDatabase', () => { + let result; + let email; + + beforeEach(async () => { + email = '☕️'; + result = await sut.addUserToDatabase(database, email); + }); + + it('should call createFormData', () => { + expect(apiClient.createFormData).toHaveBeenCalled(); + }); + + it('should call append with database and email', () => { + expect(fakeFormData.append).toHaveBeenCalledWith('database', database); + expect(fakeFormData.append).toHaveBeenCalledWith('username', email); + }); + + it('should call fetch with url and request containing formData', () => { + expect(apiClient.fetch).toHaveBeenCalledWith('/api/user/users.php', jasmine.objectContaining({ method: 'POST', body: fakeFormData })); + }); + + it('should return response', () => { + expect(result).toEqual(apiClientResponse); + }); + }); + + describe('removeUserFromDatabase', () => { + let result; + let uId; + + beforeEach(async () => { + uId = 1; + result = await sut.removeUserFromDatabase(database, uId); + }); + + it('should call fetch with url', () => { + expect(apiClient.fetch).toHaveBeenCalledWith(`/api/user/users.php?database=${database}&uId=${uId}`, jasmine.objectContaining({ method: 'DELETE' })); + }); + + it('should return response', () => { + expect(result).toEqual(apiClientResponse); + }); + }); + + describe('updateUserForDatabase', () => { + let result; + let email; + let isRestricted; + + beforeEach(async () => { + email = 'fred@bugsplat.com'; + isRestricted = true; + result = await sut.updateUserForDatabase(database, email, isRestricted); + }); + + it('should call createFormData', () => { + expect(apiClient.createFormData).toHaveBeenCalled(); + }); + + it('should call append with database and email', () => { + expect(fakeFormData.append).toHaveBeenCalledWith('database', database); + expect(fakeFormData.append).toHaveBeenCalledWith('username', email); + expect(fakeFormData.append).toHaveBeenCalledWith('rights', isRestricted ? '0' : '1'); + }); + + it('should call fetch with url and request containing formData', () => { + expect(apiClient.fetch).toHaveBeenCalledWith('/api/user/users.php', jasmine.objectContaining({ method: 'POST', body: fakeFormData })); + }); + + it('should return response', () => { + expect(result).toEqual(apiClientResponse); + }); + }); + + describe('addUserToCompany', () => { + let result; + let email; + + beforeEach(async () => { + email = '☕️'; + result = await sut.addUserToCompany(companyId, email); + }); + + + it('should call createFormData', () => { + expect(apiClient.createFormData).toHaveBeenCalled(); + }); + + it('should call append with companyId and email', () => { + expect(fakeFormData.append).toHaveBeenCalledWith('companyId', `${companyId}`); + expect(fakeFormData.append).toHaveBeenCalledWith('username', email); + }); + + it('should call fetch with url and request containing formData', () => { + expect(apiClient.fetch).toHaveBeenCalledWith('/api/user/users.php', jasmine.objectContaining({ method: 'POST', body: fakeFormData })); + }); + + it('should return response', () => { + expect(result).toEqual(apiClientResponse); + }); + }); + + describe('removeUserFromCompany', () => { + let result; + let uId; + + beforeEach(async () => { + uId = 1; + result = await sut.removeUserFromCompany(companyId, uId); + }); + + it('should call fetch with url', () => { + expect(apiClient.fetch).toHaveBeenCalledWith(`/api/user/users.php?companyId=${companyId}&uId=${uId}`, jasmine.objectContaining({ method: 'DELETE' })); + }); + + it('should return response', () => { + expect(result).toEqual(apiClientResponse); + }); + }); +}); \ No newline at end of file diff --git a/src/users/users-api-client/users-api-client.ts b/src/users/users-api-client/users-api-client.ts new file mode 100644 index 0000000..6281eb1 --- /dev/null +++ b/src/users/users-api-client/users-api-client.ts @@ -0,0 +1,151 @@ +import { ApiClient, BugSplatResponse, TableDataResponse } from '@common'; +import { UsersTableDataRequest } from './users-table-data-request'; +import { UsersApiRow } from './users-api-row'; + +const USERS_BASE_URL = '/api/user/users.php'; +export class UsersApiClient { + + constructor(private _client: ApiClient) { } + + async getUsers(request: UsersTableDataRequest): Promise> { + const { database, companyId, email } = request; + if (database && companyId) { + throw new Error('Cannot specify both database and companyId'); + } + if (!database && !companyId) { + throw new Error('Must specify either database or companyId'); + } + const params = new URLSearchParams(); + if (database) { + params.append('database', database); + } + if (companyId) { + params.append('companyId', companyId.toString()); + } + if (email) { + params.append('username', email); + } + const url = `${USERS_BASE_URL}?${params.toString()}`; + const response = await this._client.fetch>(url); + const rows = await response.json(); + + return { + rows + }; + } + + async addUserToDatabase(database: string, email: string): Promise> { + const formData = this._client.createFormData(); + formData.append('database', database); + formData.append('username', email); + const request = { + method: 'POST', + cache: 'no-cache', + credentials: 'include', + redirect: 'follow', + body: formData + } as RequestInit; + + const response = await this._client.fetch(USERS_BASE_URL, request); + if (response.status !== 200) { + throw new Error(`Error adding user ${email} to database ${database} status ${response.status}`); + } + + return response; + } + + async removeUserFromDatabase(database: string, uId: number): Promise> { + const url = `${USERS_BASE_URL}?database=${database}&uId=${uId}`; + const request = { + method: 'DELETE', + cache: 'no-cache', + credentials: 'include', + redirect: 'follow' + } as RequestInit; + + const response = await this._client.fetch(url, request); + if (response.status !== 200) { + throw new Error(`Error remove user ${uId} for ${database} status ${response.status}`); + } + + return response; + } + + async updateUserForDatabase(database: string, email: string, isRestricted: boolean): Promise> { + const rights = isRestricted ? '0' : '1'; + const formData = this._client.createFormData(); + formData.append('database', database); + formData.append('username', email); + formData.append('rights', rights); + + const request = { + method: 'POST', + cache: 'no-cache', + credentials: 'include', + redirect: 'follow', + body: formData + } as RequestInit; + + const response = await this._client.fetch(USERS_BASE_URL, request); + if (response.status !== 200) { + throw new Error(`Error updating user ${email} in database ${database} status ${response.status}`); + } + + return response; + } + + async addUserToCompany(companyId: number, email: string): Promise> { + const formData = this._client.createFormData(); + formData.append('companyId', companyId.toString()); + formData.append('username', email); + const request = { + method: 'POST', + cache: 'no-cache', + credentials: 'include', + redirect: 'follow', + body: formData + } as RequestInit; + + const response = await this._client.fetch(USERS_BASE_URL, request); + if (response.status !== 200) { + throw new Error(`Error adding user ${email} to company ${companyId} status ${response.status}`); + } + + return response; + } + + async removeUserFromCompany(companyId: number, uId: number): Promise> { + const url = `${USERS_BASE_URL}?companyId=${companyId}&uId=${uId}`; + const request = { + method: 'DELETE', + cache: 'no-cache', + credentials: 'include', + redirect: 'follow' + } as RequestInit; + + const response = await this._client.fetch(url, request); + if (response.status !== 200) { + throw new Error(`Error removing user ${uId} for company ${companyId} status ${response.status}`); + } + + return response; + } +} + + +export enum UserApiResponseStatus { + success = 'success', + warning = 'warning', + fail = 'fail', +} + +export interface UsersApiResponse { + status: UserApiResponseStatus; + message?: string; +} + +export interface AddUserResponse extends UsersApiResponse { + database?: string; + companyId?: number; + uId: number; +} \ No newline at end of file diff --git a/src/users/users-api-client/users-api-row.ts b/src/users/users-api-client/users-api-row.ts new file mode 100644 index 0000000..ef013ba --- /dev/null +++ b/src/users/users-api-client/users-api-row.ts @@ -0,0 +1,13 @@ +export class UsersApiRow { + constructor( + public readonly dbId: number, + public readonly dbName: string, + public readonly uId: number, + public readonly username: string, + public readonly lastLogin: string, + public readonly unrestricted: number, + public readonly requireMFA: number, + public readonly firstName: string = '', + public readonly lastName: string = '' + ) {} +} diff --git a/src/users/users-api-client/users-table-data-request.ts b/src/users/users-api-client/users-table-data-request.ts new file mode 100644 index 0000000..e1b7d46 --- /dev/null +++ b/src/users/users-api-client/users-table-data-request.ts @@ -0,0 +1,6 @@ + +export interface UsersTableDataRequest { + database?: string; + companyId?: number; + email?: string; +}