Browse Source

Add tests

Ensure requests are properly handled.
master
TheoryOfNekomata 6 months ago
parent
commit
6e7230adc4
3 changed files with 674 additions and 5 deletions
  1. +11
    -3
      src/data-sources/file-jsonl.ts
  2. +2
    -2
      src/handlers.ts
  3. +661
    -0
      test/e2e/default.test.ts

+ 11
- 3
src/data-sources/file-jsonl.ts View File

@@ -38,12 +38,20 @@ export class DataSource<T extends Record<string, string>> implements DataSourceI
}

async create(data: Partial<T>) {
const newData = [
const newData = {
...data
} as Record<string, unknown>;

if (this.resource.idAttr in newData) {
newData[this.resource.idAttr] = this.resource.idDeserializer(newData[this.resource.idAttr] as string);
}

const newCollection = [
...this.data,
data
newData
];

await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n'));
await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n'));

return data as T;
}


+ 2
- 2
src/handlers.ts View File

@@ -148,7 +148,7 @@ export const handleGetItem: Middleware = ({
return {
handled: true
};
} catch {
} catch (err) {
res.statusCode = constants.HTTP_STATUS_INTERNAL_SERVER_ERROR;
res.end();
return {
@@ -316,7 +316,7 @@ export const handleCreateItem: Middleware = ({
params[resource.idAttr] = newId;
const newObject = await resource.dataSource.create(params);
const theFormatted = responseBodySerializerPair.serialize(newObject);
res.writeHead(constants.HTTP_STATUS_OK, {
res.writeHead(constants.HTTP_STATUS_CREATED, {
'Content-Type': responseMediaType,
'Location': `${serverParams.baseUrl}/${resource.routeName}/${newId}`
});


+ 661
- 0
test/e2e/default.test.ts View File

@@ -0,0 +1,661 @@
import {
beforeAll,
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
tmpdir
} from 'os';
import {
mkdtemp,
rm,
writeFile,
} from 'fs/promises';
import {
join
} from 'path';
import {
application,
DataSource,
dataSources,
encodings,
Resource,
resource,
serializers,
valibot as v,
} from '../../src';
import {request, Server} from 'http';
import {constants} from 'http2';

const PORT = 3000;
const HOST = 'localhost';
const ACCEPT_ENCODING = 'utf-8';
const ACCEPT = 'application/json';

const autoIncrement = async (dataSource: DataSource) => {
const data = await dataSource.getMultiple() as Record<string, string>[];

const highestId = data.reduce<number>(
(highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId),
-Infinity
);

if (Number.isFinite(highestId)) {
return (highestId + 1);
}

return 1;
};

describe('yasumi', () => {
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;
beforeEach(() => {
Piano = resource(v.object(
{
brand: v.string()
},
v.never()
))
.name('Piano')
.id('id', {
generationStrategy: autoIncrement,
serialize: (id) => id?.toString() ?? '0',
deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
})
});

let server: Server;
beforeEach(() => {
const app = application({
name: 'piano-service',
dataSource: (resource) => new dataSources.jsonlFile.DataSource(resource, baseDir),
})
.contentType(ACCEPT, serializers.applicationJson)
.encoding(ACCEPT_ENCODING, encodings.utf8)
.resource(Piano);

server = app.createServer({
baseUrl: '/api'
});

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

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

server.listen({
port: PORT
});
});
});

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

resolve();
});
}));

describe('serving collections', () => {
it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchCollection();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchCollection();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual([]);
Piano.revokeFetchCollection();
resolve();
});
},
);

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

req.end();
});
});
});

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

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchItem();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchItem();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(data);
Piano.revokeFetchItem();
resolve();
});
},
);

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

req.end();
});
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowFetchItem();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'GET',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeFetchItem();
reject(err);
});

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

Piano.revokeFetchItem();
resolve();
},
);

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

req.end();
});
});
});

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

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

// FIXME ID de/serialization problems
it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowCreate();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos',
method: 'POST',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Content-Type': ACCEPT,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeCreate();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
id: '2'
});
Piano.revokeCreate();
resolve();
});
},
);

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

req.write(JSON.stringify(newData));
req.end();
});
});
});

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

const newData = {
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowPatch();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Content-Type': ACCEPT,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...data,
...newData,
});
Piano.revokePatch();
resolve();
});
},
);

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

req.write(JSON.stringify(newData));
req.end();
});
});

it('throws on item to patch not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowPatch();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'PATCH',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Content-Type': ACCEPT,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokePatch();
reject(err);
});

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

Piano.revokePatch();
resolve();
},
);

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

req.write(JSON.stringify(newData));
req.end();
});
});
});

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

const newData = {
id: 1,
brand: 'K. Kawai'
};

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

// FIXME IDs not properly being de/serialized
it('returns data for replacement', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowEmplace();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'PUT',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Content-Type': ACCEPT,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual(newData);
Piano.revokeEmplace();
resolve();
});
},
);

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

req.write(JSON.stringify(newData));
req.end();
});
});

it('returns data for creation', () => {
return new Promise<void>((resolve, reject) => {
const id = 2;
Piano.allowEmplace();

const req = request(
{
host: HOST,
port: PORT,
path: `/api/pianos/${id}`,
method: 'PUT',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
'Content-Type': ACCEPT,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeEmplace();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res.headers).toHaveProperty('content-type', ACCEPT);

let resBuffer = Buffer.from('');
res.on('data', (c) => {
resBuffer = Buffer.concat([resBuffer, c]);
});

res.on('close', () => {
const resBufferJson = resBuffer.toString(ACCEPT_ENCODING);
const resData = JSON.parse(resBufferJson);
expect(resData).toEqual({
...newData,
id,
});
Piano.revokeEmplace();
resolve();
});
},
);

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

req.write(JSON.stringify({
...newData,
id,
}));
req.end();
});
});
});

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

beforeEach(async () => {
const resourcePath = join(baseDir, 'pianos.jsonl');
await writeFile(resourcePath, JSON.stringify(data));
});

it('returns data', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowDelete();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/1',
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
Piano.revokeDelete();
resolve();
},
);

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

req.end();
});
});

it('throws on item not found', () => {
return new Promise<void>((resolve, reject) => {
Piano.allowDelete();

const req = request(
{
host: HOST,
port: PORT,
path: '/api/pianos/2',
method: 'DELETE',
headers: {
'Accept': ACCEPT,
'Accept-Encoding': ACCEPT_ENCODING,
},
},
(res) => {
res.on('error', (err) => {
Piano.revokeDelete();
reject(err);
});

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

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

req.end();
});
});
});
});

Loading…
Cancel
Save