HATEOAS-first backend framework.
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

429 lines
9.7 KiB

  1. import {
  2. beforeAll,
  3. afterAll,
  4. afterEach,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it, vi,
  9. } from 'vitest';
  10. import {
  11. tmpdir
  12. } from 'os';
  13. import {
  14. mkdtemp,
  15. rm,
  16. writeFile,
  17. } from 'fs/promises';
  18. import {
  19. join
  20. } from 'path';
  21. import {request} from 'http';
  22. import {constants} from 'http2';
  23. import {Backend} from '../../../src/backend';
  24. import { application, resource, validation as v, Resource } from '../../../src/common';
  25. import { autoIncrement } from '../../fixtures';
  26. import { createTestClient, TestClient, DummyDataSource } from '../../utils';
  27. import {DataSource} from '../../../src/backend/data-source';
  28. const PORT = 4001;
  29. const HOST = '127.0.0.1';
  30. const BASE_PATH = '/api';
  31. const ACCEPT = 'application/json';
  32. const ACCEPT_LANGUAGE = 'en';
  33. const ACCEPT_CHARSET = 'utf-8';
  34. const CONTENT_TYPE_CHARSET = 'utf-8';
  35. const CONTENT_TYPE = ACCEPT;
  36. describe('error handling', () => {
  37. let client: TestClient;
  38. beforeEach(() => {
  39. client = createTestClient({
  40. host: HOST,
  41. port: PORT,
  42. })
  43. .acceptMediaType(ACCEPT)
  44. .acceptLanguage(ACCEPT_LANGUAGE)
  45. .acceptCharset(ACCEPT_CHARSET)
  46. .contentType(CONTENT_TYPE)
  47. .contentCharset(CONTENT_TYPE_CHARSET);
  48. });
  49. describe('on internal errors', () => {
  50. let baseDir: string;
  51. beforeAll(async () => {
  52. try {
  53. baseDir = await mkdtemp(join(tmpdir(), 'yasumi-'));
  54. } catch {
  55. // noop
  56. }
  57. });
  58. afterAll(async () => {
  59. try {
  60. await rm(baseDir, {
  61. recursive: true,
  62. });
  63. } catch {
  64. // noop
  65. }
  66. });
  67. let Piano: Resource;
  68. beforeAll(() => {
  69. Piano = resource(v.object(
  70. {
  71. brand: v.string()
  72. },
  73. v.never()
  74. ))
  75. .name('Piano' as const)
  76. .route('pianos' as const)
  77. .id('id' as const, {
  78. generationStrategy: autoIncrement,
  79. serialize: (id) => id?.toString() ?? '0',
  80. deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0,
  81. schema: v.number(),
  82. });
  83. });
  84. let dataSource: DataSource;
  85. let backend: Backend;
  86. let server: ReturnType<Backend['createHttpServer']>;
  87. beforeEach(() => {
  88. const app = application({
  89. name: 'piano-service',
  90. })
  91. .resource(Piano);
  92. dataSource = new DummyDataSource();
  93. backend = app.createBackend({
  94. dataSource,
  95. });
  96. server = backend.createHttpServer({
  97. basePath: BASE_PATH
  98. });
  99. return new Promise((resolve, reject) => {
  100. server.on('error', (err) => {
  101. reject(err);
  102. });
  103. server.on('listening', () => {
  104. resolve();
  105. });
  106. server.listen({
  107. port: PORT
  108. });
  109. });
  110. });
  111. afterEach(() => new Promise((resolve, reject) => {
  112. server.close((err) => {
  113. if (err) {
  114. reject(err);
  115. }
  116. resolve();
  117. });
  118. }));
  119. describe.skip('serving collections', () => {
  120. beforeEach(() => {
  121. Piano.canFetchCollection();
  122. return new Promise((resolve) => {
  123. setTimeout(() => {
  124. resolve();
  125. });
  126. });
  127. });
  128. afterEach(() => {
  129. Piano.canFetchCollection(false);
  130. });
  131. it('throws on query', async () => {
  132. const [res] = await client({
  133. method: 'GET',
  134. path: `${BASE_PATH}/pianos`,
  135. });
  136. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  137. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
  138. });
  139. it('throws on HEAD method', async () => {
  140. const [res] = await client({
  141. method: 'HEAD',
  142. path: `${BASE_PATH}/pianos`,
  143. });
  144. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  145. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano Collection');
  146. });
  147. });
  148. describe('serving items', () => {
  149. const data = {
  150. id: 1,
  151. brand: 'Yamaha'
  152. };
  153. beforeEach(async () => {
  154. const resourcePath = join(baseDir, 'pianos.jsonl');
  155. await writeFile(resourcePath, JSON.stringify(data));
  156. });
  157. beforeEach(() => {
  158. Piano.canFetchItem();
  159. return new Promise((resolve) => {
  160. setTimeout(() => {
  161. resolve();
  162. });
  163. });
  164. });
  165. afterEach(() => {
  166. Piano.canFetchItem(false);
  167. });
  168. it('throws on query', async () => {
  169. const [res] = await client({
  170. method: 'GET',
  171. path: `${BASE_PATH}/pianos/2`,
  172. });
  173. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  174. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
  175. });
  176. it('throws on HEAD method', async () => {
  177. const [res] = await client({
  178. method: 'HEAD',
  179. path: `${BASE_PATH}/pianos/2`,
  180. });
  181. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  182. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
  183. });
  184. it('throws on item not found', async () => {
  185. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  186. getById.mockResolvedValueOnce(null as never);
  187. const [res] = await client({
  188. method: 'GET',
  189. path: `${BASE_PATH}/pianos/2`,
  190. });
  191. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  192. });
  193. it('throws on item not found on HEAD method', async () => {
  194. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  195. getById.mockResolvedValueOnce(null as never);
  196. const [res] = await client({
  197. method: 'HEAD',
  198. path: `${BASE_PATH}/pianos/2`,
  199. });
  200. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  201. });
  202. });
  203. describe('creating items', () => {
  204. const data = {
  205. id: 1,
  206. brand: 'Yamaha'
  207. };
  208. const newData = {
  209. brand: 'K. Kawai'
  210. };
  211. beforeEach(async () => {
  212. const resourcePath = join(baseDir, 'pianos.jsonl');
  213. await writeFile(resourcePath, JSON.stringify(data));
  214. });
  215. beforeEach(() => {
  216. Piano.canCreate();
  217. });
  218. afterEach(() => {
  219. Piano.canCreate(false);
  220. });
  221. it('throws on error assigning ID', async () => {
  222. const [res] = await client({
  223. method: 'POST',
  224. path: `${BASE_PATH}/pianos`,
  225. body: newData,
  226. });
  227. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  228. expect(res).toHaveProperty('statusMessage', 'Unable To Assign ID From Piano Data Source');
  229. });
  230. it('throws on error creating resource', async () => {
  231. const getById = vi.spyOn(DummyDataSource.prototype, 'newId');
  232. getById.mockResolvedValueOnce(data.id as never);
  233. const [res] = await client({
  234. method: 'POST',
  235. path: `${BASE_PATH}/pianos`,
  236. body: newData,
  237. });
  238. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  239. expect(res).toHaveProperty('statusMessage', 'Unable To Create Piano');
  240. });
  241. });
  242. describe.skip('patching items', () => {
  243. const data = {
  244. id: 1,
  245. brand: 'Yamaha'
  246. };
  247. const newData = {
  248. brand: 'K. Kawai'
  249. };
  250. beforeEach(async () => {
  251. const resourcePath = join(baseDir, 'pianos.jsonl');
  252. await writeFile(resourcePath, JSON.stringify(data));
  253. });
  254. beforeEach(() => {
  255. Piano.canPatch();
  256. });
  257. afterEach(() => {
  258. Piano.canPatch(false);
  259. });
  260. it('throws on item to patch not found', () => {
  261. return new Promise<void>((resolve, reject) => {
  262. const req = request(
  263. {
  264. host: HOST,
  265. port: PORT,
  266. path: `${BASE_PATH}/pianos/2`,
  267. method: 'PATCH',
  268. headers: {
  269. 'Accept': ACCEPT,
  270. 'Accept-Language': ACCEPT_LANGUAGE,
  271. 'Content-Type': `${CONTENT_TYPE}; charset="${CONTENT_TYPE_CHARSET}"`,
  272. },
  273. },
  274. (res) => {
  275. res.on('error', (err) => {
  276. reject(err);
  277. });
  278. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  279. resolve();
  280. },
  281. );
  282. req.on('error', (err) => {
  283. reject(err);
  284. });
  285. req.write(JSON.stringify(newData));
  286. req.end();
  287. });
  288. });
  289. });
  290. describe.skip('emplacing items', () => {
  291. const data = {
  292. id: 1,
  293. brand: 'Yamaha'
  294. };
  295. const newData = {
  296. id: 1,
  297. brand: 'K. Kawai'
  298. };
  299. beforeEach(async () => {
  300. const resourcePath = join(baseDir, 'pianos.jsonl');
  301. await writeFile(resourcePath, JSON.stringify(data));
  302. });
  303. beforeEach(() => {
  304. Piano.canEmplace();
  305. });
  306. afterEach(() => {
  307. Piano.canEmplace(false);
  308. });
  309. });
  310. describe('deleting items', () => {
  311. const data = {
  312. id: 1,
  313. brand: 'Yamaha'
  314. };
  315. beforeEach(async () => {
  316. const resourcePath = join(baseDir, 'pianos.jsonl');
  317. await writeFile(resourcePath, JSON.stringify(data));
  318. });
  319. beforeEach(() => {
  320. Piano.canDelete();
  321. backend.throwsErrorOnDeletingNotFound();
  322. });
  323. afterEach(() => {
  324. Piano.canDelete(false);
  325. backend.throwsErrorOnDeletingNotFound(false);
  326. });
  327. it('throws on unable to check if item exists', async () => {
  328. const [res] = await client({
  329. method: 'DELETE',
  330. path: `${BASE_PATH}/pianos/2`,
  331. });
  332. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  333. expect(res).toHaveProperty('statusMessage', 'Unable To Fetch Piano');
  334. });
  335. it('throws on item not found', async () => {
  336. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  337. getById.mockResolvedValueOnce(null as never);
  338. const [res] = await client({
  339. method: 'DELETE',
  340. path: `${BASE_PATH}/pianos/2`,
  341. });
  342. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
  343. expect(res).toHaveProperty('statusMessage', 'Delete Non-Existing Piano');
  344. });
  345. it('throws on unable to delete item', async () => {
  346. const getById = vi.spyOn(DummyDataSource.prototype, 'getById');
  347. getById.mockResolvedValueOnce({
  348. id: 2
  349. } as never);
  350. const [res] = await client({
  351. method: 'DELETE',
  352. path: `${BASE_PATH}/pianos/2`,
  353. });
  354. expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
  355. expect(res).toHaveProperty('statusMessage', 'Unable To Delete Piano');
  356. });
  357. });
  358. });
  359. });