Browse Source

Add tests

Decouple tests from data source.
master
TheoryOfNekomata 7 months ago
parent
commit
bc27b28e7a
4 changed files with 302 additions and 338 deletions
  1. +1
    -1
      test/e2e/features.test.ts
  2. +2
    -8
      test/e2e/http/default.test.ts
  3. +282
    -310
      test/e2e/http/error-handling.test.ts
  4. +17
    -19
      test/utils.ts

+ 1
- 1
test/e2e/features.test.ts View File

@@ -84,7 +84,7 @@ describe('decorators', () => {
}); });
}); });


afterEach(() => new Promise((resolve, reject) => {
afterEach(() => new Promise<void>((resolve, reject) => {
server.close((err) => { server.close((err) => {
if (err) { if (err) {
reject(err); reject(err);


+ 2
- 8
test/e2e/http/default.test.ts View File

@@ -24,8 +24,6 @@ const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8'; const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT; const CONTENT_TYPE = ACCEPT;


let portCounter = 0;

describe.only('happy path', () => { describe.only('happy path', () => {
let Piano: Resource; let Piano: Resource;
let app: Application; let app: Application;
@@ -67,7 +65,7 @@ describe.only('happy path', () => {


client = createTestClient({ client = createTestClient({
host: HOST, host: HOST,
port: PORT + portCounter,
port: PORT,
}) })
.acceptMediaType(ACCEPT) .acceptMediaType(ACCEPT)
.acceptLanguage(ACCEPT_LANGUAGE) .acceptLanguage(ACCEPT_LANGUAGE)
@@ -90,7 +88,7 @@ describe.only('happy path', () => {
}); });
}); });


afterAll(() => new Promise((resolve, reject) => {
afterAll(() => new Promise<void>((resolve, reject) => {
server.close((err) => { server.close((err) => {
if (err) { if (err) {
reject(err); reject(err);
@@ -100,10 +98,6 @@ describe.only('happy path', () => {
}); });
})); }));


afterAll(() => {
portCounter = 0;
});

describe('serving collections', () => { describe('serving collections', () => {
beforeEach(() => { beforeEach(() => {
vi vi


+ 282
- 310
test/e2e/http/error-handling.test.ts View File

@@ -7,23 +7,12 @@ import {
expect, expect,
it, vi, it, vi,
} from 'vitest'; } from 'vitest';
import {
tmpdir
} from 'os';
import {
mkdtemp,
rm,
writeFile,
} from 'fs/promises';
import {
join
} from 'path';
import {request} from 'http'; import {request} from 'http';
import {constants} from 'http2'; import {constants} from 'http2';
import {Backend} from '../../../src/backend'; import {Backend} from '../../../src/backend';
import { application, resource, validation as v, Resource } from '../../../src/common';
import {application, resource, validation as v, Resource, Application} from '../../../src/common';
import { autoIncrement } from '../../fixtures'; import { autoIncrement } from '../../fixtures';
import { createTestClient, TestClient, DummyDataSource } from '../../utils';
import {createTestClient, TestClient, DummyDataSource, DummyError} from '../../utils';
import {DataSource} from '../../../src/backend/data-source'; import {DataSource} from '../../../src/backend/data-source';


const PORT = 4001; const PORT = 4001;
@@ -36,8 +25,44 @@ const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT; const CONTENT_TYPE = ACCEPT;


describe('error handling', () => { describe('error handling', () => {
let Piano: Resource;
let app: Application;
let dataSource: DataSource;
let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
let client: TestClient; let client: TestClient;
beforeEach(() => {

beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});

app = application({
name: 'piano-service',
})
.resource(Piano);

dataSource = new DummyDataSource();

backend = app.createBackend({
dataSource,
});

server = backend.createHttpServer({
basePath: BASE_PATH
});

client = createTestClient({ client = createTestClient({
host: HOST, host: HOST,
port: PORT, port: PORT,
@@ -47,382 +72,329 @@ describe('error handling', () => {
.acceptCharset(ACCEPT_CHARSET) .acceptCharset(ACCEPT_CHARSET)
.contentType(CONTENT_TYPE) .contentType(CONTENT_TYPE)
.contentCharset(CONTENT_TYPE_CHARSET); .contentCharset(CONTENT_TYPE_CHARSET);
});

describe('on internal errors', () => {
let baseDir: string;
beforeAll(async () => {
try {
baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
} catch {
// noop
}
});
afterAll(async () => {
try {
await rm(baseDir, {
recursive: true,
});
} catch {
// noop
}
});

let Piano: Resource;
beforeAll(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano' as const)
.route('pianos' as const)
.id('id' as const, {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
schema: v.number(),
});
});

let dataSource: DataSource;
let backend: Backend;
let server: ReturnType<Backend['createHttpServer']>;
beforeEach(() => {
const app = application({
name: 'piano-service',
})
.resource(Piano);


dataSource = new DummyDataSource();
return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});


backend = app.createBackend({
dataSource,
server.on('listening', () => {
resolve();
}); });


server = backend.createHttpServer({
basePath: BASE_PATH
server.listen({
port: PORT
}); });
});
});


return new Promise((resolve, reject) => {
server.on('error', (err) => {
reject(err);
});
afterAll(() => new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}


server.on('listening', () => {
resolve();
});
resolve();
});
}));


server.listen({
port: PORT
});
});
describe('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
}); });


afterEach(() => new Promise((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
}
afterEach(() => {
Piano.canFetchCollection(false);
});


resolve();
});
}));

describe.skip('serving collections', () => {
beforeEach(() => {
Piano.canFetchCollection();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
});
it('throws on query', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockImplementationOnce(() => { throw new DummyError() });


afterEach(() => {
Piano.canFetchCollection(false);
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
}); });


it('throws on query', async () => {
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos`,
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
});

it('throws on HEAD method', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getMultiple')
.mockImplementationOnce(() => { throw new DummyError() });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
}); });


it('throws on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos`,
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
});
});


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
});
describe('serving items', () => {
beforeEach(() => {
Piano.canFetchItem();
}); });


describe('serving items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};
afterEach(() => {
Piano.canFetchItem(false);
});


beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});
it('throws on query', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });


beforeEach(() => {
Piano.canFetchItem();
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
});
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
}); });


afterEach(() => {
Piano.canFetchItem(false);
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});


it('throws on query', async () => {
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});
it('throws on HEAD method', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
}); });


it('throws on HEAD method', async () => {
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});

it('throws on item not found', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
}); });


it('throws on item not found', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});


const [res] = await client({
method: 'GET',
path: `${BASE_PATH}/pianos/2`,
});
it('throws on item not found on HEAD method', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
}); });


it('throws on item not found on HEAD method', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});
});


const [res] = await client({
method: 'HEAD',
path: `${BASE_PATH}/pianos/2`,
});
describe('creating items', () => {
const newData = {
brand: 'K. Kawai'
};


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
});
const existingResource = {
...newData,
id: 1,
};

beforeEach(() => {
Piano.canCreate();
}); });


describe('creating items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};
afterEach(() => {
Piano.canCreate(false);
});


const newData = {
brand: 'K. Kawai'
};
it('throws on error assigning ID', async () => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockImplementationOnce(() => { throw new DummyError() });


beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
}); });


beforeEach(() => {
Piano.canCreate();
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source');
});


afterEach(() => {
Piano.canCreate(false);
});
it('throws on error creating resource', async () => {
vi
.spyOn(DummyDataSource.prototype, 'newId')
.mockResolvedValueOnce(existingResource.id as never);


it('throws on error assigning ID', async () => {
const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});
vi
.spyOn(DummyDataSource.prototype, 'create')
.mockImplementationOnce(() => { throw new DummyError() });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source');
const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
}); });


it('throws on error creating resource', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'newId');
getById.mockResolvedValueOnce(data.id as never);
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano');
});
});

describe('patching items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};


const [res] = await client({
method: 'POST',
path: `${BASE_PATH}/pianos`,
body: newData,
});
const newData = {
brand: 'K. Kawai'
};


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano');
});
beforeEach(() => {
Piano.canPatch();
}); });


describe.skip('patching items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};
afterEach(() => {
Piano.canPatch(false);
});


const newData = {
brand: 'K. Kawai'
};
// TODO add more tests


beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});
it('throws on unable to fetch existing item', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });


beforeEach(() => {
Piano.canPatch();
const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newData,
headers: {
'content-type': 'application/merge-patch+json',
},
}); });


afterEach(() => {
Piano.canPatch(false);
});
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});


it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
const req = request(
{
host: HOST,
port: PORT,
path: `${BASE_PATH}/pianos/2`,
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Language': ACCEPT_LANGUAGE,
'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
},
},
(res) => {
res.on('error', (err) => {
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
resolve();
},
);

req.on('error', (err) => {
reject(err);
});

req.write(JSON.stringify(newData));
req.end();
});
it('throws on item to patch not found', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);

const [res] = await client({
method: 'PATCH',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
body: newData,
headers: {
'content-type': 'application/merge-patch+json',
},
}); });
});


describe.skip('emplacing items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', 'Patch Non-Existing Piano');
});
});


const newData = {
id: 1,
brand: 'K. Kawai'
};
describe.skip('emplacing items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};


beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});
beforeEach(() => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);
});


beforeEach(() => {
Piano.canEmplace();
});
const newData = {
id: 1,
brand: 'K. Kawai'
};


afterEach(() => {
Piano.canEmplace(false);
});
beforeEach(() => {
Piano.canEmplace();
}); });


describe('deleting items', () => {
const data = {
id: 1,
brand: 'Yamaha'
};
afterEach(() => {
Piano.canEmplace(false);
});
});


beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});
describe('deleting items', () => {
const existingResource = {
id: 1,
brand: 'Yamaha'
};


beforeEach(() => {
Piano.canDelete();
backend.throwsErrorOnDeletingNotFound();
});
beforeEach(() => {
Piano.canDelete();
backend.throwsErrorOnDeletingNotFound();
});


afterEach(() => {
Piano.canDelete(false);
backend.throwsErrorOnDeletingNotFound(false);
});
afterEach(() => {
Piano.canDelete(false);
backend.throwsErrorOnDeletingNotFound(false);
});


it('throws on unable to check if item exists', async () => {
const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});
it('throws on unable to check if item exists', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockImplementationOnce(() => { throw new DummyError() });


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
}); });


it('throws on item not found', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce(null as never);
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
});


const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});
it('throws on item not found', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(null as never);


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano');
const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
}); });


it('throws on unable to delete item', async () => {
const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
getById.mockResolvedValueOnce({
id: 2
} as never);
expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano');
});


const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/2`,
});
it('throws on unable to delete item', async () => {
vi
.spyOn(DummyDataSource.prototype, 'getById')
.mockResolvedValueOnce(existingResource as never);


expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano');
vi
.spyOn(DummyDataSource.prototype, 'delete')
.mockImplementationOnce(() => { throw new DummyError() });

const [res] = await client({
method: 'DELETE',
path: `${BASE_PATH}/pianos/${existingResource.id}`,
}); });

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano');
}); });
}); });
}); });

+ 17
- 19
test/utils.ts View File

@@ -147,42 +147,40 @@ export class DummyError extends Error {}
export class DummyDataSource implements DataSource { export class DummyDataSource implements DataSource {
private resource?: { dataSource?: unknown }; private resource?: { dataSource?: unknown };


create(): Promise<never> {
throw new DummyError();
async create(): Promise<object> {
return {};
} }


delete(): Promise<never> {
throw new DummyError();
}
async delete(): Promise<void> {}


emplace(): Promise<never> {
throw new DummyError();
async emplace(): Promise<[object, boolean]> {
return [{}, false];
} }


getById(): Promise<never> {
throw new DummyError();
async getById(): Promise<object> {
return {};
} }


newId(): Promise<never> {
throw new DummyError();
async newId(): Promise<string> {
return '';
} }


getMultiple(): Promise<never> {
throw new DummyError();
async getMultiple(): Promise<object[]> {
return [];
} }


getSingle(): Promise<never> {
throw new DummyError();
async getSingle(): Promise<object> {
return {};
} }


getTotalCount(): Promise<never> {
throw new DummyError();
async getTotalCount(): Promise<number> {
return 0;
} }


async initialize(): Promise<void> {} async initialize(): Promise<void> {}


patch(): Promise<never> {
throw new DummyError();
async patch(): Promise<object> {
return {};
} }


prepareResource(rr: unknown) { prepareResource(rr: unknown) {


Loading…
Cancel
Save