Repository: lucasfcosta/testing-javascript-applications Branch: master Commit: 26dfe4572bcb Files: 852 Total size: 773.9 KB Directory structure: gitextract_4jsmhnkg/ ├── .gitignore ├── LICENSE ├── README.md ├── chapter11/ │ ├── 1_writing_end_to_end_tests/ │ │ ├── 1_setting_up_cypress/ │ │ │ ├── cypress/ │ │ │ │ ├── fixtures/ │ │ │ │ │ └── example.json │ │ │ │ ├── integration/ │ │ │ │ │ └── examples/ │ │ │ │ │ ├── actions.spec.js │ │ │ │ │ ├── aliasing.spec.js │ │ │ │ │ ├── assertions.spec.js │ │ │ │ │ ├── connectors.spec.js │ │ │ │ │ ├── cookies.spec.js │ │ │ │ │ ├── cypress_api.spec.js │ │ │ │ │ ├── files.spec.js │ │ │ │ │ ├── local_storage.spec.js │ │ │ │ │ ├── location.spec.js │ │ │ │ │ ├── misc.spec.js │ │ │ │ │ ├── navigation.spec.js │ │ │ │ │ ├── network_requests.spec.js │ │ │ │ │ ├── querying.spec.js │ │ │ │ │ ├── spies_stubs_clocks.spec.js │ │ │ │ │ ├── traversal.spec.js │ │ │ │ │ ├── utilities.spec.js │ │ │ │ │ ├── viewport.spec.js │ │ │ │ │ ├── waiting.spec.js │ │ │ │ │ └── window.spec.js │ │ │ │ ├── plugins/ │ │ │ │ │ └── index.js │ │ │ │ └── support/ │ │ │ │ ├── commands.js │ │ │ │ └── index.js │ │ │ ├── cypress.json │ │ │ └── package.json │ │ ├── 2_writing_your_first_tests/ │ │ │ ├── cypress/ │ │ │ │ ├── dbConnection.js │ │ │ │ ├── fixtures/ │ │ │ │ │ └── example.json │ │ │ │ ├── integration/ │ │ │ │ │ └── itemSubmission.spec.js │ │ │ │ ├── knexfile.js │ │ │ │ ├── plugins/ │ │ │ │ │ ├── dbPlugin.js │ │ │ │ │ └── index.js │ │ │ │ └── support/ │ │ │ │ ├── commands.js │ │ │ │ └── index.js │ │ │ ├── cypress.json │ │ │ └── package.json │ │ └── 3_sending_http_requests/ │ │ ├── cypress/ │ │ │ ├── dbConnection.js │ │ │ ├── fixtures/ │ │ │ │ └── example.json │ │ │ ├── integration/ │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ └── itemSubmission.spec.js │ │ │ ├── knexfile.js │ │ │ ├── plugins/ │ │ │ │ ├── dbPlugin.js │ │ │ │ └── index.js │ │ │ └── support/ │ │ │ ├── commands.js │ │ │ └── index.js │ │ ├── cypress.json │ │ └── package.json │ ├── 2_best_practices_for_end_to_end_tests/ │ │ ├── 1_page_objects/ │ │ │ ├── cypress/ │ │ │ │ ├── dbConnection.js │ │ │ │ ├── fixtures/ │ │ │ │ │ └── example.json │ │ │ │ ├── integration/ │ │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ │ └── itemSubmission.spec.js │ │ │ │ ├── knexfile.js │ │ │ │ ├── pageObjects/ │ │ │ │ │ └── inventoryManagement.js │ │ │ │ ├── plugins/ │ │ │ │ │ ├── dbPlugin.js │ │ │ │ │ └── index.js │ │ │ │ └── support/ │ │ │ │ ├── commands.js │ │ │ │ └── index.js │ │ │ ├── cypress.json │ │ │ └── package.json │ │ └── 2_application_actions/ │ │ ├── cypress/ │ │ │ ├── dbConnection.js │ │ │ ├── fixtures/ │ │ │ │ └── example.json │ │ │ ├── integration/ │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ └── itemSubmission.spec.js │ │ │ ├── knexfile.js │ │ │ ├── pageObjects/ │ │ │ │ └── inventoryManagement.js │ │ │ ├── plugins/ │ │ │ │ ├── dbPlugin.js │ │ │ │ └── index.js │ │ │ └── support/ │ │ │ ├── commands.js │ │ │ └── index.js │ │ ├── cypress.json │ │ └── package.json │ ├── 3_dealing_with_flakiness/ │ │ ├── 1_avoiding_waiting_for_fixed_amounts_of_time/ │ │ │ ├── cypress/ │ │ │ │ ├── dbConnection.js │ │ │ │ ├── fixtures/ │ │ │ │ │ └── example.json │ │ │ │ ├── integration/ │ │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ │ └── itemSubmission.spec.js │ │ │ │ ├── knexfile.js │ │ │ │ ├── pageObjects/ │ │ │ │ │ └── inventoryManagement.js │ │ │ │ ├── plugins/ │ │ │ │ │ ├── dbPlugin.js │ │ │ │ │ └── index.js │ │ │ │ └── support/ │ │ │ │ ├── commands.js │ │ │ │ └── index.js │ │ │ ├── cypress.json │ │ │ └── package.json │ │ └── 2_stubbing_uncontrollable_factors/ │ │ ├── cypress/ │ │ │ ├── dbConnection.js │ │ │ ├── fixtures/ │ │ │ │ └── example.json │ │ │ ├── integration/ │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ └── itemSubmission.spec.js │ │ │ ├── knexfile.js │ │ │ ├── pageObjects/ │ │ │ │ └── inventoryManagement.js │ │ │ ├── plugins/ │ │ │ │ ├── dbPlugin.js │ │ │ │ └── index.js │ │ │ └── support/ │ │ │ ├── commands.js │ │ │ └── index.js │ │ ├── cypress.json │ │ └── package.json │ ├── 4_visual_regression_tests/ │ │ ├── cypress/ │ │ │ ├── dbConnection.js │ │ │ ├── fixtures/ │ │ │ │ └── example.json │ │ │ ├── integration/ │ │ │ │ ├── itemList.spec.js │ │ │ │ ├── itemListUpdates.spec.js │ │ │ │ └── itemSubmission.spec.js │ │ │ ├── knexfile.js │ │ │ ├── pageObjects/ │ │ │ │ └── inventoryManagement.js │ │ │ ├── plugins/ │ │ │ │ ├── dbPlugin.js │ │ │ │ └── index.js │ │ │ └── support/ │ │ │ ├── commands.js │ │ │ └── index.js │ │ ├── cypress.json │ │ └── package.json │ ├── client/ │ │ ├── domController.js │ │ ├── domController.test.js │ │ ├── index.html │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── main.test.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ ├── setupJestDom.js │ │ ├── socket.js │ │ ├── socket.test.js │ │ ├── testSocketServer.js │ │ └── testUtils.js │ └── server/ │ ├── README.md │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ ├── 20200325082401_initial_schema.js │ │ └── 20200331210311_updatedAt_field.js │ ├── package.json │ ├── seedUser.js │ ├── seeds/ │ │ └── initial_inventory.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter13/ │ └── 1_type_systems/ │ ├── 1_no_types/ │ │ ├── orderQueue.js │ │ ├── orderQueue.spec.js │ │ └── package.json │ └── 2_with_types/ │ ├── orderQueue.js │ ├── orderQueue.spec.js │ ├── orderQueue.spec.ts │ ├── orderQueue.ts │ ├── package.json │ └── tsconfig.json ├── chapter2/ │ ├── 2_unit_tests/ │ │ ├── 1_raw_tests/ │ │ │ ├── Cart.js │ │ │ └── Cart.test.js │ │ ├── 2_node_assert/ │ │ │ ├── Cart.js │ │ │ └── Cart.test.js │ │ ├── 3_jest_multiple_tests/ │ │ │ ├── Cart.js │ │ │ ├── Cart.test.js │ │ │ └── package.json │ │ ├── 4_jest_assertions/ │ │ │ ├── Cart.js │ │ │ ├── Cart.test.js │ │ │ └── package.json │ │ └── 5_npm_scripts/ │ │ ├── Cart.js │ │ ├── Cart.test.js │ │ └── package.json │ ├── 3_integration_tests/ │ │ ├── 1_knex_tests_promise/ │ │ │ ├── cart.js │ │ │ ├── cart.test.js │ │ │ ├── dbConnection.js │ │ │ ├── knexfile.js │ │ │ ├── migrations/ │ │ │ │ └── 20191230210750_create_carts.js │ │ │ └── package.json │ │ ├── 2_knex_tests_done_cb/ │ │ │ ├── cart.js │ │ │ ├── cart.test.js │ │ │ ├── dbConnection.js │ │ │ ├── knexfile.js │ │ │ ├── migrations/ │ │ │ │ └── 20191230210750_create_carts.js │ │ │ └── package.json │ │ └── 3_knex_tests_hooks/ │ │ ├── cart.js │ │ ├── cart.test.js │ │ ├── dbConnection.js │ │ ├── knexfile.js │ │ ├── migrations/ │ │ │ └── 20191230210750_create_carts.js │ │ └── package.json │ ├── 4_end_to_end_tests/ │ │ ├── 1_http_api_tests/ │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ └── 2_http_api_with_remove_item/ │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ └── 5_tests_cost_and_revenue/ │ ├── 1_good_vs_bad/ │ │ ├── badly_written.test.js │ │ ├── package.json │ │ ├── server.js │ │ └── well_written.test.js │ └── 2_test_coupling/ │ ├── package.json │ ├── pow.test.js │ ├── pow_loop.js │ └── pow_recursive.js ├── chapter3/ │ ├── 1_organising_test_suites/ │ │ ├── 1_breaking_down_tests_big_tests/ │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ ├── 2_breaking_down_tests_small_tests/ │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ └── 3_global_hooks/ │ │ ├── dummy.test.js │ │ ├── globalSetup.js │ │ ├── globalTeardown.js │ │ ├── jest.config.js │ │ └── package.json │ ├── 2_writing_good_assertions/ │ │ ├── 1_assertion_checks/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ └── package.json │ │ ├── 2_assertion_checks_toThrow/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ └── package.json │ │ ├── 3_loose_assertions/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ └── package.json │ │ ├── 4_asymmetric_matchers/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ └── package.json │ │ ├── 5_manual_assertions/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ └── package.json │ │ ├── 6_custom_matchers/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── jest.config.js │ │ │ └── package.json │ │ └── 7_circular_assertions/ │ │ ├── inventoryController.js │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ ├── 3_mocks_stubs_and_spies/ │ │ ├── 1_mocking_objects/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── logger.js │ │ │ └── package.json │ │ ├── 2_mocking_imports/ │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── logger.js │ │ │ └── package.json │ │ └── 3_manual_mocks/ │ │ ├── __mocks__/ │ │ │ └── logger.js │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── logger.js │ │ └── package.json │ └── 4_code_coverage/ │ ├── 1_measuring_code_coverage/ │ │ ├── __mocks__/ │ │ │ └── logger.js │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── logger.js │ │ └── package.json │ └── 2_what_coverage_is_good_for/ │ ├── math.js │ ├── math.test.js │ └── package.json ├── chapter4/ │ ├── 1_setting_up_a_test_environment/ │ │ └── 1_exposing_modules/ │ │ ├── 1_end_to_end_tests/ │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ ├── 2_integration_tests/ │ │ │ ├── cartController.js │ │ │ ├── cartController.test.js │ │ │ ├── inventoryController.js │ │ │ ├── logger.js │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ └── 3_unit_tests/ │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── inventoryController.js │ │ ├── logger.js │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ ├── 2_testing_http_endpoints/ │ │ ├── 1_using_supertest/ │ │ │ ├── cartController.js │ │ │ ├── cartController.test.js │ │ │ ├── inventoryController.js │ │ │ ├── logger.js │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── server.test.js │ │ └── 2_testing_middlewares/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── inventoryController.js │ │ ├── logger.js │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ └── 3_dealing_with_external_dependencies/ │ ├── 1_database_integrations/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── inventoryController.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrations/ │ │ │ └── 20200325082401_initial_schema.js │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ ├── 2_separate_database_instances/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── inventoryController.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrations/ │ │ │ └── 20200325082401_initial_schema.js │ │ ├── package.json │ │ ├── server.js │ │ └── server.test.js │ ├── 3_maitaining_a_pristine_state/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── disconnectFromDb.js │ │ ├── inventoryController.js │ │ ├── jest.config.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrateDatabases.js │ │ ├── migrations/ │ │ │ └── 20200325082401_initial_schema.js │ │ ├── package.json │ │ ├── seedUser.js │ │ ├── server.js │ │ ├── server.test.js │ │ ├── truncateTables.js │ │ └── userTestUtils.js │ ├── 4_integrations_with_other_apis/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── disconnectFromDb.js │ │ ├── inventoryController.js │ │ ├── jest.config.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrateDatabases.js │ │ ├── migrations/ │ │ │ └── 20200325082401_initial_schema.js │ │ ├── package.json │ │ ├── seedUser.js │ │ ├── server.js │ │ ├── server.test.js │ │ ├── truncateTables.js │ │ └── userTestUtils.js │ ├── 5_using_mocks_to_avoid_requests/ │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── disconnectFromDb.js │ │ ├── inventoryController.js │ │ ├── jest.config.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrateDatabases.js │ │ ├── migrations/ │ │ │ └── 20200325082401_initial_schema.js │ │ ├── package.json │ │ ├── seedUser.js │ │ ├── server.js │ │ ├── server.test.js │ │ ├── truncateTables.js │ │ └── userTestUtils.js │ └── 6_using_nock_to_avoid_requests/ │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ └── 20200325082401_initial_schema.js │ ├── package.json │ ├── seedUser.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter5/ │ └── 1_eliminating_non_determinism/ │ ├── 1_shared_resources/ │ │ ├── countModule.js │ │ ├── decrement.test.js │ │ ├── increment.test.js │ │ └── package.json │ ├── 2_resource_pools/ │ │ ├── countModule.js │ │ ├── decrement.test.js │ │ ├── increment.test.js │ │ ├── instancePool.js │ │ └── package.json │ └── 3_dealing_with_time/ │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ ├── 20200325082401_initial_schema.js │ │ └── 20200331210311_updatedAt_field.js │ ├── package.json │ ├── seedUser.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter6/ │ ├── 1_introducing_jsdom/ │ │ ├── 1_pure_html/ │ │ │ ├── index.html │ │ │ └── main.js │ │ ├── 2_jsdom/ │ │ │ ├── example.js │ │ │ ├── index.html │ │ │ ├── main.js │ │ │ ├── package.json │ │ │ └── page.js │ │ └── 3_jest_jsdom/ │ │ ├── index.html │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── main.test.js │ │ └── package.json │ ├── 2_asserting_on_the_dom/ │ │ ├── 1_finding_elements_by_dom_structure/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── main.js │ │ │ └── package.json │ │ ├── 2_finding_elements_by_id/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── main.js │ │ │ └── package.json │ │ ├── 3_robust_element_queries/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── main.js │ │ │ └── package.json │ │ ├── 4_finding_with_dom_testing_library/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── main.js │ │ │ └── package.json │ │ └── 5_writing_better_dom_assertions/ │ │ ├── domController.js │ │ ├── domController.test.js │ │ ├── index.html │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── package.json │ │ └── setupJestDom.js │ ├── 3_handling_events/ │ │ ├── 1_handling_raw_events/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── jest.config.js │ │ │ ├── main.js │ │ │ ├── main.test.js │ │ │ ├── package.json │ │ │ └── setupJestDom.js │ │ ├── 2_bubbling_up_events/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── jest.config.js │ │ │ ├── main.js │ │ │ ├── main.test.js │ │ │ ├── package.json │ │ │ └── setupJestDom.js │ │ └── 3_dom_testing_library_events/ │ │ ├── domController.js │ │ ├── domController.test.js │ │ ├── index.html │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── main.test.js │ │ ├── package.json │ │ └── setupJestDom.js │ ├── 4_testing_and_browser_apis/ │ │ ├── 1_localstorage/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── jest.config.js │ │ │ ├── main.js │ │ │ ├── main.test.js │ │ │ ├── package.json │ │ │ └── setupJestDom.js │ │ ├── 2_history_api/ │ │ │ ├── domController.js │ │ │ ├── domController.test.js │ │ │ ├── index.html │ │ │ ├── inventoryController.js │ │ │ ├── inventoryController.test.js │ │ │ ├── jest.config.js │ │ │ ├── main.js │ │ │ ├── main.test.js │ │ │ ├── package.json │ │ │ ├── setupJestDom.js │ │ │ └── testUtils.js │ │ └── server/ │ │ ├── README.md │ │ ├── authenticationController.js │ │ ├── authenticationController.test.js │ │ ├── cartController.js │ │ ├── cartController.test.js │ │ ├── dbConnection.js │ │ ├── disconnectFromDb.js │ │ ├── inventoryController.js │ │ ├── jest.config.js │ │ ├── knexfile.js │ │ ├── logger.js │ │ ├── migrateDatabases.js │ │ ├── migrations/ │ │ │ ├── 20200325082401_initial_schema.js │ │ │ └── 20200331210311_updatedAt_field.js │ │ ├── package.json │ │ ├── seedUser.js │ │ ├── seeds/ │ │ │ └── initial_inventory.js │ │ ├── server.js │ │ ├── server.test.js │ │ ├── truncateTables.js │ │ └── userTestUtils.js │ └── 5_web_sockets_and_http_requests/ │ ├── 1_http_requests/ │ │ ├── domController.js │ │ ├── domController.test.js │ │ ├── index.html │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── main.test.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ ├── setupJestDom.js │ │ └── testUtils.js │ ├── 2_web_sockets/ │ │ ├── domController.js │ │ ├── domController.test.js │ │ ├── index.html │ │ ├── inventoryController.js │ │ ├── inventoryController.test.js │ │ ├── jest.config.js │ │ ├── main.js │ │ ├── main.test.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ ├── setupJestDom.js │ │ ├── socket.js │ │ ├── socket.test.js │ │ ├── testSocketServer.js │ │ └── testUtils.js │ └── server/ │ ├── README.md │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ ├── 20200325082401_initial_schema.js │ │ └── 20200331210311_updatedAt_field.js │ ├── package.json │ ├── seedUser.js │ ├── seeds/ │ │ └── initial_inventory.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter7/ │ ├── 1_setting_up_a_test_environment/ │ │ ├── 1_createElement_calls/ │ │ │ ├── index.html │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── 2_transforming_jsx/ │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ └── package.json │ │ └── 3_setting_up_jest/ │ │ ├── App.jsx │ │ ├── app.test.js │ │ ├── babel.config.js │ │ ├── index.html │ │ ├── index.jsx │ │ ├── jest.config.js │ │ └── package.json │ ├── 2_an_overview_of_react_testing_libraries/ │ │ ├── 1_react_testing_utilities/ │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── babel.config.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ └── setupJestDom.js │ │ └── 2_react_testing_library/ │ │ ├── App.jsx │ │ ├── App.test.jsx │ │ ├── ItemForm.jsx │ │ ├── ItemForm.test.jsx │ │ ├── ItemList.jsx │ │ ├── ItemList.test.jsx │ │ ├── babel.config.js │ │ ├── constants.js │ │ ├── index.html │ │ ├── index.jsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ └── setupJestDom.js │ └── server/ │ ├── README.md │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ ├── 20200325082401_initial_schema.js │ │ └── 20200331210311_updatedAt_field.js │ ├── package.json │ ├── seedUser.js │ ├── seeds/ │ │ └── initial_inventory.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter8/ │ ├── 1_testing_component_interaction/ │ │ ├── 1_component_integration_tests/ │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── ItemForm.jsx │ │ │ ├── ItemForm.test.jsx │ │ │ ├── ItemList.jsx │ │ │ ├── ItemList.test.jsx │ │ │ ├── babel.config.js │ │ │ ├── constants.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── setupGlobalFetch.js │ │ │ └── setupJestDom.js │ │ └── 2_stubbing_components/ │ │ ├── App.jsx │ │ ├── App.test.jsx │ │ ├── ItemForm.jsx │ │ ├── ItemForm.test.jsx │ │ ├── ItemList.jsx │ │ ├── ItemList.test.jsx │ │ ├── __mocks__/ │ │ │ └── react-spring/ │ │ │ └── renderprops.jsx │ │ ├── babel.config.js │ │ ├── constants.js │ │ ├── index.html │ │ ├── index.jsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ └── setupJestDom.js │ ├── 2_snapshot_testing/ │ │ ├── 1_component_snapshots/ │ │ │ ├── ActionLog.jsx │ │ │ ├── ActionLog.test.jsx │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── ItemForm.jsx │ │ │ ├── ItemForm.test.jsx │ │ │ ├── ItemList.jsx │ │ │ ├── ItemList.test.jsx │ │ │ ├── __mocks__/ │ │ │ │ └── react-spring/ │ │ │ │ └── renderprops.jsx │ │ │ ├── __snapshots__/ │ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ │ └── App.test.jsx.snap │ │ │ ├── babel.config.js │ │ │ ├── constants.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── setupGlobalFetch.js │ │ │ └── setupJestDom.js │ │ └── 2_snapshots_beyond_components/ │ │ ├── __snapshots__/ │ │ │ └── generate_report.test.js.snap │ │ ├── generate_report.js │ │ ├── generate_report.test.js │ │ └── package.json │ ├── 3_testing_styles/ │ │ ├── 1_css_classes/ │ │ │ ├── ActionLog.jsx │ │ │ ├── ActionLog.test.jsx │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── ItemForm.jsx │ │ │ ├── ItemForm.test.jsx │ │ │ ├── ItemList.jsx │ │ │ ├── ItemList.test.jsx │ │ │ ├── __mocks__/ │ │ │ │ └── react-spring/ │ │ │ │ └── renderprops.jsx │ │ │ ├── __snapshots__/ │ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ │ └── App.test.jsx.snap │ │ │ ├── babel.config.js │ │ │ ├── constants.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── setupGlobalFetch.js │ │ │ ├── setupJestDom.js │ │ │ └── styles.css │ │ ├── 2_style_props/ │ │ │ ├── ActionLog.jsx │ │ │ ├── ActionLog.test.jsx │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── ItemForm.jsx │ │ │ ├── ItemForm.test.jsx │ │ │ ├── ItemList.jsx │ │ │ ├── ItemList.test.jsx │ │ │ ├── __mocks__/ │ │ │ │ └── react-spring/ │ │ │ │ └── renderprops.jsx │ │ │ ├── __snapshots__/ │ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ │ └── App.test.jsx.snap │ │ │ ├── babel.config.js │ │ │ ├── constants.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── setupGlobalFetch.js │ │ │ ├── setupJestDom.js │ │ │ └── styles.css │ │ └── 3_css_in_js_snapshots/ │ │ ├── ActionLog.jsx │ │ ├── ActionLog.test.jsx │ │ ├── App.jsx │ │ ├── App.test.jsx │ │ ├── ItemForm.jsx │ │ ├── ItemForm.test.jsx │ │ ├── ItemList.jsx │ │ ├── ItemList.test.jsx │ │ ├── __mocks__/ │ │ │ └── react-spring/ │ │ │ └── renderprops.jsx │ │ ├── __snapshots__/ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ ├── App.test.jsx.snap │ │ │ └── ItemList.test.jsx.snap │ │ ├── babel.config.js │ │ ├── constants.js │ │ ├── index.html │ │ ├── index.jsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ ├── setupJestDom.js │ │ ├── setupJestEmotion.js │ │ └── styles.css │ ├── 4_component_stories/ │ │ ├── 1_stories/ │ │ │ ├── .storybook/ │ │ │ │ └── main.js │ │ │ ├── ActionLog.jsx │ │ │ ├── ActionLog.stories.jsx │ │ │ ├── ActionLog.test.jsx │ │ │ ├── App.jsx │ │ │ ├── App.test.jsx │ │ │ ├── ItemForm.jsx │ │ │ ├── ItemForm.stories.jsx │ │ │ ├── ItemForm.test.jsx │ │ │ ├── ItemList.jsx │ │ │ ├── ItemList.stories.jsx │ │ │ ├── ItemList.test.jsx │ │ │ ├── __mocks__/ │ │ │ │ └── react-spring/ │ │ │ │ └── renderprops.jsx │ │ │ ├── __snapshots__/ │ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ │ ├── App.test.jsx.snap │ │ │ │ └── ItemList.test.jsx.snap │ │ │ ├── babel.config.js │ │ │ ├── constants.js │ │ │ ├── index.html │ │ │ ├── index.jsx │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── setupGlobalFetch.js │ │ │ ├── setupJestDom.js │ │ │ ├── setupJestEmotion.js │ │ │ └── styles.css │ │ └── 2_documentation/ │ │ ├── .storybook/ │ │ │ └── main.js │ │ ├── ActionLog.jsx │ │ ├── ActionLog.stories.jsx │ │ ├── ActionLog.test.jsx │ │ ├── App.jsx │ │ ├── App.test.jsx │ │ ├── ItemForm.jsx │ │ ├── ItemForm.stories.jsx │ │ ├── ItemForm.test.jsx │ │ ├── ItemList.jsx │ │ ├── ItemList.stories.jsx │ │ ├── ItemList.stories.mdx │ │ ├── ItemList.test.jsx │ │ ├── __mocks__/ │ │ │ └── react-spring/ │ │ │ └── renderprops.jsx │ │ ├── __snapshots__/ │ │ │ ├── ActionLog.test.jsx.snap │ │ │ ├── App.test.jsx.snap │ │ │ └── ItemList.test.jsx.snap │ │ ├── babel.config.js │ │ ├── constants.js │ │ ├── index.html │ │ ├── index.jsx │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── setupGlobalFetch.js │ │ ├── setupJestDom.js │ │ ├── setupJestEmotion.js │ │ └── styles.css │ └── server/ │ ├── README.md │ ├── authenticationController.js │ ├── authenticationController.test.js │ ├── cartController.js │ ├── cartController.test.js │ ├── dbConnection.js │ ├── disconnectFromDb.js │ ├── inventoryController.js │ ├── jest.config.js │ ├── knexfile.js │ ├── logger.js │ ├── migrateDatabases.js │ ├── migrations/ │ │ ├── 20200325082401_initial_schema.js │ │ └── 20200331210311_updatedAt_field.js │ ├── package.json │ ├── seedUser.js │ ├── seeds/ │ │ └── initial_inventory.js │ ├── server.js │ ├── server.test.js │ ├── truncateTables.js │ └── userTestUtils.js ├── chapter9/ │ ├── 1_the_philosophy_behind_tdd/ │ │ ├── 1_what_tdd_is/ │ │ │ ├── 1_small_test/ │ │ │ │ ├── calculateCartPrice.js │ │ │ │ ├── calculateCartPrice.test.js │ │ │ │ └── package.json │ │ │ ├── 2_partial_test/ │ │ │ │ ├── calculateCartPrice.js │ │ │ │ ├── calculateCartPrice.test.js │ │ │ │ └── package.json │ │ │ ├── 3_extra_test/ │ │ │ │ ├── calculateCartPrice.js │ │ │ │ ├── calculateCartPrice.test.js │ │ │ │ └── package.json │ │ │ └── 4_handling_edge_cases/ │ │ │ ├── calculateCartPrice.js │ │ │ ├── calculateCartPrice.test.js │ │ │ └── package.json │ │ └── 2_adjusting_iteration_size/ │ │ └── 1_bigger_steps/ │ │ ├── calculateCartPrice.js │ │ ├── calculateCartPrice.test.js │ │ ├── package.json │ │ ├── pickMostExpensive.js │ │ └── pickMostExpensive.test.js │ └── 2_writing_a_js_module_using_tdd/ │ ├── 1_generating_item_rows/ │ │ ├── inventoryReport.js │ │ ├── inventoryReport.test.js │ │ └── package.json │ ├── 2_generating_total_row/ │ │ ├── inventoryReport.js │ │ ├── inventoryReport.test.js │ │ └── package.json │ └── 3_creating_report/ │ ├── inventoryReport.js │ ├── inventoryReport.test.js │ └── package.json └── package.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output .next # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # SQLite Databases *.sqlite # .DS_Store .DS_Store # Built JS bundles bundle.js ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Testing JavaScript Applications

A Manning book by Lucas da Costa


➡ Available at Manning.com

## 📕 About this book Testing JavaScript Applications will help you write high-quality software in less time, with more confidence. In the last five years, I have been deeply involved in the JavaScript testing scene. I am a core-maintainer of both Chai.js and Sinon.js, two of the most popular testing libraries in JavaScript, and I closely follow projects like Jest and Mocha. In this book, I expect to teach you what I've learned during those years in which I've been involved in vetting and implementing features, defining best-practices, and designing the libraries that thousands of people use every day. Throughout this book's pages, you will learn how to write effective tests through various diagrams and practical examples we'll build together. Because I believe the best way to learn something is by doing it yourself, **I've put in this repository all of the book's examples**, so that you can experiment with them on your own and compare your solutions to the ones which I've implemented. Besides covering specific tools, like Jest, and techniques, like TDD, it will teach you how to think about tests from a business perspective. You will learn what to take into account when designing tests, and how to make optimal decisions for _your_ specific context. I've written Testing JavaScript Applications thinking mostly about Junior Developers. They are the ones who will benefit the most from this book's approach to tests, which covers both the _"hows"_ and the _"whys"_ of writing automated tests. Even though Junior Developers will be the ones who will benefit the most from this book, it also contains topics which cater to senior and mid-level developers. It includes my thoughts on how tests impact a business, how they structure relationships within teams, and other aspects involved in building what I'd call "a culture of quality". To get the most out of "Testing JavaScript Applications", you must have a basic understanding of JavaScript. You should know how to use objects, functions, callbacks, and, especially, Promises. Basic knowledge of CSS and HTML is also required for the chapters in which we'll test a front-end application. Because I've tried to make the examples in this book as close to reality as possible, there will be chapters in which we'll test a Node.js back-end application, and others in which we'll test a React application. Therefore, it will be necessary to have elementary knowledge about these tools. Reading their "getting started" guides should take you approximately 15 minutes each, and will be enough for you to follow along with the testing examples. If you have any questions, comments, or suggestions, I'd love to hear them. With your invaluable feedback, we'll build a better book together. I wish you all a productive, pleasant, and exciting journey. _— Lucas da Costa_
## 📁 About this repository **This repository contains all of the examples in the book _Testing JavaScript Applications_**. I have organised examples in a separate folder for each chapter and, within a chapter, I've separated them by section. Sometimes, even within sections, you will find sub-divisions with the multiple stages of an exercise or with different approaches to solving the same problem.
## 💻 Running these examples I've built these examples using [Node.js](https://nodejs.org) v12 and [NPM](https://www.npmjs.com) v6. Before executing any of these examples, `cd` into the folder you want to try and run `npm install` to install its dependencies. Most of the examples have an [NPM script](https://www.keithcirkel.co.uk/how-to-use-npm-as-a-build-tool/) named `test`. Which means that you can execute tests for that example by using `npm test`.
## 🤝 How to contribute Together, we can build better content. If you happen to find _any_ problems in _any_ of these examples, feel free to submit a Pull Request explaining what the problem was and how you solved it. In case you have a _better_ solution for any of the exercises, I'd love to see it. In that case, explain in your PR why you think that the proposed solution is better. Even though I might not agree, I will treat everyone with the respect they deserve, and will carefully read through their thoughts and comments.
## 🔗 Where to find more about me and my book I'd love to hear your thoughts on the book and keep in touch with you. Send me a tweet [@thewizardlucas](https://twitter.com/thewizardlucas) and let's have a chat! - [lucasfcosta.com - My Personal Website](https://lucasfcosta.com) - [@thewizardlucas on Twitter](https://twitter.com/thewizardlucas) - [@lucasfcosta on GitHub](https://github.com/lucasfcosta) - [LinkedIn](https://www.linkedin.com/in/lucasfdacosta) - [Manning Books' Website](https://www.manning.com/) For discussing any topics related to this book, you can email me at testing.javascript.applications@lucasfcosta.com. ================================================ FILE: chapter11/1_writing_end_to_end_tests/1_setting_up_cypress/cypress/fixtures/example.json ================================================ { "name": "Using fixtures to represent data", "email": "hello@cypress.io", "body": "Fixtures are a great way to mock data for responses to routes" } ================================================ FILE: chapter11/1_writing_end_to_end_tests/1_setting_up_cypress/cypress/integration/examples/actions.spec.js ================================================ /// context("Actions", () => { beforeEach(() => { cy.visit("https://example.cypress.io/commands/actions"); }); // https://on.cypress.io/interacting-with-elements it(".type() - type into a DOM element", () => { // https://on.cypress.io/type cy.get(".action-email") .type("fake@email.com") .should("have.value", "fake@email.com") // .type() with special character sequences .type("{leftarrow}{rightarrow}{uparrow}{downarrow}") .type("{del}{selectall}{backspace}") // .type() with key modifiers .type("{alt}{option}") //these are equivalent .type("{ctrl}{control}") //these are equivalent .type("{meta}{command}{cmd}") //these are equivalent .type("{shift}") // Delay each keypress by 0.1 sec .type("slow.typing@email.com", { delay: 100 }) .should("have.value", "slow.typing@email.com"); cy.get(".action-disabled") // Ignore error checking prior to type // like whether the input is visible or disabled .type("disabled error checking", { force: true }) .should("have.value", "disabled error checking"); }); it(".focus() - focus on a DOM element", () => { // https://on.cypress.io/focus cy.get(".action-focus") .focus() .should("have.class", "focus") .prev() .should("have.attr", "style", "color: orange;"); }); it(".blur() - blur off a DOM element", () => { // https://on.cypress.io/blur cy.get(".action-blur") .type("About to blur") .blur() .should("have.class", "error") .prev() .should("have.attr", "style", "color: red;"); }); it(".clear() - clears an input or textarea element", () => { // https://on.cypress.io/clear cy.get(".action-clear") .type("Clear this text") .should("have.value", "Clear this text") .clear() .should("have.value", ""); }); it(".submit() - submit a form", () => { // https://on.cypress.io/submit cy.get(".action-form") .find('[type="text"]') .type("HALFOFF"); cy.get(".action-form") .submit() .next() .should("contain", "Your form has been submitted!"); }); it(".click() - click on a DOM element", () => { // https://on.cypress.io/click cy.get(".action-btn").click(); // You can click on 9 specific positions of an element: // ----------------------------------- // | topLeft top topRight | // | | // | | // | | // | left center right | // | | // | | // | | // | bottomLeft bottom bottomRight | // ----------------------------------- // clicking in the center of the element is the default cy.get("#action-canvas").click(); cy.get("#action-canvas").click("topLeft"); cy.get("#action-canvas").click("top"); cy.get("#action-canvas").click("topRight"); cy.get("#action-canvas").click("left"); cy.get("#action-canvas").click("right"); cy.get("#action-canvas").click("bottomLeft"); cy.get("#action-canvas").click("bottom"); cy.get("#action-canvas").click("bottomRight"); // .click() accepts an x and y coordinate // that controls where the click occurs :) cy.get("#action-canvas") .click(80, 75) // click 80px on x coord and 75px on y coord .click(170, 75) .click(80, 165) .click(100, 185) .click(125, 190) .click(150, 185) .click(170, 165); // click multiple elements by passing multiple: true cy.get(".action-labels>.label").click({ multiple: true }); // Ignore error checking prior to clicking cy.get(".action-opacity>.btn").click({ force: true }); }); it(".dblclick() - double click on a DOM element", () => { // https://on.cypress.io/dblclick // Our app has a listener on 'dblclick' event in our 'scripts.js' // that hides the div and shows an input on double click cy.get(".action-div") .dblclick() .should("not.be.visible"); cy.get(".action-input-hidden").should("be.visible"); }); it(".rightclick() - right click on a DOM element", () => { // https://on.cypress.io/rightclick // Our app has a listener on 'contextmenu' event in our 'scripts.js' // that hides the div and shows an input on right click cy.get(".rightclick-action-div") .rightclick() .should("not.be.visible"); cy.get(".rightclick-action-input-hidden").should("be.visible"); }); it(".check() - check a checkbox or radio element", () => { // https://on.cypress.io/check // By default, .check() will check all // matching checkbox or radio elements in succession, one after another cy.get('.action-checkboxes [type="checkbox"]') .not("[disabled]") .check() .should("be.checked"); cy.get('.action-radios [type="radio"]') .not("[disabled]") .check() .should("be.checked"); // .check() accepts a value argument cy.get('.action-radios [type="radio"]') .check("radio1") .should("be.checked"); // .check() accepts an array of values cy.get('.action-multiple-checkboxes [type="checkbox"]') .check(["checkbox1", "checkbox2"]) .should("be.checked"); // Ignore error checking prior to checking cy.get(".action-checkboxes [disabled]") .check({ force: true }) .should("be.checked"); cy.get('.action-radios [type="radio"]') .check("radio3", { force: true }) .should("be.checked"); }); it(".uncheck() - uncheck a checkbox element", () => { // https://on.cypress.io/uncheck // By default, .uncheck() will uncheck all matching // checkbox elements in succession, one after another cy.get('.action-check [type="checkbox"]') .not("[disabled]") .uncheck() .should("not.be.checked"); // .uncheck() accepts a value argument cy.get('.action-check [type="checkbox"]') .check("checkbox1") .uncheck("checkbox1") .should("not.be.checked"); // .uncheck() accepts an array of values cy.get('.action-check [type="checkbox"]') .check(["checkbox1", "checkbox3"]) .uncheck(["checkbox1", "checkbox3"]) .should("not.be.checked"); // Ignore error checking prior to unchecking cy.get(".action-check [disabled]") .uncheck({ force: true }) .should("not.be.checked"); }); it(".select() - select an option in a ================================================ FILE: chapter11/client/inventoryController.js ================================================ const data = { inventory: {} }; const API_ADDR = "http://localhost:3000"; const addItem = (itemName, quantity) => { const { client } = require("./socket"); const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json", "x-socket-client-id": client.id }, body: JSON.stringify({ quantity }) }); return data.inventory; }; module.exports = { API_ADDR, data, addItem }; ================================================ FILE: chapter11/client/inventoryController.test.js ================================================ const nock = require("nock"); const { API_ADDR, addItem, data } = require("./inventoryController"); const { start, stop } = require("./testSocketServer"); const { client, connect } = require("./socket"); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); describe("addItem", () => { beforeEach(() => (data.inventory = {})); test("adding new items to the inventory", () => { // Respond to all post requests // to POST /inventory/:itemName nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); test("sending requests when adding new items", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 5 })) .reply(200); addItem("cheesecake", 5); }); describe("live-updates", () => { beforeAll(start); beforeAll(async () => { nock.cleanAll(); await connect(); }); afterAll(stop); test("sending a x-socket-client-id header", () => { const clientId = client.id; nock(API_ADDR, { reqheaders: { "x-socket-client-id": clientId } }) .post(/inventory\/.*$/) .reply(200); addItem("cheesecake", 5); }); }); }); ================================================ FILE: chapter11/client/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupGlobalFetch.js", "/setupJestDom.js" ] }; ================================================ FILE: chapter11/client/main.js ================================================ const { connect } = require("./socket"); const { handleAddItem, checkFormValues, handleUndo, handlePopstate, updateItemList } = require("./domController"); const { API_ADDR, data } = require("./inventoryController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); const undoButton = document.getElementById("undo-button"); undoButton.addEventListener("click", handleUndo); window.addEventListener("popstate", handlePopstate); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); const loadInitialData = async () => { try { const inventoryResponse = await fetch(`${API_ADDR}/inventory`); data.inventory = await inventoryResponse.json(); return updateItemList(data.inventory); } catch (e) { // Restore the inventory if the request fails const storedInventory = JSON.parse(localStorage.getItem("inventory")); if (storedInventory) { data.inventory = storedInventory; updateItemList(data.inventory); } } }; connect(); module.exports = loadInitialData(); ================================================ FILE: chapter11/client/main.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); const { API_ADDR } = require("./inventoryController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils.js"); beforeEach(clearHistoryHook); beforeEach(() => localStorage.clear()); beforeEach(async () => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); await require("./main"); // You can only spy on `window.addEventListener` after `main.js` // has been executed. Otherwise `detachPopstateHandlers` will // also detach the handlers that `main.js` attached to the page. jest.spyOn(window, "addEventListener"); }); afterEach(detachPopstateHandlers); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("persists items between sessions", async () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemListBefore = document.getElementById("item-list"); expect(itemListBefore.childNodes).toHaveLength(1); expect( getByText(itemListBefore, "cheesecake - Quantity: 6") ).toBeInTheDocument(); // This is equivalent to reloading the page document.body.innerHTML = initialHtml; jest.resetModules(); await require("./main"); const itemListAfter = document.getElementById("item-list"); expect(itemListAfter.childNodes).toHaveLength(1); expect( getByText(itemListAfter, "cheesecake - Quantity: 6") ).toBeInTheDocument(); }); describe("adding items", () => { test("updating the item list", () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); test("sending a request to update the item list", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); test("undo to one item", done => { // You must specify the encoded URL here because // nock struggles with encoded urls nock(API_ADDR) .post("/inventory/carrot%20cake") .reply(200); nock(API_ADDR) .post("/inventory/cheesecake") .reply(200); const itemField = screen.getByPlaceholderText("Item name"); const quantityField = screen.getByPlaceholderText("Quantity"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); fireEvent.input(itemField, { target: { value: "carrot cake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "5" }, bubbles: true }); fireEvent.click(submitBtn); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList.children).toHaveLength(1); expect( getByText(itemList, "cheesecake - Quantity: 6") ).toBeInTheDocument(); done(); }); fireEvent.click(screen.getByText("Undo")); }); test("undo to empty list", done => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList).toBeEmpty(); done(); }); fireEvent.click(screen.getByText("Undo")); }); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter11/client/package.json ================================================ { "name": "1_http_requests", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0", "nock": "^12.0.3", "socket.io": "^2.3.0" }, "dependencies": { "http-shutdown": "^1.2.2", "socket.io-client": "^2.3.0" } } ================================================ FILE: chapter11/client/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter11/client/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter11/client/socket.js ================================================ const { API_ADDR, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); const client = { id: null }; const io = require("socket.io-client"); const handleAddItemMsg = ({ itemName, quantity }) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return updateItemList(data.inventory); }; const connect = () => { return new Promise(resolve => { const socket = io(API_ADDR); socket.on("connect", () => { client.id = socket.id; resolve(socket); }); socket.on("add_item", handleAddItemMsg); }); }; module.exports = { client, connect, handleAddItemMsg }; ================================================ FILE: chapter11/client/socket.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText } = require("@testing-library/dom"); const { data } = require("./inventoryController"); const { start, stop, sendMsg } = require("./testSocketServer"); const { handleAddItemMsg, connect } = require("./socket"); beforeEach(() => { document.body.innerHTML = initialHtml; }); beforeEach(() => { data.inventory = {}; }); describe("handleAddItemMsg", () => { test("updating the inventory and the item list", () => { handleAddItemMsg({ itemName: "cheesecake", quantity: 6 }); expect(data.inventory).toEqual({ cheesecake: 6 }); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(1); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("handling real messages", () => { beforeAll(start); beforeAll(async () => { nock.cleanAll(); await connect(); }); afterAll(stop); test("handling add_item messages", async () => { sendMsg("add_item", { itemName: "cheesecake", quantity: 6 }); await new Promise(resolve => setTimeout(resolve, 1000)); expect(data.inventory).toEqual({ cheesecake: 6 }); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(1); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); ================================================ FILE: chapter11/client/testSocketServer.js ================================================ const server = require("http").createServer(); const io = require("socket.io")(server); const sendMsg = (msgType, content) => { io.sockets.emit(msgType, content); }; const start = () => new Promise(resolve => { server.listen(3000, resolve); }); const stop = () => new Promise(resolve => { server.close(resolve); }); module.exports = { start, stop, sendMsg }; ================================================ FILE: chapter11/client/testUtils.js ================================================ const clearHistoryHook = done => { const clearHistory = () => { if (history.state === null) { window.removeEventListener("popstate", clearHistory); return done(); } history.back(); }; window.addEventListener("popstate", clearHistory); clearHistory(); }; const detachPopstateHandlers = () => { const popstateListeners = window.addEventListener.mock.calls.filter( ([eventName]) => { return eventName === "popstate"; } ); popstateListeners.forEach(([eventName, handlerFn]) => { window.removeEventListener(eventName, handlerFn); }); jest.restoreAllMocks(); }; module.exports = { clearHistoryHook, detachPopstateHandlers }; ================================================ FILE: chapter11/server/README.md ================================================ # Chapter 5 Server To better support the client-side application we'll build on Chapter 5, I've had to do a few updates to the server from Chapter 4. In case you want to update the back-end from Chapter 4 yourself, here's the list of changes I've done: - For the server to accept the requests coming from the client, you'll need to use [`@koa/cors`](https://github.com/koajs/cors) - To enable running tests while the server is running, I bind it to different ports depending on whether I am in a test or development environment. - At `POST /inventory/:itemName` I have added a route which adds an item to the inventory. It takes a `body` containing the `quantity` to add. - At `GET /inventory` I have added a route which lists all items in the inventory. - At `DELETE /inventory/:itemName` I have added a route which let's you delete inventory items so that you can use to fix the `undo` functionality - I've used `koa-socket-2` to add support for `socket.io` - The `POST /inventory/:itemName` will now push updates to all clients but the one which added an item. ================================================ FILE: chapter11/server/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter11/server/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter11/server/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter11/server/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter11/server/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const db = require("knex")(require("./knexfile")[environmentName]); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter11/server/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter11/server/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter11/server/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter11/server/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter11/server/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter11/server/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter11/server/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter11/server/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter11/server/package.json ================================================ { "name": "chapter5_server", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "cross-env NODE_ENV=development node server.js", "migrate:dev": "knex migrate:latest --env development", "seed:dev": "knex seed:run" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "@koa/cors": "^3.0.0", "cross-env": "^7.0.2", "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "koa-socket-2": "^1.2.0", "nock": "^12.0.3", "socket.io": "^2.3.0", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter11/server/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter11/server/seeds/initial_inventory.js ================================================ exports.seed = async knex => { await knex("inventory").del(); return knex("inventory").insert([ { itemName: "cheesecake", quantity: 8 }, { itemName: "apple pie", quantity: 2 }, { itemName: "carrot cake", quantity: 5 } ]); }; ================================================ FILE: chapter11/server/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const http = require("http"); const IO = require("koa-socket-2"); const cors = require("@koa/cors"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const PORT = process.env.NODE_ENV === "test" ? 5000 : 3000; const app = new Koa(); const io = new IO(); io.attach(app); const router = new Router(); app.use(cors()); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.post("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const clientId = ctx.request.headers["x-socket-client-id"]; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const itemExists = current && current.quantity > 0; const newRecord = { itemName, quantity: (itemExists ? current.quantity : 0) + quantity }; if (current) { await db("inventory") .increment("quantity", quantity) .where({ itemName }); } else { await db("inventory").insert(newRecord); } Object.entries(io.socket.sockets.connected).forEach(([id, socket]) => { if (id === clientId) return; socket.emit("add_item", { itemName, quantity }); }); ctx.body = newRecord; }); router.delete("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const canDelete = current && current.quantity > quantity; if (canDelete) { await db("inventory") .decrement("quantity", quantity) .where({ itemName }); ctx.body = { message: `Removed ${quantity} units of ${itemName}` }; } else { ctx.status = 404; ctx.body = { message: `There aren't ${quantity} units of ${itemName} available.` }; } }); router.get("/inventory", async ctx => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory") .where("quantity", ">", 0) .orderBy("quantity", "desc"); ctx.body = inventoryContent.reduce((acc, { itemName, quantity }) => { return { ...acc, [itemName]: quantity }; }, {}); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(PORT, "127.0.0.1") }; ================================================ FILE: chapter11/server/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("list inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; const carrotCake = { itemName: "carrot cake", quantity: 0 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie, carrotCake]); }); test("fetching all available items", async () => { const { body } = await request(app) .get("/inventory") .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ eggs: 3, "apple pie": 1 }); }); }); describe("add inventory items", () => { test("adding a new item", async () => { const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 3 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); test("adding an existing item", async () => { const eggs = { itemName: "eggs", quantity: 2 }; await db("inventory").insert(eggs); const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 5 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 5 }); }); }); describe("remove inventory items", () => { beforeEach(async () => { await db("inventory").insert({ itemName: "eggs", quantity: 3 }); }); test("removing an item", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 2 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ message: "Removed 2 units of eggs" }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 1 }); }); test("removing more than the inventory quantity", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 4 }) .expect(404) .expect("Content-Type", /json/); expect(body).toEqual({ message: "There aren't 4 units of eggs available." }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("fetching an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter11/server/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter11/server/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter13/1_type_systems/1_no_types/orderQueue.js ================================================ const state = { deliveries: [] }; const addToDeliveryQueue = order => { if (order.status !== "done") { throw new Error("Can't add unfinished orders to the delivery queue."); } state.deliveries.push(order); }; module.exports = { state, addToDeliveryQueue }; ================================================ FILE: chapter13/1_type_systems/1_no_types/orderQueue.spec.js ================================================ const { state, addToDeliveryQueue } = require("./orderQueue"); test("adding unfinished orders to the queue", () => { state.deliveries = []; const newOrder = { items: ["cheesecake"], status: "in progress" }; expect(() => addToDeliveryQueue(newOrder)).toThrow(); expect(state.deliveries).toEqual([]); }); ================================================ FILE: chapter13/1_type_systems/1_no_types/package.json ================================================ { "name": "1_no_types", "version": "1.0.0", "description": "", "main": "orderQueue.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.6.0" } } ================================================ FILE: chapter13/1_type_systems/2_with_types/orderQueue.js ================================================ "use strict"; exports.__esModule = true; exports.addToDeliveryQueue = exports.state = void 0; exports.state = { deliveries: [] }; var addToDeliveryQueue = function(order) { exports.state.deliveries.push(order); }; exports.addToDeliveryQueue = addToDeliveryQueue; ================================================ FILE: chapter13/1_type_systems/2_with_types/orderQueue.spec.js ================================================ "use strict"; exports.__esModule = true; var orderQueue_1 = require("./orderQueue"); test("adding finished items to the queue", function() { orderQueue_1.state.deliveries = []; var newOrder = { items: ["cheesecake"], status: "done" }; orderQueue_1.addToDeliveryQueue(newOrder); expect(orderQueue_1.state.deliveries).toEqual([newOrder]); }); ================================================ FILE: chapter13/1_type_systems/2_with_types/orderQueue.spec.ts ================================================ import { state, addToDeliveryQueue, DoneOrder } from "./orderQueue"; test("adding finished items to the queue", () => { state.deliveries = []; const newOrder: DoneOrder = { items: ["cheesecake"], status: "done" }; addToDeliveryQueue(newOrder); expect(state.deliveries).toEqual([newOrder]); }); ================================================ FILE: chapter13/1_type_systems/2_with_types/orderQueue.ts ================================================ type OrderItems = { 0: string } & Array; type Order = { status: "in progress" | "done"; items: OrderItems; }; export type DoneOrder = Order & { status: "done" }; export const state: { deliveries: Array } = { deliveries: [] }; export const addToDeliveryQueue = (order: DoneOrder) => { state.deliveries.push(order); }; ================================================ FILE: chapter13/1_type_systems/2_with_types/package.json ================================================ { "name": "1_no_types", "version": "1.0.0", "description": "", "main": "orderQueue.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@types/jest": "^26.0.15", "jest": "^26.6.0" }, "dependencies": { "typescript": "^4.1.2" } } ================================================ FILE: chapter13/1_type_systems/2_with_types/tsconfig.json ================================================ { "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ // "outDir": "./", /* Redirect output structure to the directory. */ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ /* Advanced Options */ "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ } } ================================================ FILE: chapter2/2_unit_tests/1_raw_tests/Cart.js ================================================ class Cart { constructor() { this.items = []; } addToCart(item) { this.items.push(item); } } module.exports = Cart; ================================================ FILE: chapter2/2_unit_tests/1_raw_tests/Cart.test.js ================================================ const Cart = require("./Cart.js"); const cart = new Cart(); cart.addToCart("cheesecake"); const hasOneItem = cart.items.length === 1; const hasACheesecake = cart.items[0] === "cheesecake"; if (hasOneItem && hasACheesecake) { console.log("The addToCart function can add an item to the cart"); } else { const actualContent = cart.items.join(", "); console.error("The addToCart function didn't do what we expect!"); console.error(`Here is the actual content of the cart: ${actualContent}`); throw new Error("Test failed!"); } ================================================ FILE: chapter2/2_unit_tests/2_node_assert/Cart.js ================================================ class Cart { constructor() { this.items = []; } addToCart(item) { this.items.push(item); } } module.exports = Cart; ================================================ FILE: chapter2/2_unit_tests/2_node_assert/Cart.test.js ================================================ const assert = require("assert"); const Cart = require("./Cart.js"); const cart = new Cart(); cart.addToCart("cheesecake"); assert.deepStrictEqual(cart.items, ["cheesecake"]); console.log("The addToCart function can add an item to the cart"); ================================================ FILE: chapter2/2_unit_tests/3_jest_multiple_tests/Cart.js ================================================ class Cart { constructor() { this.items = []; } addToCart(item) { this.items.push(item); } removeFromCart(item) { for (let i = 0; i < this.items.length; i++) { const currentItem = this.items[i]; if (currentItem === item) { this.items.splice(i, 1); } } } } module.exports = Cart; ================================================ FILE: chapter2/2_unit_tests/3_jest_multiple_tests/Cart.test.js ================================================ const assert = require("assert"); const Cart = require("./Cart.js"); test("The addToCart function can add an item to the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); assert.deepStrictEqual(cart.items, ["cheesecake"]); }); test("The addToCart function can add an item to the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); cart.removeFromCart("cheesecake"); assert.deepStrictEqual(cart.items, []); }); ================================================ FILE: chapter2/2_unit_tests/3_jest_multiple_tests/package.json ================================================ { "name": "3_jest_multiple_tests", "version": "1.0.0", "description": "", "main": "Cart.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" } ================================================ FILE: chapter2/2_unit_tests/4_jest_assertions/Cart.js ================================================ class Cart { constructor() { this.items = []; } addToCart(item) { this.items.push(item); } removeFromCart(item) { for (let i = 0; i < this.items.length; i++) { const currentItem = this.items[i]; if (currentItem === item) { this.items.splice(i, 1); } } } } module.exports = Cart; ================================================ FILE: chapter2/2_unit_tests/4_jest_assertions/Cart.test.js ================================================ const Cart = require("./Cart.js"); test("The addToCart function can add an item to the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); expect(cart.items).toEqual(["cheesecake"]); }); test("The removeFromCart function can remove an item from the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); cart.removeFromCart("cheesecake"); expect(cart.items).toEqual([]); }); ================================================ FILE: chapter2/2_unit_tests/4_jest_assertions/package.json ================================================ { "name": "4_jest_assertions", "version": "1.0.0", "description": "", "main": "Cart.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" } ================================================ FILE: chapter2/2_unit_tests/5_npm_scripts/Cart.js ================================================ class Cart { constructor() { this.items = []; } addToCart(item) { this.items.push(item); } removeFromCart(item) { for (let i = 0; i < this.items.length; i++) { const currentItem = this.items[i]; if (currentItem === item) { this.items.splice(i, 1); } } } } module.exports = Cart; ================================================ FILE: chapter2/2_unit_tests/5_npm_scripts/Cart.test.js ================================================ const Cart = require("./Cart.js"); test("The addToCart function can add an item to the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); expect(cart.items).toEqual(["cheesecake"]); }); test("The addToCart function can add an item to the cart", () => { const cart = new Cart(); cart.addToCart("cheesecake"); cart.removeFromCart("cheesecake"); expect(cart.items).toEqual([]); }); ================================================ FILE: chapter2/2_unit_tests/5_npm_scripts/package.json ================================================ { "name": "5_global_jest", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "jest": "^24.9.0" } } ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/cart.js ================================================ const { db } = require("./dbConnection"); const createCart = username => { return db("carts").insert({ username }); }; const addItem = (cartId, itemName) => { return db("carts_items").insert({ cartId, itemName }); }; module.exports = { createCart, addItem }; ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/cart.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const { createCart } = require("./cart"); test("createCart creates a cart for a username", async () => { await db("carts").truncate(); await createCart("Lucas da Costa"); const result = await db.select("username").from("carts"); expect(result).toEqual([{ username: "Lucas da Costa" }]); await closeConnection(); }); ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/dbConnection.js ================================================ const db = require("knex")(require("./knexfile").development); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/knexfile.js ================================================ module.exports = { development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/migrations/20191230210750_create_carts.js ================================================ exports.up = async knex => { await knex.schema.createTable("carts", table => { table.increments("id"); table.string("username"); }); await knex.schema.createTable("carts_items", table => { table.integer("cartId").references("carts.id"); table.string("itemName"); }); }; exports.down = async knex => { await knex.schema.dropTable("carts"); await knex.schema.dropTable("carts_items"); }; ================================================ FILE: chapter2/3_integration_tests/1_knex_tests_promise/package.json ================================================ { "name": "1_knex_tests_promise", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "knex": "^0.20.6", "sqlite3": "^4.1.1" }, "devDependencies": { "jest": "^24.9.0" } } ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/cart.js ================================================ const { db } = require("./dbConnection"); const createCart = username => { return db("carts").insert({ username }); }; const addItem = (cartId, itemName) => { return db("carts_items").insert({ cartId, itemName }); }; module.exports = { createCart, addItem }; ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/cart.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const { createCart } = require("./cart"); test("createCart creates a cart for a username", done => { db("carts") .truncate() .then(() => createCart("Lucas da Costa")) .then(() => db.select("username").from("carts")) .then(result => { expect(result).toEqual([{ username: "Lucas da Costa" }]); }) .then(closeConnection) .then(done); }); ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/dbConnection.js ================================================ const db = require("knex")(require("./knexfile").development); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/knexfile.js ================================================ module.exports = { development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/migrations/20191230210750_create_carts.js ================================================ exports.up = async knex => { await knex.schema.createTable("carts", table => { table.increments("id"); table.string("username"); }); await knex.schema.createTable("carts_items", table => { table.integer("cartId").references("carts.id"); table.string("itemName"); }); }; exports.down = async knex => { await knex.schema.dropTable("carts"); await knex.schema.dropTable("carts_items"); }; ================================================ FILE: chapter2/3_integration_tests/2_knex_tests_done_cb/package.json ================================================ { "name": "2_knex_tests_done_cb", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "knex": "^0.20.6", "sqlite3": "^4.1.1" }, "devDependencies": { "jest": "^24.9.0" } } ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/cart.js ================================================ const { db } = require("./dbConnection"); const createCart = username => { return db("carts").insert({ username }); }; const addItem = (cartId, itemName) => { return db("carts_items").insert({ cartId, itemName }); }; module.exports = { createCart, addItem }; ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/cart.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const { createCart, addItem } = require("./cart"); beforeEach(async () => { await db("carts_items").truncate(); await db("carts").truncate(); }); afterAll(async () => await closeConnection()); test("createCart creates a cart for a username", async () => { await createCart("Lucas da Costa"); const result = await db.select("username").from("carts"); expect(result).toEqual([{ username: "Lucas da Costa" }]); }); test("addItem adds an item to the cart", async () => { const username = "Lucas da Costa"; await createCart(username); const { id: cartId } = await db .select() .from("carts") .where({ username }); await addItem(cartId, "cheesecake"); const result = await db.select("itemName").from("carts_items"); expect(result).toEqual([{ cartId, itemName: "cheesecake" }]); }); ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/dbConnection.js ================================================ const db = require("knex")(require("./knexfile").development); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/knexfile.js ================================================ module.exports = { development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/migrations/20191230210750_create_carts.js ================================================ exports.up = async knex => { await knex.schema.createTable("carts", table => { table.increments("id"); table.string("username"); }); await knex.schema.createTable("carts_items", table => { table.integer("cartId").references("carts.id"); table.string("itemName"); }); }; exports.down = async knex => { await knex.schema.dropTable("carts"); await knex.schema.dropTable("carts_items"); }; ================================================ FILE: chapter2/3_integration_tests/3_knex_tests_hooks/package.json ================================================ { "name": "1_knex_tests_promise", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "knex": "^0.20.6", "sqlite3": "^4.1.1" }, "devDependencies": { "jest": "^24.9.0" } } ================================================ FILE: chapter2/4_end_to_end_tests/1_http_api_tests/package.json ================================================ { "name": "1_http_api_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter2/4_end_to_end_tests/1_http_api_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); const carts = new Map(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = app.listen(3000); ================================================ FILE: chapter2/4_end_to_end_tests/1_http_api_tests/server.test.js ================================================ const app = require("./server"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; const addItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "POST" }); }; const getItems = username => { return fetch(`${apiRoot}/carts/${username}/items`, { method: "GET" }); }; test("adding items to a cart", async () => { const initialItemsResponse = await getItems("lucas"); expect(initialItemsResponse.status).toBe(404); const addItemResponse = await addItem("lucas", "cheesecake"); expect(await addItemResponse.json()).toEqual(["cheesecake"]); const finalItemsResponse = await getItems("lucas"); expect(await finalItemsResponse.json()).toEqual(["cheesecake"]); }); afterAll(() => app.close()); ================================================ FILE: chapter2/4_end_to_end_tests/2_http_api_with_remove_item/package.json ================================================ { "name": "1_http_api_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^26.6.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter2/4_end_to_end_tests/2_http_api_with_remove_item/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); let carts = new Map(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); ctx.body = newItems; }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const newItems = (carts.get(username) || []).filter(i => i !== item); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); // This method is designed especifically for testability. // Because we keep `carts` in memory, we must reset it back // to its inital state by deleting all items in it. // If you were dealing with a database, you'd have to do // something similar in your tests by ensuring the database // is reset to its initial state before each test. const resetState = () => { carts = new Map(); }; module.exports = { app: app.listen(3000), resetState }; ================================================ FILE: chapter2/4_end_to_end_tests/2_http_api_with_remove_item/server.test.js ================================================ const { app, resetState } = require("./server"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; const addItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "POST" }); }; const removeItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "DELETE" }); }; const getItems = username => { return fetch(`${apiRoot}/carts/${username}/items`, { method: "GET" }); }; test("adding items to a cart", async () => { const initialItemsResponse = await getItems("lucas"); expect(initialItemsResponse.status).toBe(404); const addItemResponse = await addItem("lucas", "cheesecake"); expect(await addItemResponse.json()).toEqual(["cheesecake"]); const finalItemsResponse = await getItems("lucas"); expect(await finalItemsResponse.json()).toEqual(["cheesecake"]); }); test("removing items from a cart", async () => { const initialItemsResponse = await getItems("lucas"); expect(initialItemsResponse.status).toBe(404); await addItem("lucas", "cheesecake"); const removeItemsResponse = await removeItem("lucas", "cheesecake"); expect(await removeItemsResponse.json()).toEqual([]); const finalItemsResponse = await getItems("lucas"); expect(await finalItemsResponse.json()).toEqual([]); }); // We must clean-up our server's state before each test. // If you kept state in a database, you'd need to ensure // your database is reset to its initial state. beforeEach(() => resetState()); afterAll(() => app.close()); ================================================ FILE: chapter2/5_tests_cost_and_revenue/1_good_vs_bad/badly_written.test.js ================================================ const { app, resetState } = require("./server"); const fetch = require("isomorphic-fetch"); test("adding items to a cart", done => { resetState(); return fetch(`http://localhost:3000/carts/lucas/items`, { method: "GET" }) .then(initialItemsResponse => { expect(initialItemsResponse.status).toEqual(404); return fetch(`http://localhost:3000/carts/lucas/items/cheesecake`, { method: "POST" }).then(response => response.json()); }) .then(addItemResponse => { expect(addItemResponse).toEqual(["cheesecake"]); return fetch(`http://localhost:3000/carts/lucas/items`, { method: "GET" }).then(response => response.json()); }) .then(finalItemsResponse => { expect(finalItemsResponse).toEqual(["cheesecake"]); }) .then(() => { app.close(); done(); }); }); ================================================ FILE: chapter2/5_tests_cost_and_revenue/1_good_vs_bad/package.json ================================================ { "name": "1_good_vs_bad", "version": "1.0.0", "scripts": { "test-good": "jest well_written.test.js", "test-bad": "jest badly_written.test.js" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter2/5_tests_cost_and_revenue/1_good_vs_bad/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); let carts = new Map(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); ctx.body = newItems; }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const newItems = (carts.get(username) || []).filter(i => i !== item); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); // This method is designed especifically for testability. // Because we keep `carts` in memory, we must reset it back // to its inital state by deleting all items in it. // If you were dealing with a database, you'd have to do // something similar in your tests by ensuring the database // is reset to its initial state before each test. const resetState = () => { carts = new Map(); }; module.exports = { app: app.listen(3000), resetState }; ================================================ FILE: chapter2/5_tests_cost_and_revenue/1_good_vs_bad/well_written.test.js ================================================ const { app, resetState } = require("./server"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; const addItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "POST" }); }; const getItems = username => { return fetch(`${apiRoot}/carts/${username}/items`, { method: "GET" }); }; beforeEach(() => resetState()); afterAll(() => app.close()); test("adding items to a cart", async () => { const initialItemsResponse = await getItems("lucas"); expect(initialItemsResponse.status).toBe(404); const addItemResponse = await addItem("lucas", "cheesecake"); expect(await addItemResponse.json()).toEqual(["cheesecake"]); const finalItemsResponse = await getItems("lucas"); expect(await finalItemsResponse.json()).toEqual(["cheesecake"]); }); ================================================ FILE: chapter2/5_tests_cost_and_revenue/2_test_coupling/package.json ================================================ { "name": "2_test_coupling", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^24.9.0" } } ================================================ FILE: chapter2/5_tests_cost_and_revenue/2_test_coupling/pow.test.js ================================================ //const pow = require("./pow_recursive"); const pow = require("./pow_loop"); test("calculates powers", () => { expect(pow(2, 0)).toBe(1); expect(pow(2, -3)).toBe(0.125); expect(pow(2, 2)).toBe(4); expect(pow(2, 5)).toBe(32); expect(pow(0, 5)).toBe(0); expect(pow(1, 4)).toBe(1); }); ================================================ FILE: chapter2/5_tests_cost_and_revenue/2_test_coupling/pow_loop.js ================================================ const pow = (a, b) => { let result = 1; for (let i = 0; i < Math.abs(b); i++) { if (b < 0) result = result / a; if (b > 0) result = result * a; } return result; }; module.exports = pow; ================================================ FILE: chapter2/5_tests_cost_and_revenue/2_test_coupling/pow_recursive.js ================================================ const pow = (a, b, acc = 1) => { if (b === 0) return acc; const nextB = b < 0 ? b + 1 : b - 1; const nextAcc = b < 0 ? acc / a : acc * a; return pow(a, nextB, nextAcc); }; module.exports = pow; ================================================ FILE: chapter3/1_organising_test_suites/1_breaking_down_tests_big_tests/package.json ================================================ { "name": "1_breaking_down_tests_big_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter3/1_organising_test_suites/1_breaking_down_tests_big_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); const carts = new Map(); const inventory = new Map(); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!inventory.get(item)) { ctx.status = 404; return; } inventory.set(item, inventory.get(item) - 1); const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), inventory, carts }; ================================================ FILE: chapter3/1_organising_test_suites/1_breaking_down_tests_big_tests/server.test.js ================================================ const { app, inventory, carts } = require("./server"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; const addItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "POST" }); }; describe("addItem", () => { test("adding items to a cart", async () => { inventory.set("cheesecake", 1); const addItemResponse = await addItem("lucas", "cheesecake"); expect(await addItemResponse.json()).toEqual(["cheesecake"]); expect(inventory.get("cheesecake")).toBe(0); expect(carts.get("lucas")).toEqual(["cheesecake"]); const failedAddItem = await addItem("lucas", "cheesecake"); expect(failedAddItem.status).toBe(404); }); }); afterAll(() => app.close()); ================================================ FILE: chapter3/1_organising_test_suites/2_breaking_down_tests_small_tests/package.json ================================================ { "name": "2_breaking_down_tests_small_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter3/1_organising_test_suites/2_breaking_down_tests_small_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); const carts = new Map(); const inventory = new Map(); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!inventory.get(item)) { ctx.status = 404; return; } inventory.set(item, inventory.get(item) - 1); const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), inventory, carts }; ================================================ FILE: chapter3/1_organising_test_suites/2_breaking_down_tests_small_tests/server.test.js ================================================ const { app, inventory, carts } = require("./server"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; const addItem = (username, item) => { return fetch(`${apiRoot}/carts/${username}/items/${item}`, { method: "POST" }); }; describe("addItem", () => { beforeEach(() => carts.forEach((value, key) => carts.delete(key))); beforeEach(() => inventory.set("cheesecake", 1)); test("correct response", async () => { const addItemResponse = await addItem("lucas", "cheesecake"); expect(await addItemResponse.json()).toEqual(["cheesecake"]); }); test("inventory update", async () => { await addItem("lucas", "cheesecake"); expect(inventory.get("cheesecake")).toBe(0); }); test("cart update", async () => { await addItem("keith", "cheesecake"); expect(carts.get("keith")).toEqual(["cheesecake"]); }); test("soldout items", async () => { inventory.set("cheesecake", 0); const failedAddItem = await addItem("lucas", "cheesecake"); expect(failedAddItem.status).toBe(404); }); }); afterAll(() => app.close()); ================================================ FILE: chapter3/1_organising_test_suites/3_global_hooks/dummy.test.js ================================================ describe("placeholder tests", () => { test("placeholder 1", () => {}); test("placeholder 2", () => {}); test("placeholder 3", () => {}); }); ================================================ FILE: chapter3/1_organising_test_suites/3_global_hooks/globalSetup.js ================================================ const setup = () => { global._accessibleOnTeardown = "Look, I was set on the setup file"; console.log("\nsetup executed\n"); }; module.exports = setup; ================================================ FILE: chapter3/1_organising_test_suites/3_global_hooks/globalTeardown.js ================================================ const teardown = () => { console.log(`The value set on setup was: ${global._accessibleOnTeardown}`); console.log("teardown executed"); }; module.exports = teardown; ================================================ FILE: chapter3/1_organising_test_suites/3_global_hooks/jest.config.js ================================================ module.exports = { globalSetup: "./globalSetup.js", globalTeardown: "./globalTeardown.js", testEnvironment: "node" }; ================================================ FILE: chapter3/1_organising_test_suites/3_global_hooks/package.json ================================================ { "name": "1_organising_test_suites", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/1_assertion_checks/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; module.exports = { inventory, addToInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/1_assertion_checks/inventoryController.test.js ================================================ const { inventory, addToInventory } = require("./inventoryController"); beforeEach(() => inventory.set("cheesecake", 0)); test("cancels operation for invalid quantities", () => { expect.assertions(2); try { addToInventory("cheesecake", "not a number"); } catch (e) { expect(inventory.get("cheesecake")).toBe(0); } expect(Array.from(inventory.entries())).toHaveLength(1); }); ================================================ FILE: chapter3/2_writing_good_assertions/1_assertion_checks/package.json ================================================ { "name": "1_assertion_checks", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/2_assertion_checks_toThrow/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; module.exports = { inventory, addToInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/2_assertion_checks_toThrow/inventoryController.test.js ================================================ const { inventory, addToInventory } = require("./inventoryController"); beforeEach(() => inventory.set("cheesecake", 0)); test("cancels operation for invalid quantities", () => { expect(() => addToInventory("cheesecake", "not a number")).not.toThrow(); expect(inventory.get("cheesecake")).toBe(0); expect(Array.from(inventory.entries())).toHaveLength(1); }); ================================================ FILE: chapter3/2_writing_good_assertions/2_assertion_checks_toThrow/package.json ================================================ { "name": "1_assertion_checks", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/3_loose_assertions/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; module.exports = { inventory, addToInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/3_loose_assertions/inventoryController.test.js ================================================ const { inventory, addToInventory } = require("./inventoryController"); beforeEach(() => { inventory.forEach((value, key) => inventory.delete(key)); }); test("returned value", () => { const result = addToInventory("cheesecake", 2); expect(typeof result).toBe("number"); }); ================================================ FILE: chapter3/2_writing_good_assertions/3_loose_assertions/package.json ================================================ { "name": "2_loose_assertions", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/4_asymmetric_matchers/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); return { ...contents, generatedAt: new Date() }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/4_asymmetric_matchers/inventoryController.test.js ================================================ const { inventory, getInventory } = require("./inventoryController"); test("inventory contents", () => { inventory .set("cheesecake", 1) .set("macarroon", 3) .set("croissant", 3) .set("eclaire", 7); const result = getInventory(); expect(result).toEqual({ cheesecake: 1, macarroon: 3, croissant: 3, eclaire: 7, generatedAt: expect.any(Date) }); }); ================================================ FILE: chapter3/2_writing_good_assertions/4_asymmetric_matchers/package.json ================================================ { "name": "3_asymmetric_matchers", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/5_manual_assertions/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); // To make the tests in this folder pass, update this // line so that it doesn't set the new Date's year to 3000. return { ...contents, generatedAt: new Date(new Date().setYear(3000)) }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/5_manual_assertions/inventoryController.test.js ================================================ const { getInventory } = require("./inventoryController"); // This test _will_ fail. // Here I'm trying to demonstrate Jest's output // when an assertion like `.toBe(true)` fails. test("generatedAt in the past", () => { const result = getInventory(); const currentTime = Date.now() + 1; const isPastTimestamp = result.generatedAt.getTime() <= currentTime; expect(isPastTimestamp).toBe(true); }); ================================================ FILE: chapter3/2_writing_good_assertions/5_manual_assertions/package.json ================================================ { "name": "4_manual_assertions", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" } } ================================================ FILE: chapter3/2_writing_good_assertions/6_custom_matchers/inventoryController.js ================================================ const inventory = new Map(); const addToInventory = (item, n) => { if (typeof n !== "number") throw new Error("quantity must be a number"); const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + n; inventory.set(item, newQuantity); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); return { ...contents, generatedAt: new Date(new Date().setYear(3000)) }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/6_custom_matchers/inventoryController.test.js ================================================ const { getInventory } = require("./inventoryController"); test("generatedAt in the past", () => { const result = getInventory(); const currentTime = new Date(Date.now() + 1); expect(result.generatedAt).toBeBefore(currentTime); }); ================================================ FILE: chapter3/2_writing_good_assertions/6_custom_matchers/jest.config.js ================================================ module.exports = { testEnvironment: "node", setupFilesAfterEnv: ["jest-extended"] }; ================================================ FILE: chapter3/2_writing_good_assertions/6_custom_matchers/package.json ================================================ { "name": "5_custom_matchers", "version": "1.0.0", "description": "", "main": "jest.config.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0", "jest-extended": "^0.11.5" } } ================================================ FILE: chapter3/2_writing_good_assertions/7_circular_assertions/inventoryController.js ================================================ const inventory = new Map(); const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name]) => { return { ...contents, [name]: 1000 }; }, {}); return { ...contents, generatedAt: new Date() }; }; module.exports = { inventory, getInventory }; ================================================ FILE: chapter3/2_writing_good_assertions/7_circular_assertions/package.json ================================================ { "name": "6_circular_assertions", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^25.1.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^8.0.8" } } ================================================ FILE: chapter3/2_writing_good_assertions/7_circular_assertions/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const { getInventory } = require("./inventoryController"); const app = new Koa(); const router = new Router(); router.get("/inventory", ctx => (ctx.body = getInventory())); app.use(router.routes()); module.exports = app.listen(3000); ================================================ FILE: chapter3/2_writing_good_assertions/7_circular_assertions/server.test.js ================================================ const app = require("./server"); const fetch = require("isomorphic-fetch"); const { inventory, getInventory } = require("./inventoryController"); const apiRoot = "http://localhost:3000"; const sendGetInventoryRequest = () => { return fetch(`${apiRoot}/inventory`, { method: "GET" }); }; test("fetching inventory", async () => { inventory.set("cheesecake", 1).set("macarroon", 2); const getInventoryResponse = await sendGetInventoryRequest("lucas"); const expected = { ...getInventory(), generatedAt: expect.anything() }; expect(await getInventoryResponse.json()).toEqual(expected); }); afterAll(() => app.close()); ================================================ FILE: chapter3/3_mocks_stubs_and_spies/1_mocking_objects/inventoryController.js ================================================ const logger = require("./logger"); const inventory = new Map(); const addToInventory = (item, quantity) => { if (typeof quantity !== "number") { logger.logError( { quantity }, "could not add item to inventory because quantity was not a number" ); throw new Error("quantity must be a number"); } const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + quantity; inventory.set(item, newQuantity); logger.logInfo( { item, quantity, memoryUsage: process.memoryUsage().rss }, "item added to the inventory" ); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); logger.logInfo({ contents }, "inventory items fetched"); return { ...contents, generatedAt: new Date(new Date().setYear(3000)) }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/1_mocking_objects/inventoryController.test.js ================================================ const logger = require("./logger"); const { inventory, addToInventory, getInventory } = require("./inventoryController"); // Clearing the inventory before each test beforeEach(() => { inventory.forEach((value, key) => inventory.delete(key)); }); beforeAll(() => jest.spyOn(logger, "logInfo").mockImplementation(jest.fn())); beforeAll(() => jest.spyOn(logger, "logError").mockImplementation(jest.fn())); afterEach(() => jest.resetAllMocks()); describe("addToInventory", () => { beforeEach(() => { jest .spyOn(process, "memoryUsage") .mockReturnValue({ rss: 123456, heapTotal: 1, heapUsed: 2, external: 3 }); }); test("logging new items", () => { addToInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ item: "cheesecake", quantity: 2, memoryUsage: 123456 }); expect(secondArg).toEqual("item added to the inventory"); }); test("logging logErrors", () => { try { addToInventory("cheesecake", "not a number"); } catch (e) { // No-op } expect(logger.logError.mock.calls).toHaveLength(1); const firstCallArgs = logger.logError.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ quantity: "not a number" }); expect(secondArg).toEqual( "could not add item to inventory because quantity was not a number" ); }); }); describe("getInventory", () => { test("logging fetches", () => { inventory.set("cheesecake", 2); getInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ contents: { cheesecake: 2 } }); expect(secondArg).toEqual("inventory items fetched"); }); }); ================================================ FILE: chapter3/3_mocks_stubs_and_spies/1_mocking_objects/logger.js ================================================ const pino = require("pino"); const pinoInstance = pino(); const logger = { logInfo: pinoInstance.info.bind(pinoInstance), logError: pinoInstance.error.bind(pinoInstance) }; module.exports = logger; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/1_mocking_objects/package.json ================================================ { "name": "1_mocking_objects", "version": "1.0.0", "description": "", "main": "inventoryController.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" }, "dependencies": { "pino": "^5.16.0" } } ================================================ FILE: chapter3/3_mocks_stubs_and_spies/2_mocking_imports/inventoryController.js ================================================ const { logInfo, logError } = require("./logger"); const inventory = new Map(); const addToInventory = (item, quantity) => { if (typeof quantity !== "number") { logError( { quantity }, "could not add item to inventory because quantity was not a number" ); throw new Error("quantity must be a number"); } const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + quantity; inventory.set(item, newQuantity); logInfo( { item, quantity, memoryUsage: process.memoryUsage().rss }, "item added to the inventory" ); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); logInfo({ contents }, "inventory items fetched"); return { ...contents, generatedAt: new Date(new Date().setYear(3000)) }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/2_mocking_imports/inventoryController.test.js ================================================ const logger = require("./logger"); const { inventory, addToInventory, getInventory } = require("./inventoryController"); jest.mock("./logger", () => ({ logInfo: jest.fn(), logError: jest.fn() })); // Clearing the inventory before each test beforeEach(() => { inventory.forEach((value, key) => inventory.delete(key)); }); afterEach(() => jest.resetAllMocks()); describe("addToInventory", () => { beforeEach(() => { jest .spyOn(process, "memoryUsage") .mockReturnValue({ rss: 123456, heapTotal: 1, heapUsed: 2, external: 3 }); }); test("logging new items", () => { addToInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ item: "cheesecake", quantity: 2, memoryUsage: 123456 }); expect(secondArg).toEqual("item added to the inventory"); }); test("logging logErrors", () => { try { addToInventory("cheesecake", "not a number"); } catch (e) { // No-op } expect(logger.logError.mock.calls).toHaveLength(1); const firstCallArgs = logger.logError.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ quantity: "not a number" }); expect(secondArg).toEqual( "could not add item to inventory because quantity was not a number" ); }); }); describe("getInventory", () => { test("logging fetches", () => { inventory.set("cheesecake", 2); getInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ contents: { cheesecake: 2 } }); expect(secondArg).toEqual("inventory items fetched"); }); }); ================================================ FILE: chapter3/3_mocks_stubs_and_spies/2_mocking_imports/logger.js ================================================ const pino = require("pino"); const pinoInstance = pino(); const logger = { logInfo: pinoInstance.info.bind(pinoInstance), logError: pinoInstance.error.bind(pinoInstance) }; module.exports = logger; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/2_mocking_imports/package.json ================================================ { "name": "2_mocking_imports", "version": "1.0.0", "description": "", "main": "inventoryController.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" }, "dependencies": { "pino": "^5.16.0" } } ================================================ FILE: chapter3/3_mocks_stubs_and_spies/3_manual_mocks/__mocks__/logger.js ================================================ module.exports = { logInfo: jest.fn(), logError: jest.fn() }; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/3_manual_mocks/inventoryController.js ================================================ const { logInfo, logError } = require("./logger"); const inventory = new Map(); const addToInventory = (item, quantity) => { if (typeof quantity !== "number") { logError( { quantity }, "could not add item to inventory because quantity was not a number" ); throw new Error("quantity must be a number"); } const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + quantity; inventory.set(item, newQuantity); logInfo( { item, quantity, memoryUsage: process.memoryUsage().rss }, "item added to the inventory" ); return newQuantity; }; const getInventory = () => { const contentArray = Array.from(inventory.entries()); const contents = contentArray.reduce((contents, [name, quantity]) => { return { ...contents, [name]: quantity }; }, {}); logInfo({ contents }, "inventory items fetched"); return { ...contents, generatedAt: new Date(new Date().setYear(3000)) }; }; module.exports = { inventory, addToInventory, getInventory }; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/3_manual_mocks/inventoryController.test.js ================================================ const logger = require("./logger"); const { inventory, addToInventory, getInventory } = require("./inventoryController"); // Clearing the inventory before each test beforeEach(() => { inventory.forEach((value, key) => inventory.delete(key)); }); afterEach(() => jest.resetAllMocks()); jest.mock("./logger"); describe("addToInventory", () => { beforeEach(() => { jest .spyOn(process, "memoryUsage") .mockReturnValue({ rss: 123456, heapTotal: 1, heapUsed: 2, external: 3 }); }); test("logging new items", () => { addToInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ item: "cheesecake", quantity: 2, memoryUsage: 123456 }); expect(secondArg).toEqual("item added to the inventory"); }); test("logging logErrors", () => { try { addToInventory("cheesecake", "not a number"); } catch (e) { // No-op } expect(logger.logError.mock.calls).toHaveLength(1); const firstCallArgs = logger.logError.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ quantity: "not a number" }); expect(secondArg).toEqual( "could not add item to inventory because quantity was not a number" ); }); }); describe("getInventory", () => { test("logging fetches", () => { inventory.set("cheesecake", 2); getInventory("cheesecake", 2); expect(logger.logInfo.mock.calls).toHaveLength(1); const firstCallArgs = logger.logInfo.mock.calls[0]; const [firstArg, secondArg] = firstCallArgs; expect(firstArg).toEqual({ contents: { cheesecake: 2 } }); expect(secondArg).toEqual("inventory items fetched"); }); }); ================================================ FILE: chapter3/3_mocks_stubs_and_spies/3_manual_mocks/logger.js ================================================ const pino = require("pino"); const pinoInstance = pino(); const logger = { logInfo: pinoInstance.info.bind(pinoInstance), logError: pinoInstance.error.bind(pinoInstance) }; module.exports = logger; ================================================ FILE: chapter3/3_mocks_stubs_and_spies/3_manual_mocks/package.json ================================================ { "name": "3_manual_mocks", "version": "1.0.0", "description": "", "main": "inventoryController.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.6.0" }, "dependencies": { "pino": "^5.16.0" } } ================================================ FILE: chapter3/4_code_coverage/1_measuring_code_coverage/__mocks__/logger.js ================================================ module.exports = { logInfo: jest.fn(), logError: jest.fn() }; ================================================ FILE: chapter3/4_code_coverage/1_measuring_code_coverage/inventoryController.js ================================================ const { logInfo, logError } = require("./logger"); const inventory = new Map(); const addToInventory = (item, quantity) => { if (typeof quantity !== "number") { logError( { quantity }, "could not add item to inventory because quantity was not a number" ); throw new Error("quantity must be a number"); } const currentQuantity = inventory.get(item) || 0; const newQuantity = currentQuantity + quantity; inventory.set(item, newQuantity); logInfo( { item, quantity, memoryUsage: process.memoryUsage().rss }, "item added to the inventory" ); return newQuantity; }; module.exports = { inventory, addToInventory }; ================================================ FILE: chapter3/4_code_coverage/1_measuring_code_coverage/inventoryController.test.js ================================================ const { inventory, addToInventory } = require("./inventoryController"); jest.mock("./logger"); beforeEach(() => inventory.clear()); describe("addToInventory", () => { test("passing valid arguments", () => { addToInventory("cheesecake", 2); }); test("passing invalid arguments", () => { try { addToInventory("cheesecake", "should throw"); } catch (e) { // ... } }); }); ================================================ FILE: chapter3/4_code_coverage/1_measuring_code_coverage/logger.js ================================================ const pino = require("pino"); const pinoInstance = pino(); const logger = { logInfo: pinoInstance.info.bind(pinoInstance), logError: pinoInstance.error.bind(pinoInstance) }; module.exports = logger; ================================================ FILE: chapter3/4_code_coverage/1_measuring_code_coverage/package.json ================================================ { "name": "1_measuring_code_coverage", "version": "1.0.0", "description": "", "main": "inventoryController.js", "scripts": { "test": "jest", "coverage": "jest --coverage" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" }, "dependencies": { "pino": "^5.16.0" } } ================================================ FILE: chapter3/4_code_coverage/2_what_coverage_is_good_for/math.js ================================================ function sumOrDivide(a, b) { if (a % 2 === 0 && b % 2 === 0) { return a + b; } else { return a / b; } } ================================================ FILE: chapter3/4_code_coverage/2_what_coverage_is_good_for/math.test.js ================================================ test("sum", () => { sumOrDivide(2, 4); // WARNING: No assertions! }); test("multiply", () => { sumOrDivide(2, 6); // WARNING: No assertions! }); ================================================ FILE: chapter3/4_code_coverage/2_what_coverage_is_good_for/package.json ================================================ { "name": "2_what_coverage_is_good_for", "version": "1.0.0", "description": "", "main": "inventoryController.js", "scripts": { "test": "jest", "coverage": "jest --coverage" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.1.0" }, "dependencies": { "pino": "^5.16.0" } } ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/1_end_to_end_tests/package.json ================================================ { "name": "1_end_to_end_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/1_end_to_end_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const app = new Koa(); const router = new Router(); let carts = new Map(); let inventory = new Map(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; const isAvailable = inventory.has(item) && inventory.get(item) > 0; if (!isAvailable) { ctx.body = { message: `${item} is unavailable` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); inventory.set(item, inventory.get(item) - 1); ctx.body = newItems; }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!carts.has(username) || !carts.get(username).includes(item)) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).filter(i => i !== item); inventory.set(item, (inventory.get(item) || 0) + 1); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), carts, inventory }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/1_end_to_end_tests/server.test.js ================================================ const { app, carts, inventory } = require("./server.js"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; afterAll(() => app.close()); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("add items to a cart", () => { test("adding available items", async () => { inventory.set("cheesecake", 1); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual(["cheesecake"]); expect(inventory.get("cheesecake")).toEqual(0); expect(carts.get("test_user")).toEqual(["cheesecake"]); }); test("adding unavailable items", async () => { carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is unavailable" }); expect(carts.get("test_user")).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { carts.set("test_user", ["cheesecake"]); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual([]); expect(carts.get("test_user")).toEqual([]); expect(inventory.get("cheesecake")).toEqual(1); }); test("removing non-existing items", async () => { inventory.set("cheesecake", 0); carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is not in the cart" }); expect(inventory.get("cheesecake")).toEqual(0); }); }); ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/cartController.js ================================================ const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const carts = new Map(); const addItemToCart = (username, item) => { removeFromInventory(item); const newItems = (carts.get(username) || []).concat(item); carts.set(username, newItems); logger.log(`${item} added to ${username}'s cart`); return newItems; }; module.exports = { addItemToCart, carts }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/cartController.test.js ================================================ const { inventory } = require("./inventoryController"); const { carts, addItemToCart } = require("./cartController"); const fs = require("fs"); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", () => { carts.set("test_user", []); inventory.set("cheesecake", 0); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error(`cheesecake is unavailable`); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual([]); expect.assertions(2); }); test("logging added items", () => { carts.set("test_user", []); inventory.set("cheesecake", 1); addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/inventoryController.js ================================================ const inventory = new Map(); const removeFromInventory = item => { if (!inventory.has(item) || !inventory.get(item) > 0) { const err = new Error(`${item} is unavailable`); err.code = 400; throw err; } inventory.set(item, inventory.get(item) - 1); }; module.exports = { removeFromInventory, inventory }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/package.json ================================================ { "name": "1_integration-tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const { carts, addItemToCart } = require("./cartController"); const { inventory } = require("./inventoryController"); const app = new Koa(); const router = new Router(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { try { const { username, item } = ctx.params; const newItems = addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!carts.has(username) || !carts.get(username).includes(item)) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).filter(i => i !== item); inventory.set(item, (inventory.get(item) || 0) + 1); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), carts, inventory }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/2_integration_tests/server.test.js ================================================ const { app } = require("./server.js"); const { inventory } = require("./inventoryController.js"); const { carts } = require("./cartController.js"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; afterAll(() => app.close()); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("add items to a cart", () => { test("adding available items", async () => { inventory.set("cheesecake", 1); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual(["cheesecake"]); expect(inventory.get("cheesecake")).toEqual(0); expect(carts.get("test_user")).toEqual(["cheesecake"]); }); test("adding unavailable items", async () => { carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is unavailable" }); expect(carts.get("test_user")).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { carts.set("test_user", ["cheesecake"]); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual([]); expect(carts.get("test_user")).toEqual([]); expect(inventory.get("cheesecake")).toEqual(1); }); test("removing non-existing items", async () => { inventory.set("cheesecake", 0); carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is not in the cart" }); expect(inventory.get("cheesecake")).toEqual(0); }); }); ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/cartController.js ================================================ const { inventory, removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const carts = new Map(); const addItemToCart = (username, item) => { removeFromInventory(item); const newItems = (carts.get(username) || []).concat(item); if (!compliesToItemLimit(newItems)) { inventory.set(item, inventory.get(item) + 1); const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } carts.set(username, newItems); logger.log(`${item} added to ${username}'s cart`); return newItems; }; const compliesToItemLimit = cart => { const unitsPerItem = cart.reduce((itemMap, itemName) => { const quantity = (itemMap[itemName] || 0) + 1; return { ...itemMap, [itemName]: quantity }; }, {}); return Object.values(unitsPerItem).every(quantity => quantity <= 3); }; module.exports = { addItemToCart, carts, compliesToItemLimit }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/cartController.test.js ================================================ const { inventory } = require("./inventoryController"); const { carts, addItemToCart, compliesToItemLimit } = require("./cartController"); const fs = require("fs"); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", () => { carts.set("test_user", []); inventory.set("cheesecake", 0); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", () => { const initialCartContent = ["cheesecake", "cheesecake", "cheesecake"]; carts.set("test_user", initialCartContent); inventory.set("cheesecake", 1); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual(initialCartContent); expect(inventory.get('cheesecake')).toEqual(1); expect.assertions(3); }); test("logging added items", () => { carts.set("test_user", []); inventory.set("cheesecake", 1); addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); describe("compliesToItemLimit", () => { test("returns true for cards with no more than 3 items of a kind", () => { const cart = ["cheesecake", "cheesecake", "almond brownie", "apple pie"]; expect(compliesToItemLimit(cart)).toBe(true); }); test("returns false for cards with no more than 3 items of a kind", () => { const cart = [ "cheesecake", "cheesecake", "almond brownie", "cheesecake", "cheesecake" ]; expect(compliesToItemLimit(cart)).toBe(false); }); }); ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/inventoryController.js ================================================ const inventory = new Map(); const removeFromInventory = item => { if (!inventory.has(item) || !inventory.get(item) > 0) { const err = new Error(`${item} is unavailable`); err.code = 400; throw err; } inventory.set(item, inventory.get(item) - 1); }; module.exports = { removeFromInventory, inventory }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/package.json ================================================ { "name": "1_unit_tests", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0" }, "dependencies": { "koa": "^2.11.0", "koa-router": "^7.4.0" } } ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const { carts, addItemToCart } = require("./cartController"); const { inventory } = require("./inventoryController"); const app = new Koa(); const router = new Router(); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items/:item", ctx => { try { const { username, item } = ctx.params; const newItems = addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!carts.has(username) || !carts.get(username).includes(item)) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).filter(i => i !== item); inventory.set(item, (inventory.get(item) || 0) + 1); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), carts, inventory }; ================================================ FILE: chapter4/1_setting_up_a_test_environment/1_exposing_modules/3_unit_tests/server.test.js ================================================ const { app } = require("./server.js"); const { inventory } = require("./inventoryController.js"); const { carts } = require("./cartController.js"); const fetch = require("isomorphic-fetch"); const apiRoot = "http://localhost:3000"; afterAll(() => app.close()); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("add items to a cart", () => { test("adding available items", async () => { inventory.set("cheesecake", 1); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual(["cheesecake"]); expect(inventory.get("cheesecake")).toEqual(0); expect(carts.get("test_user")).toEqual(["cheesecake"]); }); test("adding unavailable items", async () => { carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "POST" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is unavailable" }); expect(carts.get("test_user")).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { carts.set("test_user", ["cheesecake"]); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(200); expect(await response.json()).toEqual([]); expect(carts.get("test_user")).toEqual([]); expect(inventory.get("cheesecake")).toEqual(1); }); test("removing non-existing items", async () => { inventory.set("cheesecake", 0); carts.set("test_user", []); const response = await fetch( `${apiRoot}/carts/test_user/items/cheesecake`, { method: "DELETE" } ); expect(response.status).toEqual(400); expect(await response.json()).toEqual({ message: "cheesecake is not in the cart" }); expect(inventory.get("cheesecake")).toEqual(0); }); }); ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/cartController.js ================================================ const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const carts = new Map(); const addItemToCart = (username, item) => { removeFromInventory(item); const newItems = (carts.get(username) || []).concat(item); if (!compliesToItemLimit(newItems)) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } carts.set(username, newItems); logger.log(`${item} added to ${username}'s cart`); return newItems; }; const compliesToItemLimit = cart => { const unitsPerItem = cart.reduce((itemMap, itemName) => { const quantity = (itemMap[itemName] || 0) + 1; return { ...itemMap, [itemName]: quantity }; }, {}); return Object.values(unitsPerItem).every(quantity => quantity <= 3); }; module.exports = { addItemToCart, carts, compliesToItemLimit }; ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/cartController.test.js ================================================ const { inventory } = require("./inventoryController"); const { carts, addItemToCart, compliesToItemLimit } = require("./cartController"); const fs = require("fs"); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", () => { carts.set("test_user", []); inventory.set("cheesecake", 0); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", () => { const initialCartContent = ["cheesecake", "cheesecake", "cheesecake"]; carts.set("test_user", initialCartContent); inventory.set("cheesecake", 1); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual(initialCartContent); expect.assertions(2); }); test("logging added items", () => { carts.set("test_user", []); inventory.set("cheesecake", 1); addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); describe("compliesToItemLimit", () => { test("returns true for cards with no more than 3 items of a kind", () => { const cart = ["cheesecake", "cheesecake", "almond brownie", "apple pie"]; expect(compliesToItemLimit(cart)).toBe(true); }); test("returns true for cards with no more than 3 items of a kind", () => { const cart = [ "cheesecake", "cheesecake", "almond brownie", "cheesecake", "cheesecake" ]; expect(compliesToItemLimit(cart)).toBe(false); }); }); ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/inventoryController.js ================================================ const inventory = new Map(); const removeFromInventory = item => { if (!inventory.has(item) || !inventory.get(item) > 0) { const err = new Error(`${item} is unavailable`); err.code = 400; throw err; } inventory.set(item, inventory.get(item) - 1); }; module.exports = { removeFromInventory, inventory }; ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/package.json ================================================ { "name": "2_using_supertest", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0" } } ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { carts, addItemToCart } = require("./cartController"); const { inventory } = require("./inventoryController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); router.get("/carts/:username/items", ctx => { const cart = carts.get(ctx.params.username); cart ? (ctx.body = cart) : (ctx.status = 404); }); router.post("/carts/:username/items", ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!carts.has(username) || !carts.get(username).includes(item)) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).filter(i => i !== item); inventory.set(item, (inventory.get(item) || 0) + 1); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), carts, inventory }; ================================================ FILE: chapter4/2_testing_http_endpoints/1_using_supertest/server.test.js ================================================ const request = require("supertest"); const { app } = require("./server.js"); const { inventory } = require("./inventoryController.js"); const { carts } = require("./cartController.js"); afterAll(() => app.close()); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("add items to a cart", () => { test("adding available items", async () => { inventory.set("cheesecake", 3); const response = await request(app) .post("/carts/test_user/items") .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = ["cheesecake", "cheesecake", "cheesecake"]; expect(response.body).toEqual(newItems); expect(inventory.get("cheesecake")).toEqual(0); expect(carts.get("test_user")).toEqual(newItems); }); test("adding unavailable items", async () => { carts.set("test_user", []); const response = await request(app) .post("/carts/test_user/items") .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); expect(carts.get("test_user")).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { carts.set("test_user", ["cheesecake"]); const response = await request(app) .del("/carts/test_user/items/cheesecake") .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual([]); expect(carts.get("test_user")).toEqual([]); expect(inventory.get("cheesecake")).toEqual(1); }); test("removing non-existing items", async () => { inventory.set("cheesecake", 0); carts.set("test_user", []); const response = await request(app) .del("/carts/test_user/items/cheesecake") .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); expect(inventory.get("cheesecake")).toEqual(0); }); }); ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/authenticationController.js ================================================ const crypto = require("crypto"); const users = new Map(); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = (username, password) => { const userExists = users.has(username); if (!userExists) return false; const currentPasswordHash = users.get(username).passwordHash; return hashPassword(password) === currentPasswordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); if (!credentialsAreValid(username, password)) { throw new Error("invalid credentials"); } } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { users, hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/authenticationController.test.js ================================================ const crypto = require("crypto"); const { users, hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); afterEach(() => users.clear()); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", () => { users.set("test_user", { email: "test_user@example.org", passwordHash: hashPassword("a_password") }); expect(credentialsAreValid("test_user", "a_password")).toBe(true); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { users.set("test_user", { email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const validAuth = Buffer.from("test_user:a_password").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${validAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/cartController.js ================================================ const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const carts = new Map(); const addItemToCart = (username, item) => { removeFromInventory(item); const newItems = (carts.get(username) || []).concat(item); if (!compliesToItemLimit(newItems)) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } carts.set(username, newItems); logger.log(`${item} added to ${username}'s cart`); return newItems; }; const compliesToItemLimit = cart => { const unitsPerItem = cart.reduce((itemMap, itemName) => { const quantity = (itemMap[itemName] || 0) + 1; return { ...itemMap, [itemName]: quantity }; }, {}); return Object.values(unitsPerItem).every(quantity => quantity <= 3); }; module.exports = { addItemToCart, carts, compliesToItemLimit }; ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/cartController.test.js ================================================ const { inventory } = require("./inventoryController"); const { carts, addItemToCart, compliesToItemLimit } = require("./cartController"); const fs = require("fs"); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", () => { carts.set("test_user", []); inventory.set("cheesecake", 0); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", () => { const initialCartContent = ["cheesecake", "cheesecake", "cheesecake"]; carts.set("test_user", initialCartContent); inventory.set("cheesecake", 1); try { addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } expect(carts.get("test_user")).toEqual(initialCartContent); expect.assertions(2); }); test("logging added items", () => { carts.set("test_user", []); inventory.set("cheesecake", 1); addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); describe("compliesToItemLimit", () => { test("returns true for cards with no more than 3 items of a kind", () => { const cart = ["cheesecake", "cheesecake", "almond brownie", "apple pie"]; expect(compliesToItemLimit(cart)).toBe(true); }); test("returns true for cards with no more than 3 items of a kind", () => { const cart = [ "cheesecake", "cheesecake", "almond brownie", "cheesecake", "cheesecake" ]; expect(compliesToItemLimit(cart)).toBe(false); }); }); ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/inventoryController.js ================================================ const inventory = new Map(); const removeFromInventory = item => { if (!inventory.has(item) || !inventory.get(item) > 0) { const err = new Error(`${item} is unavailable`); err.code = 400; throw err; } inventory.set(item, inventory.get(item) - 1); }; module.exports = { removeFromInventory, inventory }; ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/package.json ================================================ { "name": "2_testing_middlewares", "version": "1.0.0", "scripts": { "test": "jest" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0" } } ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { carts, addItemToCart } = require("./cartController"); const { inventory } = require("./inventoryController"); const { users, hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = users.has(username); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } users.set(username, { email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", ctx => { const { username, item } = ctx.params; if (!carts.has(username) || !carts.get(username).includes(item)) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } const newItems = (carts.get(username) || []).filter(i => i !== item); inventory.set(item, (inventory.get(item) || 0) + 1); carts.set(username, newItems); ctx.body = newItems; }); app.use(router.routes()); module.exports = { app: app.listen(3000), carts, inventory }; ================================================ FILE: chapter4/2_testing_http_endpoints/2_testing_middlewares/server.test.js ================================================ const request = require("supertest"); const { app } = require("./server.js"); const { inventory } = require("./inventoryController.js"); const { carts } = require("./cartController.js"); const { users, hashPassword } = require("./authenticationController.js"); afterAll(() => app.close()); afterEach(() => inventory.clear()); afterEach(() => carts.clear()); afterEach(() => users.clear()); const user = "test_user"; const password = "a_password"; const validAuth = Buffer.from(`${user}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const createUser = () => { users.set(user, { email: "test_user@example.org", passwordHash: hashPassword(password) }); }; describe("add items to a cart", () => { beforeEach(createUser); test("adding available items", async () => { inventory.set("cheesecake", 3); const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = ["cheesecake", "cheesecake", "cheesecake"]; expect(response.body).toEqual(newItems); expect(inventory.get("cheesecake")).toEqual(0); expect(carts.get("test_user")).toEqual(newItems); }); test("adding unavailable items", async () => { carts.set("test_user", []); const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); expect(carts.get("test_user")).toEqual([]); }); }); describe("removing items from a cart", () => { beforeEach(createUser); test("removing existing items", async () => { carts.set("test_user", ["cheesecake"]); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual([]); expect(carts.get("test_user")).toEqual([]); expect(inventory.get("cheesecake")).toEqual(1); }); test("removing non-existing items", async () => { inventory.set("cheesecake", 0); carts.set("test_user", []); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); expect(inventory.get("cheesecake")).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user created successfully" }); expect(users.get("test_user")).toEqual({ email: "test_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { users.set("test_user", { email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user already exists" }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/authenticationController.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); beforeEach(() => db("users").truncate()); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); expect(await credentialsAreValid("test_user", "a_password")).toBe(true); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const validAuth = Buffer.from("test_user:a_password").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${validAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/cartController.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const fs = require("fs"); beforeEach(() => db("users").truncate()); beforeEach(() => db("carts_items").truncate()); beforeEach(() => db("inventory").truncate()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const { id: userId } = await db .select() .from("users") .where({ username: "test_user" }) .first(); await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const { id: userId } = await db .select() .from("users") .where({ username: "test_user" }) .first(); await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 1 }); await addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/dbConnection.js ================================================ const knex = require("knex"); const knexConfig = require("./knexfile").development; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/knexfile.js ================================================ module.exports = { development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/package.json ================================================ { "name": "1_database_integrations", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/1_database_integrations/server.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); afterAll(() => app.close()); beforeEach(() => db("users").truncate()); beforeEach(() => db("carts_items").truncate()); beforeEach(() => db("inventory").truncate()); const username = "test_user"; const password = "a_password"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const createUser = async () => { return await db("users").insert({ username, email: "test_user@example.org", passwordHash: hashPassword(password) }); }; describe("add items to a cart", () => { beforeEach(createUser); test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { let userId; beforeEach(async () => { userId = (await createUser())[0]; }); test("removing existing items", async () => { await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "test_user" }) .first(); expect(savedUser).toEqual({ email: "test_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { await createUser(); const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user already exists" }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/authenticationController.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); beforeEach(() => db("users").truncate()); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); expect(await credentialsAreValid("test_user", "a_password")).toBe(true); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const validAuth = Buffer.from("test_user:a_password").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${validAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/cartController.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const fs = require("fs"); beforeEach(() => db("users").truncate()); beforeEach(() => db("carts_items").truncate()); beforeEach(() => db("inventory").truncate()); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const { id: userId } = await db .select() .from("users") .where({ username: "test_user" }) .first(); await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart("test_user", "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("users").insert({ username: "test_user", email: "test_user@example.org", passwordHash: hashPassword("a_password") }); const { id: userId } = await db .select() .from("users") .where({ username: "test_user" }) .first(); await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 1 }); await addItemToCart("test_user", "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain("cheesecake added to test_user's cart\n"); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/dbConnection.js ================================================ const knex = require("knex"); const knexConfig = require("./knexfile").development; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/package.json ================================================ { "name": "2_separate_database_instances", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/2_separate_database_instances/server.test.js ================================================ const { db, closeConnection } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); afterAll(() => app.close()); beforeEach(() => db("users").truncate()); beforeEach(() => db("carts_items").truncate()); beforeEach(() => db("inventory").truncate()); const username = "test_user"; const password = "a_password"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const createUser = async () => { return await db("users").insert({ username, email: "test_user@example.org", passwordHash: hashPassword(password) }); }; describe("add items to a cart", () => { beforeEach(createUser); test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post("/carts/test_user/items") .set("authorization", authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { let userId; beforeEach(async () => { userId = (await createUser())[0]; }); test("removing existing items", async () => { await db("carts_items").insert({ userId, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", "test_user"); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del("/carts/test_user/items/cheesecake") .set("authorization", authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "test_user" }) .first(); expect(savedUser).toEqual({ email: "test_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { await createUser(); const response = await request(app) .put("/users/test_user") .send({ email: "test_user@example.org", password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "test_user already exists" }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const knex = require("knex"); const knexConfig = require("./knexfile")[environmentName]; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/package.json ================================================ { "name": "3_maintaining_a_pristine_state", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/server.js ================================================ const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/3_maitaining_a_pristine_state/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/authenticationController.js ================================================ const { user: globalUser } = require("./userTestUtils"); const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const knex = require("knex"); const knexConfig = require("./knexfile")[environmentName]; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/package.json ================================================ { "name": "4_integrations_with_other_apis", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/server.test.js ================================================ const fetch = require("isomorphic-fetch"); const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("can fetch an item from the inventory", async () => { const thirdPartyResponse = await fetch("http://recipepuppy.com/api?i=eggs"); const { title, href, results: recipes } = await thirdPartyResponse.json(); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${title} - ${href}`, recipes }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/4_integrations_with_other_apis/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/authenticationController.js ================================================ const { user: globalUser } = require("./userTestUtils"); const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const knex = require("knex"); const knexConfig = require("./knexfile")[environmentName]; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/package.json ================================================ { "name": "5_using_mocks_to_avoid_requests", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "jest-when": "^2.7.0", "supertest": "^4.0.2" }, "dependencies": { "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/server.test.js ================================================ const fetch = require("isomorphic-fetch"); const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const { when } = require("jest-when"); afterAll(() => app.close()); jest.mock("isomorphic-fetch"); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("can fetch an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; fetch.mockRejectedValue("Not used as expected!"); when(fetch) .calledWith("http://recipepuppy.com/api?i=eggs") .mockResolvedValue({ json: jest.fn().mockResolvedValue(eggsResponse) }); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/5_using_mocks_to_avoid_requests/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1 }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; module.exports = { addItemToCart }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const knex = require("knex"); const knexConfig = require("./knexfile")[environmentName]; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/package.json ================================================ { "name": "4_integrations_with_other_apis", "version": "1.0.0", "scripts": { "test": "jest --runInBand" }, "devDependencies": { "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "nock": "^12.0.3", "sqlite3": "^4.1.1" } } ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); beforeEach(() => { nock.cleanAll(); }); afterEach(() => { if (!nock.isDone()) { throw new Error("Not all mocked endpoints received requests."); } }); test("can fetch an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter4/3_dealing_with_external_dependencies/6_using_nock_to_avoid_requests/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter5/1_eliminating_non_determinism/1_shared_resources/countModule.js ================================================ const fs = require("fs"); const filepath = "./state.txt"; const getState = () => parseInt(fs.readFileSync(filepath), 10); const setState = n => fs.writeFileSync(filepath, n); const increment = () => fs.writeFileSync(filepath, getState() + 1); const decrement = () => fs.writeFileSync(filepath, getState() - 1); module.exports = { getState, setState, increment, decrement }; ================================================ FILE: chapter5/1_eliminating_non_determinism/1_shared_resources/decrement.test.js ================================================ const { getState, setState, decrement } = require("./countModule"); test("decrementing the state 10 times", () => { setState(0); for (let i = 0; i < 10; i++) { decrement(); } expect(getState()).toBe(-10); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/1_shared_resources/increment.test.js ================================================ const { getState, setState, increment } = require("./countModule"); test("incrementing the state 10 times", () => { setState(0); for (let i = 0; i < 10; i++) { increment(); } expect(getState()).toBe(10); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/1_shared_resources/package.json ================================================ { "name": "1_shared_resources", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.6.0" } } ================================================ FILE: chapter5/1_eliminating_non_determinism/2_resource_pools/countModule.js ================================================ const fs = require("fs"); const init = filepath => { const getState = () => { return parseInt(fs.readFileSync(filepath, "utf-8"), 10); }; const setState = n => fs.writeFileSync(filepath, n); const increment = () => fs.writeFileSync(filepath, getState() + 1); const decrement = () => fs.writeFileSync(filepath, getState() - 1); return { getState, setState, increment, decrement }; }; module.exports = { init }; ================================================ FILE: chapter5/1_eliminating_non_determinism/2_resource_pools/decrement.test.js ================================================ const pool = require("./instancePool"); const instance = pool.getInstance(process.env.JEST_WORKER_ID); const { setState, getState, decrement } = instance; test("decrementing the state 10 times", () => { setState(0); for (let i = 0; i < 10; i++) { decrement(); } expect(getState()).toBe(-10); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/2_resource_pools/increment.test.js ================================================ const pool = require("./instancePool"); const instance = pool.getInstance(process.env.JEST_WORKER_ID); const { setState, getState, increment } = instance; test("incrementing the state 10 times", () => { setState(0); for (let i = 0; i < 10; i++) { increment(); } expect(getState()).toBe(10); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/2_resource_pools/instancePool.js ================================================ const { init } = require("./countModule"); const instancePool = {}; const getInstance = workerId => { if (!instancePool[workerId]) { instancePool[workerId] = init(`/tmp/test_state_${workerId}.txt`); } return instancePool[workerId]; }; module.exports = { getInstance }; ================================================ FILE: chapter5/1_eliminating_non_determinism/2_resource_pools/package.json ================================================ { "name": "2_resource_pools", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.2.3" } } ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const knex = require("knex"); const knexConfig = require("./knexfile")[environmentName]; const db = knex(knexConfig); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/package.json ================================================ { "name": "3_dealing_with_time", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "node server.js" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^26.6.0", "supertest": "^4.0.2" }, "dependencies": { "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "nock": "^12.0.3", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const app = new Koa(); const router = new Router(); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(3000) }; ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("can fetch an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter5/1_eliminating_non_determinism/3_dealing_with_time/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter6/1_introducing_jsdom/1_pure_html/index.html ================================================ Inventory Manager

Cheesecakes: 0

================================================ FILE: chapter6/1_introducing_jsdom/1_pure_html/main.js ================================================ let data = { cheesecakes: 0 }; const incrementCount = () => { data.cheesecakes++; window.document.getElementById("count").innerText = data.cheesecakes; }; const incrementButton = window.document.getElementById("increment-button"); incrementButton.addEventListener("click", incrementCount); ================================================ FILE: chapter6/1_introducing_jsdom/2_jsdom/example.js ================================================ const page = require("./page"); console.log("Initial page body:"); console.log(page.window.document.body.innerHTML); console.log("Initial contents of the count element:"); console.log(page.window.document.getElementById("count").innerHTML); // Changing the count element's content page.window.document.getElementById("count").innerHTML = 1337; console.log("Updated contents of the count element:"); console.log(page.window.document.getElementById("count").innerHTML); // Appending a paragraph to the page const paragraph = page.window.document.createElement("p"); paragraph.innerText = "Look, I'm a new paragraph"; page.window.document.body.appendChild(paragraph); console.log("Final page body:"); console.log(page.window.document.body.innerHTML); ================================================ FILE: chapter6/1_introducing_jsdom/2_jsdom/index.html ================================================ Inventory Manager

Cheesecakes: 0

================================================ FILE: chapter6/1_introducing_jsdom/2_jsdom/main.js ================================================ let data = { cheesecakes: 0 }; const incrementCount = () => { data.cheesecakes++; window.document.getElementById("count").innerText = data.cheesecakes; }; const incrementButton = window.document.getElementById("increment-button"); incrementButton.addEventListener("click", incrementCount); ================================================ FILE: chapter6/1_introducing_jsdom/2_jsdom/package.json ================================================ { "name": "2_jsdom", "version": "1.0.0", "description": "", "main": "main.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "jsdom": "^16.2.2" } } ================================================ FILE: chapter6/1_introducing_jsdom/2_jsdom/page.js ================================================ const fs = require("fs"); const { JSDOM } = require("jsdom"); const html = fs.readFileSync("./index.html"); const page = new JSDOM(html); module.exports = page; ================================================ FILE: chapter6/1_introducing_jsdom/3_jest_jsdom/index.html ================================================ Inventory Manager

Cheesecakes: 0

================================================ FILE: chapter6/1_introducing_jsdom/3_jest_jsdom/jest.config.js ================================================ module.exports = { testEnvironment: "jsdom" }; ================================================ FILE: chapter6/1_introducing_jsdom/3_jest_jsdom/main.js ================================================ let data = { cheesecakes: 0 }; const incrementCount = () => { data.cheesecakes++; window.document.getElementById("count").innerText = data.cheesecakes; }; const incrementButton = window.document.getElementById("increment-button"); incrementButton.addEventListener("click", incrementCount); module.exports = { incrementCount, data }; ================================================ FILE: chapter6/1_introducing_jsdom/3_jest_jsdom/main.test.js ================================================ const fs = require("fs"); window.document.body.innerHTML = fs.readFileSync("./index.html"); const { incrementCount, data } = require("./main"); describe("incrementCount", () => { test("incrementing the count", () => { data.cheesecakes = 0; incrementCount(); expect(data.cheesecakes).toBe(1); }); }); ================================================ FILE: chapter6/1_introducing_jsdom/3_jest_jsdom/package.json ================================================ { "name": "3_jest_jsdom", "version": "1.0.0", "description": "", "main": "main.js", "scripts": { "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": {}, "devDependencies": { "browserify": "^16.5.1", "jest": "^26.6.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/domController.js ================================================ const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; inventoryList.appendChild(listItem); }); }; module.exports = { updateItemList }; ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/domController.test.js ================================================ const fs = require("fs"); document.body.innerHTML = fs.readFileSync("./index.html"); const { updateItemList } = require("./domController"); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.querySelector("body > ul"); expect(itemList.childNodes).toHaveLength(3); // The `childNodes` property has a `length`, but it's _not_ an Array const nodesText = Array.from(itemList.childNodes).map( node => node.innerHTML ); expect(nodesText).toContain("cheesecake - Quantity: 5"); expect(nodesText).toContain("apple pie - Quantity: 2"); expect(nodesText).toContain("carrot cake - Quantity: 6"); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/index.html ================================================ Inventory Manager

Inventory Contents

    ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/main.js ================================================ const { addItem, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); addItem("cheesecake", 3); addItem("apple pie", 8); addItem("carrot cake", 7); updateItemList(data.inventory); ================================================ FILE: chapter6/2_asserting_on_the_dom/1_finding_elements_by_dom_structure/package.json ================================================ { "name": "1_finding_elements_by_dom_structure", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/domController.js ================================================ const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; inventoryList.appendChild(listItem); }); }; module.exports = { updateItemList }; ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/domController.test.js ================================================ const fs = require("fs"); document.body.innerHTML = fs.readFileSync("./index.html"); const { updateItemList } = require("./domController"); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); // The `childNodes` property has a `length`, but it's _not_ an Array const nodesText = Array.from(itemList.childNodes).map( node => node.innerHTML ); expect(nodesText).toContain("cheesecake - Quantity: 5"); expect(nodesText).toContain("apple pie - Quantity: 2"); expect(nodesText).toContain("carrot cake - Quantity: 6"); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/index.html ================================================ Inventory Manager

    Inventory Contents

      ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/main.js ================================================ const { addItem, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); addItem("cheesecake", 3); addItem("apple pie", 8); addItem("carrot cake", 7); updateItemList(data.inventory); ================================================ FILE: chapter6/2_asserting_on_the_dom/2_finding_elements_by_id/package.json ================================================ { "name": "2_finding_elements_by_id", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/domController.js ================================================ const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; module.exports = { updateItemList }; ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { updateItemList } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); // The `childNodes` property has a `length`, but it's _not_ an Array const nodesText = Array.from(itemList.childNodes).map( node => node.innerHTML ); expect(nodesText).toContain("cheesecake - Quantity: 5"); expect(nodesText).toContain("apple pie - Quantity: 2"); expect(nodesText).toContain("carrot cake - Quantity: 6"); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); const paragraphs = Array.from(document.querySelectorAll("p")); const updateParagraphs = paragraphs.filter(p => { return p.innerHTML.includes("The inventory has been updated"); }); expect(updateParagraphs).toHaveLength(1); expect(updateParagraphs[0].innerHTML).toBe( `The inventory has been updated - ${JSON.stringify(inventory)}` ); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/index.html ================================================ Inventory Manager

      Inventory Contents

        ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/main.js ================================================ const { addItem, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); addItem("cheesecake", 3); addItem("apple pie", 8); addItem("carrot cake", 7); updateItemList(data.inventory); ================================================ FILE: chapter6/2_asserting_on_the_dom/3_robust_element_queries/package.json ================================================ { "name": "3_robust_element_queries", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/domController.js ================================================ const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; module.exports = { updateItemList }; ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeTruthy(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeTruthy(); expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeTruthy(); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/index.html ================================================ Inventory Manager

        Inventory Contents

          ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/main.js ================================================ const { addItem, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); addItem("cheesecake", 3); addItem("apple pie", 8); addItem("carrot cake", 7); updateItemList(data.inventory); ================================================ FILE: chapter6/2_asserting_on_the_dom/4_finding_with_dom_testing_library/package.json ================================================ { "name": "4_finding_with_dom_testing_library", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^26.6.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/domController.js ================================================ const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; module.exports = { updateItemList }; ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/index.html ================================================ Inventory Manager

          Inventory Contents

            ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/main.js ================================================ const { addItem, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); addItem("cheesecake", 3); addItem("apple pie", 8); addItem("carrot cake", 7); updateItemList(data.inventory); ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/package.json ================================================ { "name": "5_writing_better_dom_assertions", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/2_asserting_on_the_dom/5_writing_better_dom_assertions/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const handleItemName = event => { const itemName = event.target.value; const errorMsg = window.document.getElementById("error-msg"); if (itemName === "") { errorMsg.innerHTML = ""; } else if (!validItems.includes(itemName)) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } }; module.exports = { updateItemList, handleAddItem, handleItemName }; ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, handleItemName } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); }); describe("handleAddItem", () => { test("adding items to the page", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("handleItemName", () => { test("entering valid item names", () => { const event = { preventDefault: jest.fn(), target: { value: "cheesecake" } }; handleItemName(event); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names", () => { const event = { preventDefault: jest.fn(), target: { value: "book" } }; handleItemName(event); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/index.html ================================================ Inventory Manager

            Inventory Contents

              ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return data.inventory; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/main.js ================================================ const { handleAddItem, handleItemName } = require("./domController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); const itemInput = document.querySelector(`input[name="name"]`); itemInput.addEventListener("input", handleItemName); ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/main.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText } = require("@testing-library/dom"); beforeEach(() => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); require("./main"); }); test("adding items through the form", () => { screen.getByPlaceholderText("Item name").value = "cheesecake"; screen.getByPlaceholderText("Quantity").value = "6"; const event = new Event("submit"); const form = document.getElementById("add-item-form"); form.dispatchEvent(event); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); itemField.value = "cheesecake"; const inputEvent = new Event("input"); itemField.dispatchEvent(inputEvent); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); itemField.value = "book"; const inputEvent = new Event("input"); itemField.dispatchEvent(inputEvent); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/package.json ================================================ { "name": "1_handling_raw_events", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/3_handling_events/1_handling_raw_events/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; module.exports = { updateItemList, handleAddItem, checkFormValues }; ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); }); describe("handleAddItem", () => { test("adding items to the page", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/index.html ================================================ Inventory Manager

              Inventory Contents

                ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return data.inventory; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/main.js ================================================ const { handleAddItem, checkFormValues } = require("./domController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/main.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText } = require("@testing-library/dom"); beforeEach(() => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); require("./main"); }); test("adding items through the form", () => { screen.getByPlaceholderText("Item name").value = "cheesecake"; screen.getByPlaceholderText("Quantity").value = "6"; const event = new Event("submit"); const form = document.getElementById("add-item-form"); form.dispatchEvent(event); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); itemField.value = "cheesecake"; const inputEvent = new Event("input", { bubbles: true }); itemField.dispatchEvent(inputEvent); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); itemField.value = "book"; const inputEvent = new Event("input", { bubbles: true }); itemField.dispatchEvent(inputEvent); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/package.json ================================================ { "name": "1_handling_raw_events", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/3_handling_events/2_bubbling_up_events/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; module.exports = { updateItemList, handleAddItem, checkFormValues }; ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); }); describe("handleAddItem", () => { test("adding items to the page", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/index.html ================================================ Inventory Manager

                Inventory Contents

                  ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return data.inventory; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/main.js ================================================ const { handleAddItem, checkFormValues } = require("./domController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/main.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); beforeEach(() => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); require("./main"); }); test("adding items through the form", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); const submitBtn = screen.getByText("Add to inventory"); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/package.json ================================================ { "name": "1_handling_raw_events", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/3_handling_events/3_dom_testing_library_events/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { if (inventory === null) return; localStorage.setItem("inventory", JSON.stringify(inventory)); const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; module.exports = { updateItemList, handleAddItem, checkFormValues }; ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues } = require("./domController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { beforeEach(() => localStorage.clear()); test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); test("updates the localStorage with the inventory", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify(inventory) ); }); test("does not update the inventory when passing null", () => { localStorage.setItem("inventory", JSON.stringify({ cheesecake: 5 })); updateItemList(null); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify({ cheesecake: 5 }) ); }); }); describe("handleAddItem", () => { test("adding items to the page", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/index.html ================================================ Inventory Manager

                  Inventory Contents

                    ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return data.inventory; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/main.js ================================================ const { handleAddItem, checkFormValues, updateItemList } = require("./domController"); const { data } = require("./inventoryController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); // Restore the inventory when the page loads const storedInventory = JSON.parse(localStorage.getItem("inventory")); if (storedInventory) { data.inventory = storedInventory; updateItemList(data.inventory); } ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/main.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); beforeEach(() => localStorage.clear()); beforeEach(() => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); require("./main"); }); test("persists items between sessions", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); const submitBtn = screen.getByText("Add to inventory"); fireEvent.click(submitBtn); const itemListBefore = document.getElementById("item-list"); expect(itemListBefore.childNodes).toHaveLength(1); expect( getByText(itemListBefore, "cheesecake - Quantity: 6") ).toBeInTheDocument(); // This is equivalent to reloading the page document.body.innerHTML = initialHtml; jest.resetModules(); require("./main"); const itemListAfter = document.getElementById("item-list"); expect(itemListAfter.childNodes).toHaveLength(1); expect( getByText(itemListAfter, "cheesecake - Quantity: 6") ).toBeInTheDocument(); }); describe("adding items", () => { test("updating the item list", () => { const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/package.json ================================================ { "name": "2_localstorage", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/4_testing_and_browser_apis/1_localstorage/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { if (inventory === null) return; localStorage.setItem("inventory", JSON.stringify(inventory)); const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); history.pushState({ inventory: { ...data.inventory } }, document.title); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; const handleUndo = () => { if (history.state === null) return; history.back(); }; const handlePopstate = () => { data.inventory = history.state ? history.state.inventory : {}; updateItemList(data.inventory); }; module.exports = { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate }; ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/domController.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate } = require("./domController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils"); const { data } = require("./inventoryController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { beforeEach(() => localStorage.clear()); test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); test("updates the localStorage with the inventory", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify(inventory) ); }); test("does not update the inventory when passing null", () => { localStorage.setItem("inventory", JSON.stringify({ cheesecake: 5 })); updateItemList(null); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify({ cheesecake: 5 }) ); }); }); describe("handleAddItem", () => { beforeEach(() => (data.inventory = {})); test("adding items to the page", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); test("updating the application's history", () => { const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); describe("tests with history", () => { beforeEach(() => jest.spyOn(window, "addEventListener")); afterEach(detachPopstateHandlers); beforeEach(clearHistoryHook); describe("handleUndo", () => { test("going back from a non-initial state", done => { window.addEventListener("popstate", () => { expect(history.state).toEqual(null); done(); }); history.pushState({ inventory: { cheesecake: 5 } }, "title"); handleUndo(); }); test("going back from an initial state", () => { jest.spyOn(history, "back"); handleUndo(); // This assertion doesn't care about whether // a call to `history.back` would have finished, // it only checks whether it's been called expect(history.back.mock.calls).toHaveLength(0); }); }); describe("handlePopstate", () => { test("updating the item list with the current state", () => { history.pushState( { inventory: { cheesecake: 5, "carrot cake": 2 } }, "title" ); handlePopstate(); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(2); expect( getByText(itemList, "cheesecake - Quantity: 5") ).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 2") ).toBeInTheDocument(); }); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/index.html ================================================ Inventory Manager

                    Inventory Contents

                      ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/inventoryController.js ================================================ const data = { inventory: {} }; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return data.inventory; }; module.exports = { data, addItem }; ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/inventoryController.test.js ================================================ const { addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { data.inventory = {}; addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["./setupJestDom.js"] }; ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/main.js ================================================ const { handleAddItem, checkFormValues, handleUndo, handlePopstate, updateItemList } = require("./domController"); const { data } = require("./inventoryController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); const undoButton = document.getElementById("undo-button"); undoButton.addEventListener("click", handleUndo); window.addEventListener("popstate", handlePopstate); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); // Restore the inventory when the page loads const storedInventory = JSON.parse(localStorage.getItem("inventory")); if (storedInventory) { data.inventory = storedInventory; updateItemList(data.inventory); } ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/main.test.js ================================================ const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils.js"); beforeEach(clearHistoryHook); beforeEach(() => localStorage.clear()); beforeEach(() => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); require("./main"); // You can only spy on `window.addEventListener` after `main.js` // has been executed. Otherwise `detachPopstateHandlers` will // also detach the handlers that `main.js` attached to the page. jest.spyOn(window, "addEventListener"); }); afterEach(detachPopstateHandlers); test("persists items between sessions", () => { const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemListBefore = document.getElementById("item-list"); expect(itemListBefore.childNodes).toHaveLength(1); expect( getByText(itemListBefore, "cheesecake - Quantity: 6") ).toBeInTheDocument(); // This is equivalent to reloading the page document.body.innerHTML = initialHtml; jest.resetModules(); require("./main"); const itemListAfter = document.getElementById("item-list"); expect(itemListAfter.childNodes).toHaveLength(1); expect( getByText(itemListAfter, "cheesecake - Quantity: 6") ).toBeInTheDocument(); }); describe("adding items", () => { test("updating the item list", () => { const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); test("undo to one item", done => { const itemField = screen.getByPlaceholderText("Item name"); const quantityField = screen.getByPlaceholderText("Quantity"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); fireEvent.input(itemField, { target: { value: "carrot cake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "5" }, bubbles: true }); fireEvent.click(submitBtn); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList.children).toHaveLength(1); expect( getByText(itemList, "cheesecake - Quantity: 6") ).toBeInTheDocument(); done(); }); fireEvent.click(screen.getByText("Undo")); }); test("undo to empty list", done => { const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList).toBeEmpty(); done(); }); fireEvent.click(screen.getByText("Undo")); }); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/package.json ================================================ { "name": "2_localstorage", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "jest": "^24.9.0" } } ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/4_testing_and_browser_apis/2_history_api/testUtils.js ================================================ const clearHistoryHook = done => { const clearHistory = () => { if (history.state === null) { window.removeEventListener("popstate", clearHistory); return done(); } history.back(); }; window.addEventListener("popstate", clearHistory); clearHistory(); }; const detachPopstateHandlers = () => { const popstateListeners = window.addEventListener.mock.calls.filter( ([eventName]) => { return eventName === "popstate"; } ); popstateListeners.forEach(([eventName, handlerFn]) => { window.removeEventListener(eventName, handlerFn); }); jest.restoreAllMocks(); }; module.exports = { clearHistoryHook, detachPopstateHandlers }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/README.md ================================================ # Chapter 5 Server To better support the client-side application we'll build on Chapter 5, I've had to do a few updates to the server from Chapter 4. In case you want to update the back-end from Chapter 4 yourself, here's the list of changes I've done: - For the server to accept the requests coming from the client, you'll need to use [`@koa/cors`](https://github.com/koajs/cors) - To enable running tests while the server is running, I bind it to different ports depending on whether I am in a test or development environment. - At `POST /inventory/:itemName` I have added a route which adds an item to the inventory. It takes a `body` containing the `quantity` to add. - At `GET /inventory` I have added a route which lists all items in the inventory. ================================================ FILE: chapter6/4_testing_and_browser_apis/server/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const db = require("knex")(require("./knexfile")[environmentName]); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/package.json ================================================ { "name": "4_integrations_with_other_apis", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "cross-env NODE_ENV=development node server.js", "migrate:dev": "knex migrate:latest --env development", "seed:dev": "knex seed:run" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "@koa/cors": "^3.0.0", "cross-env": "^7.0.2", "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "nock": "^12.0.3", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter6/4_testing_and_browser_apis/server/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/seeds/initial_inventory.js ================================================ exports.seed = async knex => { await knex("inventory").del(); return knex("inventory").insert([ { itemName: "cheesecake", quantity: 8 }, { itemName: "apple pie", quantity: 2 }, { itemName: "carrot cake", quantity: 5 } ]); }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const cors = require("@koa/cors"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const PORT = process.env.NODE_ENV === "test" ? 5000 : 3000; const app = new Koa(); const router = new Router(); app.use(cors()); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.post("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const itemExists = current && current.quantity > 0; const newRecord = { itemName, quantity: (itemExists ? current.quantity : 0) + quantity }; if (current) { await db("inventory") .increment("quantity", quantity) .where({ itemName }); } else { await db("inventory").insert(newRecord); } ctx.body = newRecord; }); router.get("/inventory", async ctx => { ctx.body = await db .select("itemName", "quantity") .from("inventory") .where("quantity", ">", 0) .orderBy("quantity", "desc"); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(PORT) }; ================================================ FILE: chapter6/4_testing_and_browser_apis/server/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("list inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; const carrotCake = { itemName: "carrot cake", quantity: 0 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie, carrotCake]); }); test("fetching all available items", async () => { const { body } = await request(app) .get("/inventory") .expect(200) .expect("Content-Type", /json/); expect(body).toEqual([eggs, applePie]); }); }); describe("add inventory items", () => { test("adding a new item", async () => { const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 3 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); test("adding an existing item", async () => { const eggs = { itemName: "eggs", quantity: 2 }; await db("inventory").insert(eggs); const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 5 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 5 }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("fetching an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter6/4_testing_and_browser_apis/server/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { if (inventory === null) return; localStorage.setItem("inventory", JSON.stringify(inventory)); const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); history.pushState({ inventory: { ...data.inventory } }, document.title); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; const handleUndo = () => { if (history.state === null) return; history.back(); }; const handlePopstate = () => { data.inventory = history.state ? history.state.inventory : {}; updateItemList(data.inventory); }; module.exports = { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/domController.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate } = require("./domController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils"); const { API_ADDR, data } = require("./inventoryController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { beforeEach(() => localStorage.clear()); test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); test("updates the localStorage with the inventory", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify(inventory) ); }); test("does not update the inventory when passing null", () => { localStorage.setItem("inventory", JSON.stringify({ cheesecake: 5 })); updateItemList(null); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify({ cheesecake: 5 }) ); }); }); describe("handleAddItem", () => { beforeEach(() => (data.inventory = {})); test("adding items to the page", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); test("updating the application's history", () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); describe("tests with history", () => { beforeEach(() => jest.spyOn(window, "addEventListener")); afterEach(detachPopstateHandlers); beforeEach(clearHistoryHook); describe("handleUndo", () => { test("going back from a non-initial state", done => { window.addEventListener("popstate", () => { expect(history.state).toEqual(null); done(); }); history.pushState({ inventory: { cheesecake: 5 } }, "title"); handleUndo(); }); test("going back from an initial state", () => { jest.spyOn(history, "back"); handleUndo(); // This assertion doesn't care about whether // a call to `history.back` would have finished, // it only checks whether it's been called expect(history.back.mock.calls).toHaveLength(0); }); }); describe("handlePopstate", () => { test("updating the item list with the current state", () => { history.pushState( { inventory: { cheesecake: 5, "carrot cake": 2 } }, "title" ); handlePopstate(); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(2); expect( getByText(itemList, "cheesecake - Quantity: 5") ).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 2") ).toBeInTheDocument(); }); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/index.html ================================================ Inventory Manager

                      Inventory Contents

                        ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/inventoryController.js ================================================ const data = { inventory: {} }; const API_ADDR = "http://localhost:3000"; const addItem = (itemName, quantity) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); return data.inventory; }; module.exports = { API_ADDR, data, addItem }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/inventoryController.test.js ================================================ const nock = require("nock"); const { API_ADDR, addItem, data } = require("./inventoryController"); describe("addItem", () => { test("adding new items to the inventory", () => { // Respond to all post requests // to POST /inventory/:itemName nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); test("sending requests when adding new items", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 5 })) .reply(200); addItem("cheesecake", 5); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupGlobalFetch.js", "/setupJestDom.js" ] }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/main.js ================================================ const { handleAddItem, checkFormValues, handleUndo, handlePopstate, updateItemList } = require("./domController"); const { API_ADDR, data } = require("./inventoryController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); const undoButton = document.getElementById("undo-button"); undoButton.addEventListener("click", handleUndo); window.addEventListener("popstate", handlePopstate); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); const loadInitialData = async () => { try { const inventoryResponse = await fetch(`${API_ADDR}/inventory`); data.inventory = await inventoryResponse.json(); return updateItemList(data.inventory); } catch (e) { // Restore the inventory if the request fails const storedInventory = JSON.parse(localStorage.getItem("inventory")); if (storedInventory) { data.inventory = storedInventory; updateItemList(data.inventory); } } }; module.exports = loadInitialData(); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/main.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); const { API_ADDR } = require("./inventoryController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils.js"); beforeEach(clearHistoryHook); beforeEach(() => localStorage.clear()); beforeEach(async () => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); await require("./main"); // You can only spy on `window.addEventListener` after `main.js` // has been executed. Otherwise `detachPopstateHandlers` will // also detach the handlers that `main.js` attached to the page. jest.spyOn(window, "addEventListener"); }); afterEach(detachPopstateHandlers); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("persists items between sessions", async () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemListBefore = document.getElementById("item-list"); expect(itemListBefore.childNodes).toHaveLength(1); expect( getByText(itemListBefore, "cheesecake - Quantity: 6") ).toBeInTheDocument(); // This is equivalent to reloading the page document.body.innerHTML = initialHtml; jest.resetModules(); await require("./main"); const itemListAfter = document.getElementById("item-list"); expect(itemListAfter.childNodes).toHaveLength(1); expect( getByText(itemListAfter, "cheesecake - Quantity: 6") ).toBeInTheDocument(); }); describe("adding items", () => { test("updating the item list", () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); test("sending a request to update the item list", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); test("undo to one item", done => { // You must specify the encoded URL here because // nock struggles with encoded urls nock(API_ADDR) .post("/inventory/carrot%20cake") .reply(200); nock(API_ADDR) .post("/inventory/cheesecake") .reply(200); const itemField = screen.getByPlaceholderText("Item name"); const quantityField = screen.getByPlaceholderText("Quantity"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); fireEvent.input(itemField, { target: { value: "carrot cake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "5" }, bubbles: true }); fireEvent.click(submitBtn); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList.children).toHaveLength(1); expect( getByText(itemList, "cheesecake - Quantity: 6") ).toBeInTheDocument(); done(); }); fireEvent.click(screen.getByText("Undo")); }); test("undo to empty list", done => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const itemField = screen.getByPlaceholderText("Item name"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList).toBeEmpty(); done(); }); fireEvent.click(screen.getByText("Undo")); }); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/package.json ================================================ { "name": "1_http_requests", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0", "nock": "^12.0.3" } } ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/1_http_requests/testUtils.js ================================================ const clearHistoryHook = done => { const clearHistory = () => { if (history.state === null) { window.removeEventListener("popstate", clearHistory); return done(); } history.back(); }; window.addEventListener("popstate", clearHistory); clearHistory(); }; const detachPopstateHandlers = () => { const popstateListeners = window.addEventListener.mock.calls.filter( ([eventName]) => { return eventName === "popstate"; } ); popstateListeners.forEach(([eventName, handlerFn]) => { window.removeEventListener(eventName, handlerFn); }); jest.restoreAllMocks(); }; module.exports = { clearHistoryHook, detachPopstateHandlers }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/domController.js ================================================ const { addItem, data } = require("./inventoryController"); const updateItemList = inventory => { if (inventory === null) return; localStorage.setItem("inventory", JSON.stringify(inventory)); const inventoryList = window.document.getElementById("item-list"); // Clears the list inventoryList.innerHTML = ""; Object.entries(inventory).forEach(([itemName, quantity]) => { const listItem = window.document.createElement("li"); listItem.innerHTML = `${itemName} - Quantity: ${quantity}`; if (quantity < 5) { listItem.className = "almost-soldout"; } inventoryList.appendChild(listItem); }); const inventoryContents = JSON.stringify(inventory); const p = window.document.createElement("p"); p.innerHTML = `The inventory has been updated - ${inventoryContents}`; window.document.body.appendChild(p); }; const handleAddItem = event => { // Prevent the page from reloading as it would by default event.preventDefault(); const { name, quantity } = event.target.elements; addItem(name.value, parseInt(quantity.value, 10)); history.pushState({ inventory: { ...data.inventory } }, document.title); updateItemList(data.inventory); }; const validItems = ["cheesecake", "apple pie", "carrot cake"]; const checkFormValues = () => { const itemName = document.querySelector(`input[name="name"]`).value; const quantity = document.querySelector(`input[name="quantity"]`).value; const itemNameIsEmpty = itemName === ""; const itemNameIsInvalid = !validItems.includes(itemName); const quantityIsEmpty = quantity === ""; const errorMsg = window.document.getElementById("error-msg"); if (itemNameIsEmpty) { errorMsg.innerHTML = ""; } else if (itemNameIsInvalid) { errorMsg.innerHTML = `${itemName} is not a valid item.`; } else { errorMsg.innerHTML = `${itemName} is valid!`; } const submitButton = document.querySelector(`button[type="submit"]`); if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { submitButton.disabled = true; } else { submitButton.disabled = false; } }; const handleUndo = () => { if (history.state === null) return; history.back(); }; const handlePopstate = () => { data.inventory = history.state ? history.state.inventory : {}; updateItemList(data.inventory); }; module.exports = { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/domController.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText, screen } = require("@testing-library/dom"); const { updateItemList, handleAddItem, checkFormValues, handleUndo, handlePopstate } = require("./domController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils"); const { API_ADDR, data } = require("./inventoryController"); beforeEach(() => { document.body.innerHTML = initialHtml; }); describe("updateItemList", () => { beforeEach(() => localStorage.clear()); test("updates the DOM with the inventory items", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(3); expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument(); expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 6") ).toBeInTheDocument(); }); test("highlighting in red elements whose quantity is below five", () => { const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 }; updateItemList(inventory); expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({ color: "red" }); }); test("adding a paragraph indicating what was the update", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect( screen.getByText( `The inventory has been updated - ${JSON.stringify(inventory)}` ) ).toBeTruthy(); }); test("updates the localStorage with the inventory", () => { const inventory = { cheesecake: 5, "apple pie": 2 }; updateItemList(inventory); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify(inventory) ); }); test("does not update the inventory when passing null", () => { localStorage.setItem("inventory", JSON.stringify({ cheesecake: 5 })); updateItemList(null); expect(localStorage.getItem("inventory")).toEqual( JSON.stringify({ cheesecake: 5 }) ); }); }); describe("handleAddItem", () => { beforeEach(() => (data.inventory = {})); test("adding items to the page", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); // Checking if the form's default reload is prevent expect(event.preventDefault.mock.calls).toHaveLength(1); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); test("updating the application's history", () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const event = { preventDefault: jest.fn(), target: { elements: { name: { value: "cheesecake" }, quantity: { value: "6" } } } }; handleAddItem(event); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); }); }); describe("checkFormValues", () => { test("entering valid item values", () => { document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeEnabled(); }); test("entering invalid item names", () => { document.querySelector(`input[name="name"]`).value = "invalid"; document.querySelector(`input[name="quantity"]`).value = "1"; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); document.querySelector(`input[name="name"]`).value = "cheesecake"; document.querySelector(`input[name="quantity"]`).value = ""; checkFormValues(); expect(screen.getByText("Add to inventory")).toBeDisabled(); }); }); describe("tests with history", () => { beforeEach(() => jest.spyOn(window, "addEventListener")); afterEach(detachPopstateHandlers); beforeEach(clearHistoryHook); describe("handleUndo", () => { test("going back from a non-initial state", done => { window.addEventListener("popstate", () => { expect(history.state).toEqual(null); done(); }); history.pushState({ inventory: { cheesecake: 5 } }, "title"); handleUndo(); }); test("going back from an initial state", () => { jest.spyOn(history, "back"); handleUndo(); // This assertion doesn't care about whether // a call to `history.back` would have finished, // it only checks whether it's been called expect(history.back.mock.calls).toHaveLength(0); }); }); describe("handlePopstate", () => { test("updating the item list with the current state", () => { history.pushState( { inventory: { cheesecake: 5, "carrot cake": 2 } }, "title" ); handlePopstate(); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(2); expect( getByText(itemList, "cheesecake - Quantity: 5") ).toBeInTheDocument(); expect( getByText(itemList, "carrot cake - Quantity: 2") ).toBeInTheDocument(); }); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/index.html ================================================ Inventory Manager

                        Inventory Contents

                          ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/inventoryController.js ================================================ const data = { inventory: {} }; const API_ADDR = "http://localhost:3000"; const addItem = (itemName, quantity) => { const { client } = require("./socket"); const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json", "x-socket-client-id": client.id }, body: JSON.stringify({ quantity }) }); return data.inventory; }; module.exports = { API_ADDR, data, addItem }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/inventoryController.test.js ================================================ const nock = require("nock"); const { API_ADDR, addItem, data } = require("./inventoryController"); const { start, stop } = require("./testSocketServer"); const { client, connect } = require("./socket"); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); describe("addItem", () => { test("adding new items to the inventory", () => { // Respond to all post requests // to POST /inventory/:itemName nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); addItem("cheesecake", 5); expect(data.inventory.cheesecake).toBe(5); }); test("sending requests when adding new items", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 5 })) .reply(200); addItem("cheesecake", 5); }); describe("live-updates", () => { beforeAll(start); beforeAll(async () => { nock.cleanAll(); await connect(); }); afterAll(stop); test("sending a x-socket-client-id header", () => { const clientId = client.id; nock(API_ADDR, { reqheaders: { "x-socket-client-id": clientId } }) .post(/inventory\/.*$/) .reply(200); addItem("cheesecake", 5); }); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupGlobalFetch.js", "/setupJestDom.js" ] }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/main.js ================================================ const { connect } = require("./socket"); const { handleAddItem, checkFormValues, handleUndo, handlePopstate, updateItemList } = require("./domController"); const { API_ADDR, data } = require("./inventoryController"); const form = document.getElementById("add-item-form"); form.addEventListener("submit", handleAddItem); form.addEventListener("input", checkFormValues); const undoButton = document.getElementById("undo-button"); undoButton.addEventListener("click", handleUndo); window.addEventListener("popstate", handlePopstate); // Run `checkFormValues` once to see if the initial state is valid checkFormValues(); const loadInitialData = async () => { try { const inventoryResponse = await fetch(`${API_ADDR}/inventory`); data.inventory = await inventoryResponse.json(); return updateItemList(data.inventory); } catch (e) { // Restore the inventory if the request fails const storedInventory = JSON.parse(localStorage.getItem("inventory")); if (storedInventory) { data.inventory = storedInventory; updateItemList(data.inventory); } } }; connect(); module.exports = loadInitialData(); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/main.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { screen, getByText, fireEvent } = require("@testing-library/dom"); const { API_ADDR } = require("./inventoryController"); const { clearHistoryHook, detachPopstateHandlers } = require("./testUtils.js"); beforeEach(clearHistoryHook); beforeEach(() => localStorage.clear()); beforeEach(async () => { document.body.innerHTML = initialHtml; // You must execute main.js again so that it can attach the // event listener to the form every time the body changes. // Here you must use `jest.resetModules` because otherwise // Jest will have cached `main.js` and it will _not_ run again. jest.resetModules(); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); await require("./main"); // You can only spy on `window.addEventListener` after `main.js` // has been executed. Otherwise `detachPopstateHandlers` will // also detach the handlers that `main.js` attached to the page. jest.spyOn(window, "addEventListener"); }); afterEach(detachPopstateHandlers); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("persists items between sessions", async () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); nock(API_ADDR) .get("/inventory") .replyWithError({ code: 500 }); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemListBefore = document.getElementById("item-list"); expect(itemListBefore.childNodes).toHaveLength(1); expect( getByText(itemListBefore, "cheesecake - Quantity: 6") ).toBeInTheDocument(); // This is equivalent to reloading the page document.body.innerHTML = initialHtml; jest.resetModules(); await require("./main"); const itemListAfter = document.getElementById("item-list"); expect(itemListAfter.childNodes).toHaveLength(1); expect( getByText(itemListAfter, "cheesecake - Quantity: 6") ).toBeInTheDocument(); }); describe("adding items", () => { test("updating the item list", () => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); const itemList = document.getElementById("item-list"); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); test("sending a request to update the item list", () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); if (!nock.isDone()) throw new Error("POST /inventory/cheesecake was not reached"); }); test("undo to one item", done => { // You must specify the encoded URL here because // nock struggles with encoded urls nock(API_ADDR) .post("/inventory/carrot%20cake") .reply(200); nock(API_ADDR) .post("/inventory/cheesecake") .reply(200); const itemField = screen.getByPlaceholderText("Item name"); const quantityField = screen.getByPlaceholderText("Quantity"); const submitBtn = screen.getByText("Add to inventory"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); fireEvent.input(itemField, { target: { value: "carrot cake" }, bubbles: true }); fireEvent.input(quantityField, { target: { value: "5" }, bubbles: true }); fireEvent.click(submitBtn); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList.children).toHaveLength(1); expect( getByText(itemList, "cheesecake - Quantity: 6") ).toBeInTheDocument(); done(); }); fireEvent.click(screen.getByText("Undo")); }); test("undo to empty list", done => { nock(API_ADDR) .post(/inventory\/.*$/) .reply(200); const submitBtn = screen.getByText("Add to inventory"); const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); const quantityField = screen.getByPlaceholderText("Quantity"); fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true }); fireEvent.click(submitBtn); expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); window.addEventListener("popstate", () => { const itemList = document.getElementById("item-list"); expect(itemList).toBeEmpty(); done(); }); fireEvent.click(screen.getByText("Undo")); }); }); describe("item name validation", () => { test("entering valid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "cheesecake" }, bubbles: true }); expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument(); }); test("entering invalid item names ", () => { const itemField = screen.getByPlaceholderText("Item name"); fireEvent.input(itemField, { target: { value: "book" }, bubbles: true }); expect(screen.getByText("book is not a valid item.")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/package.json ================================================ { "name": "1_http_requests", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "http-server ./", "test": "jest", "build": "browserify main.js -o bundle.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@testing-library/dom": "^7.2.2", "@testing-library/jest-dom": "^5.5.0", "browserify": "^16.5.1", "http-server": "^0.12.1", "isomorphic-fetch": "^2.2.1", "jest": "^24.9.0", "nock": "^12.0.3", "socket.io": "^2.3.0" }, "dependencies": { "http-shutdown": "^1.2.2", "socket.io-client": "^2.3.0" } } ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/socket.js ================================================ const { API_ADDR, data } = require("./inventoryController"); const { updateItemList } = require("./domController"); const client = { id: null }; const io = require("socket.io-client"); const handleAddItemMsg = ({ itemName, quantity }) => { const currentQuantity = data.inventory[itemName] || 0; data.inventory[itemName] = currentQuantity + quantity; return updateItemList(data.inventory); }; const connect = () => { return new Promise(resolve => { const socket = io(API_ADDR); socket.on("connect", () => { client.id = socket.id; resolve(socket); }); socket.on("add_item", handleAddItemMsg); }); }; module.exports = { client, connect, handleAddItemMsg }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/socket.test.js ================================================ const nock = require("nock"); const fs = require("fs"); const initialHtml = fs.readFileSync("./index.html"); const { getByText } = require("@testing-library/dom"); const { data } = require("./inventoryController"); const { start, stop, sendMsg } = require("./testSocketServer"); const { handleAddItemMsg, connect } = require("./socket"); beforeEach(() => { document.body.innerHTML = initialHtml; }); beforeEach(() => { data.inventory = {}; }); describe("handleAddItemMsg", () => { test("updating the inventory and the item list", () => { handleAddItemMsg({ itemName: "cheesecake", quantity: 6 }); expect(data.inventory).toEqual({ cheesecake: 6 }); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(1); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); describe("handling real messages", () => { beforeAll(start); beforeAll(async () => { nock.cleanAll(); await connect(); }); afterAll(stop); test("handling add_item messages", async () => { sendMsg("add_item", { itemName: "cheesecake", quantity: 6 }); await new Promise(resolve => setTimeout(resolve, 1000)); expect(data.inventory).toEqual({ cheesecake: 6 }); const itemList = document.getElementById("item-list"); expect(itemList.childNodes).toHaveLength(1); expect(getByText(itemList, "cheesecake - Quantity: 6")).toBeInTheDocument(); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/testSocketServer.js ================================================ const server = require("http").createServer(); const io = require("socket.io")(server); const sendMsg = (msgType, content) => { io.sockets.emit(msgType, content); }; const start = () => new Promise(resolve => { server.listen(3000, resolve); }); const stop = () => new Promise(resolve => { server.close(resolve); }); module.exports = { start, stop, sendMsg }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/2_web_sockets/testUtils.js ================================================ const clearHistoryHook = done => { const clearHistory = () => { if (history.state === null) { window.removeEventListener("popstate", clearHistory); return done(); } history.back(); }; window.addEventListener("popstate", clearHistory); clearHistory(); }; const detachPopstateHandlers = () => { const popstateListeners = window.addEventListener.mock.calls.filter( ([eventName]) => { return eventName === "popstate"; } ); popstateListeners.forEach(([eventName, handlerFn]) => { window.removeEventListener(eventName, handlerFn); }); jest.restoreAllMocks(); }; module.exports = { clearHistoryHook, detachPopstateHandlers }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/README.md ================================================ # Chapter 5 Server To better support the client-side application we'll build on Chapter 5, I've had to do a few updates to the server from Chapter 4. In case you want to update the back-end from Chapter 4 yourself, here's the list of changes I've done: - For the server to accept the requests coming from the client, you'll need to use [`@koa/cors`](https://github.com/koajs/cors) - To enable running tests while the server is running, I bind it to different ports depending on whether I am in a test or development environment. - At `POST /inventory/:itemName` I have added a route which adds an item to the inventory. It takes a `body` containing the `quantity` to add. - At `GET /inventory` I have added a route which lists all items in the inventory. - At `DELETE /inventory/:itemName` I have added a route which let's you delete inventory items so that you can use to fix the `undo` functionality - I've used `koa-socket-2` to add support for `socket.io` - The `POST /inventory/:itemName` will now push updates to all clients but the one which added an item. ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const db = require("knex")(require("./knexfile")[environmentName]); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/package.json ================================================ { "name": "chapter5_server", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "cross-env NODE_ENV=development node server.js", "migrate:dev": "knex migrate:latest --env development", "seed:dev": "knex seed:run" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "@koa/cors": "^3.0.0", "cross-env": "^7.0.2", "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "koa-socket-2": "^1.2.0", "nock": "^12.0.3", "socket.io": "^2.3.0", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/seeds/initial_inventory.js ================================================ exports.seed = async knex => { await knex("inventory").del(); return knex("inventory").insert([ { itemName: "cheesecake", quantity: 8 }, { itemName: "apple pie", quantity: 2 }, { itemName: "carrot cake", quantity: 5 } ]); }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const http = require("http"); const IO = require("koa-socket-2"); const cors = require("@koa/cors"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const PORT = process.env.NODE_ENV === "test" ? 5000 : 3000; const app = new Koa(); const io = new IO(); io.attach(app); const router = new Router(); app.use(cors()); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.post("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const clientId = ctx.request.headers["x-socket-client-id"]; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const itemExists = current && current.quantity > 0; const newRecord = { itemName, quantity: (itemExists ? current.quantity : 0) + quantity }; if (current) { await db("inventory") .increment("quantity", quantity) .where({ itemName }); } else { await db("inventory").insert(newRecord); } Object.entries(io.socket.sockets.connected).forEach(([id, socket]) => { if (id === clientId) return; socket.emit("add_item", { itemName, quantity }); }); ctx.body = newRecord; }); router.delete("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const canDelete = current && current.quantity > quantity; if (canDelete) { await db("inventory") .decrement("quantity", quantity) .where({ itemName }); ctx.body = { message: `Removed ${quantity} units of ${itemName}` }; } else { ctx.status = 404; ctx.body = { message: `There aren't ${quantity} units of ${itemName} available.` }; } }); router.get("/inventory", async ctx => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory") .where("quantity", ">", 0) .orderBy("quantity", "desc"); ctx.body = inventoryContent.reduce((acc, { itemName, quantity }) => { return { ...acc, [itemName]: quantity }; }, {}); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(PORT, "127.0.0.1") }; ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("list inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; const carrotCake = { itemName: "carrot cake", quantity: 0 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie, carrotCake]); }); test("fetching all available items", async () => { const { body } = await request(app) .get("/inventory") .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ eggs: 3, "apple pie": 1 }); }); }); describe("add inventory items", () => { test("adding a new item", async () => { const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 3 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); test("adding an existing item", async () => { const eggs = { itemName: "eggs", quantity: 2 }; await db("inventory").insert(eggs); const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 5 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 5 }); }); }); describe("remove inventory items", () => { beforeEach(async () => { await db("inventory").insert({ itemName: "eggs", quantity: 3 }); }); test("removing an item", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 2 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ message: "Removed 2 units of eggs" }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 1 }); }); test("removing more than the inventory quantity", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 4 }) .expect(404) .expect("Content-Type", /json/); expect(body).toEqual({ message: "There aren't 4 units of eggs available." }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("fetching an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter6/5_web_sockets_and_http_requests/server/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter7/1_setting_up_a_test_environment/1_createElement_calls/index.html ================================================ Inventory
                          ================================================ FILE: chapter7/1_setting_up_a_test_environment/1_createElement_calls/index.js ================================================ const ReactDOM = require("react-dom"); const React = require("react"); const header = React.createElement("h1", null, "Inventory Contents"); const App = React.createElement("div", null, header); ReactDOM.render(App, document.getElementById("app")); ================================================ FILE: chapter7/1_setting_up_a_test_environment/1_createElement_calls/package.json ================================================ { "name": "1_createelement_calls", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.js -o bundle.js", "start": "http-server ./" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "browserify": "^16.5.1", "http-server": "^0.12.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" } } ================================================ FILE: chapter7/1_setting_up_a_test_environment/2_transforming_jsx/index.html ================================================ Inventory
                          ================================================ FILE: chapter7/1_setting_up_a_test_environment/2_transforming_jsx/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; const App = () => { return (

                          Inventory Contents

                          ); }; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter7/1_setting_up_a_test_environment/2_transforming_jsx/package.json ================================================ { "name": "2_transforming_jsx", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -o bundle.js", "start": "http-server ./" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "babelify": "^10.0.0", "browserify": "^16.5.1", "http-server": "^0.12.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" }, "browserify": { "transform": [ [ "babelify", { "presets": [ "@babel/preset-env", "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/App.jsx ================================================ import React from "react"; export const App = () => { return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/app.test.js ================================================ import App from "./App.jsx"; ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/index.html ================================================ Inventory
                          ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/jest.config.js ================================================ // For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html module.exports = { // All imported modules in your tests should be mocked automatically // automock: false, // Stop running tests after `n` failures // bail: 0, // The directory where Jest should store its cached dependency information // cacheDirectory: "/private/var/folders/p0/0npmk50s57v5sgzb_s9z2xmc0000gn/T/jest_dx", // Automatically clear mock calls and instances between every test // clearMocks: false, // Indicates whether the coverage information should be collected while executing the test // collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected // collectCoverageFrom: undefined, // The directory where Jest should output its coverage files // coverageDirectory: undefined, // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ // "/node_modules/" // ], // A list of reporter names that Jest uses when writing coverage reports // coverageReporters: [ // "json", // "text", // "lcov", // "clover" // ], // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: undefined, // A path to a custom dependency extractor // dependencyExtractor: undefined, // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, // Force coverage collection from ignored files using an array of glob patterns // forceCoverageMatch: [], // A path to a module which exports an async function that is triggered once before all test suites // globalSetup: undefined, // A path to a module which exports an async function that is triggered once after all test suites // globalTeardown: undefined, // A set of global variables that need to be available in all test environments // globals: {}, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. // maxWorkers: "50%", // An array of directory names to be searched recursively up from the requiring module's location // moduleDirectories: [ // "node_modules" // ], // An array of file extensions your modules use // moduleFileExtensions: [ // "js", // "json", // "jsx", // "ts", // "tsx", // "node" // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // moduleNameMapper: {}, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], // Activates notifications for test results // notify: false, // An enum that specifies notification mode. Requires { notify: true } // notifyMode: "failure-change", // A preset that is used as a base for Jest's configuration // preset: undefined, // Run tests from one or more projects // projects: undefined, // Use this configuration option to add custom reporters to Jest // reporters: undefined, // Automatically reset mock state between every test // resetMocks: false, // Reset the module registry before running each individual test // resetModules: false, // A path to a custom resolver // resolver: undefined, // Automatically restore mock state between every test // restoreMocks: false, // The root directory that Jest should scan for tests and modules within // rootDir: undefined, // A list of paths to directories that Jest should use to search for files in // roots: [ // "" // ], // Allows you to use a custom runner instead of Jest's default test runner // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], // A list of paths to snapshot serializer modules Jest should use for snapshot testing // snapshotSerializers: [], // The test environment that will be used for testing // testEnvironment: "jest-environment-jsdom", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, // Adds a location field to test results // testLocationInResults: false, // The glob patterns Jest uses to detect test files // testMatch: [ // "**/__tests__/**/*.[jt]s?(x)", // "**/?(*.)+(spec|test).[tj]s?(x)" // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ // "/node_modules/" // ], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], // This option allows the use of a custom results processor // testResultsProcessor: undefined, // This option allows use of a custom test runner // testRunner: "jasmine2", // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href // testURL: "http://localhost", // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" // timers: "real", // A map from regular expressions to paths to transformers // transform: undefined, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // transformIgnorePatterns: [ // "/node_modules/" // ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, // Indicates whether each individual test should be reported during the run // verbose: undefined, // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode // watchPathIgnorePatterns: [], // Whether to use watchman for file crawling // watchman: true, }; ================================================ FILE: chapter7/1_setting_up_a_test_environment/3_setting_up_jest/package.json ================================================ { "name": "3_setting_up_jest", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "babelify": "^10.0.0", "browserify": "^16.5.1", "http-server": "^0.12.3", "jest": "^24.9" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" }, "browserify": { "transform": [ [ "babelify", { "presets": [ "@babel/preset-env", "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/App.jsx ================================================ import React from "react"; export const App = () => { const [cheesecakes, setCheesecake] = React.useState(0); return (

                          Inventory Contents

                          Cheesecakes: {cheesecakes}

                          ); }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/App.test.jsx ================================================ import React from "react"; import { App } from "./App.jsx"; import { render } from "react-dom"; import { act } from "react-dom/test-utils"; import { screen, fireEvent } from "@testing-library/dom"; const root = document.createElement("div"); document.body.appendChild(root); test("renders the appropriate header", () => { act(() => { render(, root); }); expect(screen.getByText("Inventory Contents")).toBeInTheDocument(); }); test("increments the number of cheesecakes", () => { act(() => { render(, root); }); expect(screen.getByText("Cheesecakes: 0")).toBeInTheDocument(); const addCheesecakeBtn = screen.getByText("Add cheesecake"); act(() => { fireEvent.click(addCheesecakeBtn); }); expect(screen.getByText("Cheesecakes: 1")).toBeInTheDocument(); }); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/index.html ================================================ Inventory
                          ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: ["/setupJestDom.js"] }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/package.json ================================================ { "name": "1_react_testing_utilities", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "babelify": "^10.0.0", "browserify": "^16.5.1", "http-server": "^0.12.3", "jest": "^24.9" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" }, "browserify": { "transform": [ [ "babelify", { "presets": [ "@babel/preset-env", "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/1_react_testing_utilities/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; export const App = () => { const [items, setItems] = useState({}); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) setItems(responseBody); }; loadItems(); return () => (isMounted.current = false); }, []); return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { render, waitFor } from "@testing-library/react"; beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { findByText } = render(); expect(await findByText("cheesecake - Quantity: 2")).toBeInTheDocument(); expect(await findByText("croissant - Quantity: 5")).toBeInTheDocument(); expect(await findByText("macaroon - Quantity: 96")).toBeInTheDocument(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = () => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/ItemList.jsx ================================================ import React from "react"; export const ItemList = ({ itemList }) => { return (
                            {Object.entries(itemList).map(([itemName, quantity]) => { return (
                          • {itemName} - Quantity: {quantity}
                          • ); })}
                          ); }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/ItemList.test.jsx ================================================ import React from "react"; import { ItemList } from "./ItemList.jsx"; import { render } from "@testing-library/react"; test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText("cheesecake - Quantity: 2")).toBeInTheDocument(); expect(getByText("croissant - Quantity: 5")).toBeInTheDocument(); expect(getByText("macaroon - Quantity: 96")).toBeInTheDocument(); }); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/index.html ================================================ Inventory
                          ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/package.json ================================================ { "name": "2_react-testing-library", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter7/2_an_overview_of_react_testing_libraries/2_react_testing_library/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter7/server/README.md ================================================ # Chapter 5 Server To better support the client-side application we'll build on Chapter 5, I've had to do a few updates to the server from Chapter 4. In case you want to update the back-end from Chapter 4 yourself, here's the list of changes I've done: - For the server to accept the requests coming from the client, you'll need to use [`@koa/cors`](https://github.com/koajs/cors) - To enable running tests while the server is running, I bind it to different ports depending on whether I am in a test or development environment. - At `POST /inventory/:itemName` I have added a route which adds an item to the inventory. It takes a `body` containing the `quantity` to add. - At `GET /inventory` I have added a route which lists all items in the inventory. - At `DELETE /inventory/:itemName` I have added a route which let's you delete inventory items so that you can use to fix the `undo` functionality - I've used `koa-socket-2` to add support for `socket.io` - The `POST /inventory/:itemName` will now push updates to all clients but the one which added an item. ================================================ FILE: chapter7/server/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter7/server/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter7/server/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter7/server/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter7/server/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const db = require("knex")(require("./knexfile")[environmentName]); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter7/server/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter7/server/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter7/server/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter7/server/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter7/server/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter7/server/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter7/server/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter7/server/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter7/server/package.json ================================================ { "name": "chapter5_server", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "cross-env NODE_ENV=development node server.js", "migrate:dev": "knex migrate:latest --env development", "seed:dev": "knex seed:run" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "@koa/cors": "^3.0.0", "cross-env": "^7.0.2", "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "koa-socket-2": "^1.2.0", "nock": "^12.0.3", "socket.io": "^2.3.0", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter7/server/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter7/server/seeds/initial_inventory.js ================================================ exports.seed = async knex => { await knex("inventory").del(); return knex("inventory").insert([ { itemName: "cheesecake", quantity: 8 }, { itemName: "apple pie", quantity: 2 }, { itemName: "carrot cake", quantity: 5 } ]); }; ================================================ FILE: chapter7/server/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const http = require("http"); const IO = require("koa-socket-2"); const cors = require("@koa/cors"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const PORT = process.env.NODE_ENV === "test" ? 5000 : 3000; const app = new Koa(); const io = new IO(); io.attach(app); const router = new Router(); app.use(cors()); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.post("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const clientId = ctx.request.headers["x-socket-client-id"]; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const itemExists = current && current.quantity > 0; const newRecord = { itemName, quantity: (itemExists ? current.quantity : 0) + quantity }; if (current) { await db("inventory") .increment("quantity", quantity) .where({ itemName }); } else { await db("inventory").insert(newRecord); } Object.entries(io.socket.sockets.connected).forEach(([id, socket]) => { if (id === clientId) return; socket.emit("add_item", { itemName, quantity }); }); ctx.body = newRecord; }); router.delete("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const canDelete = current && current.quantity > quantity; if (canDelete) { await db("inventory") .decrement("quantity", quantity) .where({ itemName }); ctx.body = { message: `Removed ${quantity} units of ${itemName}` }; } else { ctx.status = 404; ctx.body = { message: `There aren't ${quantity} units of ${itemName} available.` }; } }); router.get("/inventory", async ctx => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory") .where("quantity", ">", 0) .orderBy("quantity", "desc"); ctx.body = inventoryContent.reduce((acc, { itemName, quantity }) => { return { ...acc, [itemName]: quantity }; }, {}); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(PORT, "127.0.0.1") }; ================================================ FILE: chapter7/server/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("list inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; const carrotCake = { itemName: "carrot cake", quantity: 0 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie, carrotCake]); }); test("fetching all available items", async () => { const { body } = await request(app) .get("/inventory") .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ eggs: 3, "apple pie": 1 }); }); }); describe("add inventory items", () => { test("adding a new item", async () => { const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 3 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); test("adding an existing item", async () => { const eggs = { itemName: "eggs", quantity: 2 }; await db("inventory").insert(eggs); const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 5 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 5 }); }); }); describe("remove inventory items", () => { beforeEach(async () => { await db("inventory").insert({ itemName: "eggs", quantity: 3 }); }); test("removing an item", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 2 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ message: "Removed 2 units of eggs" }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 1 }); }); test("removing more than the inventory quantity", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 4 }) .expect(404) .expect("Content-Type", /json/); expect(body).toEqual({ message: "There aren't 4 units of eggs available." }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("fetching an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter7/server/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter7/server/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; export const App = () => { const [items, setItems] = useState({}); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) setItems(responseBody); }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/ItemList.jsx ================================================ import React from "react"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; export const ItemList = ({ itemList }) => { return (
                            {Object.entries(itemList).map(([itemName, quantity]) => { return
                          • {generateItemText(itemName, quantity)}
                          • ; })}
                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); }); ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/package.json ================================================ { "name": "1_component_integration_tests", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/1_testing_component_interaction/1_component_integration_tests/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; export const App = () => { const [items, setItems] = useState({}); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) setItems(responseBody); }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/ItemList.jsx ================================================ import React from "react"; import { Transition } from "react-spring/renderprops"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); }); ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/package.json ================================================ { "name": "2_stubbing_components", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/1_testing_component_interaction/2_stubbing_components/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ItemList.jsx ================================================ import React from "react"; import { Transition } from "react-spring/renderprops"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); }); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/package.json ================================================ { "name": "1_component_snapshots", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/2_snapshot_testing/1_component_snapshots/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/2_snapshot_testing/2_snapshots_beyond_components/__snapshots__/generate_report.test.js.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`generating a .txt report 1`] = ` "cheesecake - Quantity: 8 - Value: 176 carrot cake - Quantity: 3 - Value: 54 macaroon - Quantity: 40 - Value: 240 chocolate cake - Quantity: 12 - Value: 204 Total value: 63" `; ================================================ FILE: chapter8/2_snapshot_testing/2_snapshots_beyond_components/generate_report.js ================================================ const fs = require("fs"); const inventory = [ { item: "cheesecake", quantity: 8, price: 22 }, { item: "carrot cake", quantity: 3, price: 18 }, { item: "macaroon", quantity: 40, price: 6 }, { item: "chocolate cake", quantity: 12, price: 17 } ]; module.exports.generateReport = items => { const lines = items.map(({ item, quantity, price }) => { return `${item} - Quantity: ${quantity} - Value: ${price * quantity}`; }); const totalValue = items.reduce((sum, { price }) => { return sum + price; }, 0); const content = lines.concat(`Total value: ${totalValue}`).join("\n"); fs.writeFileSync("/tmp/report.txt", content); }; module.exports.generateReport(inventory); ================================================ FILE: chapter8/2_snapshot_testing/2_snapshots_beyond_components/generate_report.test.js ================================================ const fs = require("fs"); const { generateReport } = require("./generate_report"); test("generating a .txt report", () => { const inventory = [ { item: "cheesecake", quantity: 8, price: 22 }, { item: "carrot cake", quantity: 3, price: 18 }, { item: "macaroon", quantity: 40, price: 6 }, { item: "chocolate cake", quantity: 12, price: 17 } ]; generateReport(inventory); const report = fs.readFileSync("/tmp/report.txt", "utf-8"); expect(report).toMatchSnapshot(); }); ================================================ FILE: chapter8/2_snapshot_testing/2_snapshots_beyond_components/package.json ================================================ { "name": "2_snapshots_beyond_components", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^25.5" }, "dependencies": {} } ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ItemList.jsx ================================================ import React from "react"; import { Transition } from "react-spring/renderprops"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("highlighting items that are almost out of stock", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const cheesecakeItem = getByText(generateItemText("cheesecake", 2)); expect(cheesecakeItem).toHaveClass("almost-out-of-stock"); }); }); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/3_testing_styles/1_css_classes/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/package.json ================================================ { "name": "1_testing_styles", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/3_testing_styles/1_css_classes/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/3_testing_styles/1_css_classes/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/3_testing_styles/1_css_classes/styles.css ================================================ .almost-out-of-stock { font-weight: bold; color: red; } ================================================ FILE: chapter8/3_testing_styles/2_style_props/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/2_style_props/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/2_style_props/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/3_testing_styles/2_style_props/ItemList.jsx ================================================ import React from "react"; import { Transition } from "react-spring/renderprops"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; const almostOutOfStock = { fontWeight: "bold", color: "red" }; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("highlighting items that are almost out of stock", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const cheesecakeItem = getByText(generateItemText("cheesecake", 2)); expect(cheesecakeItem).toHaveClass("almost-out-of-stock"); }); }); ================================================ FILE: chapter8/3_testing_styles/2_style_props/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/3_testing_styles/2_style_props/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/3_testing_styles/2_style_props/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/3_testing_styles/2_style_props/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/3_testing_styles/2_style_props/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/3_testing_styles/2_style_props/jest.config.js ================================================ module.exports = { setupFilesAfterEnv: [ "/setupJestDom.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/3_testing_styles/2_style_props/package.json ================================================ { "name": "1_testing_styles", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "nock": "^12.0.3" }, "dependencies": { "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/3_testing_styles/2_style_props/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/3_testing_styles/2_style_props/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/3_testing_styles/2_style_props/styles.css ================================================ .almost-out-of-stock { font-weight: bold; color: red; } ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ItemList.jsx ================================================ /* @jsx jsx */ import { Transition } from "react-spring/renderprops"; import { css, keyframes, jsx } from "@emotion/core"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; const pulsate = keyframes` 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } `; const almostOutOfStock = css` font-weight: bold; color: red; animation: ${pulsate} 2s infinite; `; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("highlighting items that are almost out of stock", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const cheesecakeItem = getByText(generateItemText("cheesecake", 2)); expect(cheesecakeItem).toHaveStyle({ color: "red" }); }); }); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/__snapshots__/ItemList.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ItemList Component highlighting items that are almost out of stock 1`] = ` @keyframes animation-0 { 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } } .emotion-0 { font-weight: bold; color: red; -webkit-animation: animation-0 2s infinite; animation: animation-0 2s infinite; }
                        • Cheesecake - Quantity: 2
                        • `; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/jest.config.js ================================================ module.exports = { snapshotSerializers: ["jest-emotion"], setupFilesAfterEnv: [ "/setupJestDom.js", "/setupJestEmotion.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/package.json ================================================ { "name": "1_testing_styles", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "jest-emotion": "^10.0.32", "nock": "^12.0.3" }, "dependencies": { "@emotion/core": "^10.0.28", "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/setupJestEmotion.js ================================================ const { matchers } = require("jest-emotion"); expect.extend(matchers); ================================================ FILE: chapter8/3_testing_styles/3_css_in_js_snapshots/styles.css ================================================ .almost-out-of-stock { font-weight: bold; color: red; } ================================================ FILE: chapter8/4_component_stories/1_stories/.storybook/main.js ================================================ module.exports = { stories: ["../**/*.stories.jsx"], addons: [ "@storybook/addon-actions/register", "@storybook/addon-knobs/register" ], webpackFinal: async config => { return { ...config, resolve: { ...config.resolve, alias: { "core-js/modules": "@storybook/core/node_modules/core-js/modules", "core-js/features": "@storybook/core/node_modules/core-js/features" } } }; } }; ================================================ FILE: chapter8/4_component_stories/1_stories/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/4_component_stories/1_stories/ActionLog.stories.jsx ================================================ import React from "react"; import { storiesOf } from "@storybook/react"; import { ActionLog } from "./ActionLog"; const actionLogStories = storiesOf("ActionLog", module); actionLogStories.add("A log of actions", () => { return ( ); }); ================================================ FILE: chapter8/4_component_stories/1_stories/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/4_component_stories/1_stories/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/4_component_stories/1_stories/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/4_component_stories/1_stories/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/4_component_stories/1_stories/ItemForm.stories.jsx ================================================ import React, { useEffect } from "react"; import fetchMock from "fetch-mock"; import { action } from "@storybook/addon-actions"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm"; export default { title: "ItemForm", component: ItemForm, includeStories: ["itemForm"] }; export const itemForm = () => { const ItemFormStory = () => { useEffect(() => { fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200); return () => fetchMock.restore(); }, []); return ; }; return ; }; ================================================ FILE: chapter8/4_component_stories/1_stories/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/4_component_stories/1_stories/ItemList.jsx ================================================ /* @jsx jsx */ import { Transition } from "react-spring/renderprops"; import { css, keyframes, jsx } from "@emotion/core"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; const pulsate = keyframes` 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } `; const almostOutOfStock = css` font-weight: bold; color: red; animation: ${pulsate} 2s infinite; `; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/4_component_stories/1_stories/ItemList.stories.jsx ================================================ import React from "react"; import { withKnobs, object } from "@storybook/addon-knobs"; import { ItemList } from "./ItemList"; export default { title: "ItemList", component: ItemList, includeStories: ["staticItemList", "animatedItems"], decorators: [withKnobs] }; export const staticItemList = () => ( ); export const animatedItems = () => { const knobLabel = "Contents"; const knobDefaultValue = { cheesecake: 2, croissant: 5 }; const itemList = object(knobLabel, knobDefaultValue); return ; }; ================================================ FILE: chapter8/4_component_stories/1_stories/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("highlighting items that are almost out of stock", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const cheesecakeItem = getByText(generateItemText("cheesecake", 2)); expect(cheesecakeItem).toMatchSnapshot(); }); }); ================================================ FILE: chapter8/4_component_stories/1_stories/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/4_component_stories/1_stories/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/4_component_stories/1_stories/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/4_component_stories/1_stories/__snapshots__/ItemList.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ItemList Component highlighting items that are almost out of stock 1`] = ` @keyframes animation-0 { 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } } .emotion-0 { font-weight: bold; color: red; -webkit-animation: animation-0 2s infinite; animation: animation-0 2s infinite; }
                        • Cheesecake - Quantity: 2
                        • `; ================================================ FILE: chapter8/4_component_stories/1_stories/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/4_component_stories/1_stories/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/4_component_stories/1_stories/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/4_component_stories/1_stories/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/4_component_stories/1_stories/jest.config.js ================================================ module.exports = { snapshotSerializers: ["jest-emotion"], setupFilesAfterEnv: [ "/setupJestDom.js", "/setupJestEmotion.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/4_component_stories/1_stories/package.json ================================================ { "name": "1_component_stories", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "storybook": "start-storybook", "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@storybook/addon-actions": "^6.0.28", "@storybook/addon-knobs": "^6.0.28", "@storybook/react": "^6.0.28", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babel-loader": "^8.1.0", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "fetch-mock": "^9.10.3", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "jest-emotion": "^10.0.32", "nock": "^12.0.3" }, "dependencies": { "@emotion/core": "^10.0.28", "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/4_component_stories/1_stories/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/4_component_stories/1_stories/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/4_component_stories/1_stories/setupJestEmotion.js ================================================ const { matchers } = require("jest-emotion"); expect.extend(matchers); ================================================ FILE: chapter8/4_component_stories/1_stories/styles.css ================================================ .almost-out-of-stock { font-weight: bold; color: red; } ================================================ FILE: chapter8/4_component_stories/2_documentation/.storybook/main.js ================================================ module.exports = { stories: ["../**/*.stories.@(jsx|mdx)"], addons: [ "@storybook/addon-knobs/register", "@storybook/addon-actions/register", { name: "@storybook/addon-docs", options: { configureJSX: true } } ], webpackFinal: async config => { return { ...config, resolve: { ...config.resolve, alias: { "core-js/modules": "@storybook/core/node_modules/core-js/modules", "core-js/features": "@storybook/core/node_modules/core-js/features" } } }; } }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ActionLog.jsx ================================================ import React from "react"; export const ActionLog = ({ actions }) => { return (

                          Action Log

                            {actions.map(({ time, message, data }, i) => { const date = new Date(time).toUTCString(); return (
                          • Date: {date} - Message: {message} - Data: {JSON.stringify(data)}
                          • ); })}
                          ); }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ActionLog.stories.jsx ================================================ import React from "react"; import { storiesOf } from "@storybook/react"; import { ActionLog } from "./ActionLog"; const actionLogStories = storiesOf("ActionLog", module); actionLogStories.add("A log of actions", () => { return ( ); }); ================================================ FILE: chapter8/4_component_stories/2_documentation/ActionLog.test.jsx ================================================ import React from "react"; import { ActionLog } from "./ActionLog"; import { render } from "@testing-library/react"; const daysToMs = days => days * 24 * 60 * 60 * 1000; test("logging actions", () => { const actions = [ { time: new Date(daysToMs(1)), message: "Loaded item list", data: { cheesecake: 2, macaroon: 5 } }, { time: new Date(daysToMs(2)), message: "Item added", data: { cheesecake: 2 } }, { time: new Date(daysToMs(3)), message: "Item removed", data: { cheesecake: 1 } }, { time: new Date(daysToMs(4)), message: "Something weird happened", data: { error: "The cheesecake is a lie" } } ]; const { container } = render(); expect(container).toMatchSnapshot(); }); ================================================ FILE: chapter8/4_component_stories/2_documentation/App.jsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { ItemList } from "./ItemList.jsx"; import { ActionLog } from "./ActionLog.jsx"; export const App = () => { const [items, setItems] = useState({}); const [actions, setActions] = useState([]); const isMounted = useRef(null); useEffect(() => { isMounted.current = true; const loadItems = async () => { const response = await fetch(`${API_ADDR}/inventory`); const responseBody = await response.json(); if (isMounted.current) { setItems(responseBody); setActions( actions.concat({ time: new Date().toISOString(), message: "Loaded items from the server", data: { status: response.status, body: responseBody } }) ); } }; loadItems(); return () => (isMounted.current = false); }, []); const updateItems = (itemAdded, addedQuantity) => { const currentQuantity = items[itemAdded] || 0; setItems({ ...items, [itemAdded]: currentQuantity + addedQuantity }); setActions( actions.concat({ time: new Date().toISOString(), message: "Item added", data: { itemAdded, addedQuantity } }) ); }; return (

                          Inventory Contents

                          ); }; ================================================ FILE: chapter8/4_component_stories/2_documentation/App.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { App } from "./App.jsx"; import { generateItemText } from "./ItemList.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; jest.mock("react-spring/renderprops"); beforeEach(() => { nock(API_ADDR) .get("/inventory") .reply(200, { cheesecake: 2, croissant: 5, macaroon: 96 }); }); afterEach(() => { if (!nock.isDone()) { nock.cleanAll(); throw new Error("Not all mocked endpoints received requests."); } }); test("renders the appropriate header", () => { const { getByText } = render(); expect(getByText("Inventory Contents")).toBeInTheDocument(); }); test("rendering the server's list of items", async () => { const { getByText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the list of items with new items", async () => { nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("updating the action log when loading items", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValue("2020-06-20T13:37:00.000Z"); const { getByTestId } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); test("updating the action log adding an item", async () => { jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-20T13:37:00.000Z"); jest .spyOn(Date.prototype, "toISOString") .mockReturnValueOnce("2020-06-21T13:37:00.000Z"); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 6 })) .reply(200); const { getByTestId, getByText, getByPlaceholderText } = render(); await waitFor(() => { const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); }); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "6" } }); fireEvent.click(getByText("Add item")); await waitFor(() => { expect(getByText(generateItemText("cheesecake", 8))).toBeInTheDocument(); }); const actionLog = getByTestId("action-log"); expect(actionLog).toMatchSnapshot(); }); ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemForm.jsx ================================================ import React from "react"; import { API_ADDR } from "./constants"; const addItemRequest = (itemName, quantity) => { fetch(`${API_ADDR}/inventory/${itemName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quantity }) }); }; export const ItemForm = ({ onItemAdded }) => { const [itemName, setItemName] = React.useState(""); const [quantity, setQuantity] = React.useState(0); const onSubmit = async e => { e.preventDefault(); await addItemRequest(itemName, quantity); if (onItemAdded) onItemAdded(itemName, quantity); }; return (
                          setItemName(e.target.value)} placeholder="Item name" /> setQuantity(parseInt(e.target.value, 10))} placeholder="Quantity" />
                          ); }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemForm.stories.jsx ================================================ import React, { useEffect } from "react"; import fetchMock from "fetch-mock"; import { action } from "@storybook/addon-actions"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm"; export default { title: "ItemForm", component: ItemForm, includeStories: ["itemForm"] }; export const itemForm = () => { const ItemFormStory = () => { useEffect(() => { fetchMock.post(`glob:${API_ADDR}/inventory/*`, 200); return () => fetchMock.restore(); }, []); return ; }; return ; }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemForm.test.jsx ================================================ import React from "react"; import nock from "nock"; import { API_ADDR } from "./constants"; import { ItemForm } from "./ItemForm.jsx"; import { render, fireEvent, waitFor } from "@testing-library/react"; test("form's elements", () => { const { getByText, getByPlaceholderText } = render(); expect(getByPlaceholderText("Item name")).toBeInTheDocument(); expect(getByPlaceholderText("Quantity")).toBeInTheDocument(); expect(getByText("Add item")).toBeInTheDocument(); }); test("sending requests", () => { const { getByText, getByPlaceholderText } = render(); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); expect(nock.isDone()).toBe(true); }); test("invoking the onItemAdded callback", async () => { const onItemAdded = jest.fn(); const { getByText, getByPlaceholderText } = render( ); nock(API_ADDR) .post("/inventory/cheesecake", JSON.stringify({ quantity: 2 })) .reply(200); fireEvent.change(getByPlaceholderText("Item name"), { target: { value: "cheesecake" } }); fireEvent.change(getByPlaceholderText("Quantity"), { target: { value: "2" } }); fireEvent.click(getByText("Add item")); await waitFor(() => expect(nock.isDone()).toBe(true)); expect(onItemAdded).toHaveBeenCalledTimes(1); expect(onItemAdded).toHaveBeenCalledWith("cheesecake", 2); }); ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemList.jsx ================================================ /* @jsx jsx */ import { Transition } from "react-spring/renderprops"; import { css, keyframes, jsx } from "@emotion/core"; export const generateItemText = (itemName, quantity) => { const capitalizedItemName = itemName.charAt(0).toUpperCase() + itemName.slice(1); return `${capitalizedItemName} - Quantity: ${quantity}`; }; const pulsate = keyframes` 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } `; const almostOutOfStock = css` font-weight: bold; color: red; animation: ${pulsate} 2s infinite; `; export const ItemList = ({ itemList }) => { const items = Object.entries(itemList); return (
                            itemName} from={{ fontSize: 0, opacity: 0 }} enter={{ fontSize: 18, opacity: 1 }} leave={{ fontSize: 0, opacity: 0 }} > {([itemName, quantity]) => styleProps => (
                          • {generateItemText(itemName, quantity)}
                          • )}
                          ); }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemList.stories.jsx ================================================ import React from "react"; import { withKnobs, object } from "@storybook/addon-knobs"; import { ItemList } from "./ItemList"; export default { title: "ItemList", component: ItemList, includeStories: ["staticItemList", "animatedItems"], decorators: [withKnobs] }; export const staticItemList = () => ( ); export const animatedItems = () => { const knobLabel = "Contents"; const knobDefaultValue = { cheesecake: 2, croissant: 5 }; const itemList = object(knobLabel, knobDefaultValue); return ; }; ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemList.stories.mdx ================================================ import { Meta, Story, Preview } from "@storybook/addon-docs/blocks"; import { ItemList } from "./ItemList"; # Item list The `ItemList` component displays a list of inventory items. It's capable of: - Animating new items - Highlighting items which are about to become unavailable ## Props - An object in which each key represents an item's name, and each value represents its quantity. ================================================ FILE: chapter8/4_component_stories/2_documentation/ItemList.test.jsx ================================================ import React from "react"; import { ItemList, generateItemText } from "./ItemList.jsx"; import { render } from "@testing-library/react"; jest.mock("react-spring/renderprops"); describe("generateItemText", () => { test("generating an item's text", () => { expect(generateItemText("cheesecake", 3)).toBe("Cheesecake - Quantity: 3"); expect(generateItemText("apple pie", 22)).toBe("Apple pie - Quantity: 22"); }); }); describe("ItemList Component", () => { test("list items", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const listElement = document.querySelector("ul"); expect(listElement.childElementCount).toBe(3); expect(getByText(generateItemText("cheesecake", 2))).toBeInTheDocument(); expect(getByText(generateItemText("croissant", 5))).toBeInTheDocument(); expect(getByText(generateItemText("macaroon", 96))).toBeInTheDocument(); }); test("highlighting items that are almost out of stock", () => { const itemList = { cheesecake: 2, croissant: 5, macaroon: 96 }; const { getByText } = render(); const cheesecakeItem = getByText(generateItemText("cheesecake", 2)); expect(cheesecakeItem).toMatchSnapshot(); }); }); ================================================ FILE: chapter8/4_component_stories/2_documentation/__mocks__/react-spring/renderprops.jsx ================================================ const FakeReactSpringTransition = jest.fn(({ items, children }) => { return items.map(item => { return children(item)({ fakeStyles: "fake " }); }); }); export { FakeReactSpringTransition as Transition }; ================================================ FILE: chapter8/4_component_stories/2_documentation/__snapshots__/ActionLog.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`logging actions 1`] = `

                          Action Log

                          • Date: Fri, 02 Jan 1970 00:00:00 GMT - Message: Loaded item list - Data: {"cheesecake":2,"macaroon":5}
                          • Date: Sat, 03 Jan 1970 00:00:00 GMT - Message: Item added - Data: {"cheesecake":2}
                          • Date: Sun, 04 Jan 1970 00:00:00 GMT - Message: Item removed - Data: {"cheesecake":1}
                          • Date: Mon, 05 Jan 1970 00:00:00 GMT - Message: Something weird happened - Data: {"error":"The cheesecake is a lie"}
                          `; ================================================ FILE: chapter8/4_component_stories/2_documentation/__snapshots__/App.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updating the action log adding an item 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          • Date: Sun, 21 Jun 2020 13:37:00 GMT - Message: Item added - Data: {"itemAdded":"cheesecake","addedQuantity":6}
                          `; exports[`updating the action log when loading items 1`] = `

                          Action Log

                          • Date: Sat, 20 Jun 2020 13:37:00 GMT - Message: Loaded items from the server - Data: {"status":200,"body":{"cheesecake":2,"croissant":5,"macaroon":96}}
                          `; ================================================ FILE: chapter8/4_component_stories/2_documentation/__snapshots__/ItemList.test.jsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ItemList Component highlighting items that are almost out of stock 1`] = ` @keyframes animation-0 { 0% { opacity: .3; } 50% { opacity: 1; } 100% { opacity: .3; } } .emotion-0 { font-weight: bold; color: red; -webkit-animation: animation-0 2s infinite; animation: animation-0 2s infinite; }
                        • Cheesecake - Quantity: 2
                        • `; ================================================ FILE: chapter8/4_component_stories/2_documentation/babel.config.js ================================================ module.exports = { presets: [ [ "@babel/preset-env", { targets: { node: "current" } } ], "@babel/preset-react" ] }; ================================================ FILE: chapter8/4_component_stories/2_documentation/constants.js ================================================ export const API_ADDR = "http://localhost:3000"; ================================================ FILE: chapter8/4_component_stories/2_documentation/index.html ================================================ Inventory
                          ================================================ FILE: chapter8/4_component_stories/2_documentation/index.jsx ================================================ import ReactDOM from "react-dom"; import React from "react"; import { App } from "./App.jsx"; ReactDOM.render(, document.getElementById("app")); ================================================ FILE: chapter8/4_component_stories/2_documentation/jest.config.js ================================================ module.exports = { snapshotSerializers: ["jest-emotion"], setupFilesAfterEnv: [ "/setupJestDom.js", "/setupJestEmotion.js", "/setupGlobalFetch.js" ] }; ================================================ FILE: chapter8/4_component_stories/2_documentation/package.json ================================================ { "name": "1_component_stories", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "storybook": "start-storybook", "build": "browserify index.jsx -p esmify -o bundle.js", "start": "http-server ./", "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", "@babel/preset-react": "^7.9.4", "@storybook/addon-actions": "^6.0.28", "@storybook/addon-docs": "^5.3.19", "@storybook/addon-knobs": "^6.0.28", "@storybook/react": "^6.0.28", "@testing-library/dom": "^7.10.1", "@testing-library/jest-dom": "^5.9.0", "@testing-library/react": "^10.2.1", "babel-loader": "^8.1.0", "babelify": "^10.0.0", "browserify": "^16.5.1", "core-js": "^2.6.11", "esmify": "^2.1.1", "fetch-mock": "^9.10.3", "http-server": "^0.12.3", "isomorphic-fetch": "^2.2.1", "jest": "^25.5", "jest-emotion": "^10.0.32", "nock": "^12.0.3", "react-is": "^16.13.1" }, "dependencies": { "@emotion/core": "^10.0.28", "prop-types": "^15.7.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-spring": "^8.0.27" }, "browserify": { "transform": [ [ "babelify", { "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 2 } ], "@babel/preset-react" ] } ] ] } } ================================================ FILE: chapter8/4_component_stories/2_documentation/setupGlobalFetch.js ================================================ const fetch = require("isomorphic-fetch"); global.window.fetch = fetch; ================================================ FILE: chapter8/4_component_stories/2_documentation/setupJestDom.js ================================================ const jestDom = require("@testing-library/jest-dom"); expect.extend(jestDom); ================================================ FILE: chapter8/4_component_stories/2_documentation/setupJestEmotion.js ================================================ const { matchers } = require("jest-emotion"); expect.extend(matchers); ================================================ FILE: chapter8/4_component_stories/2_documentation/styles.css ================================================ .almost-out-of-stock { font-weight: bold; color: red; } ================================================ FILE: chapter8/server/README.md ================================================ # Chapter 5 Server To better support the client-side application we'll build on Chapter 5, I've had to do a few updates to the server from Chapter 4. In case you want to update the back-end from Chapter 4 yourself, here's the list of changes I've done: - For the server to accept the requests coming from the client, you'll need to use [`@koa/cors`](https://github.com/koajs/cors) - To enable running tests while the server is running, I bind it to different ports depending on whether I am in a test or development environment. - At `POST /inventory/:itemName` I have added a route which adds an item to the inventory. It takes a `body` containing the `quantity` to add. - At `GET /inventory` I have added a route which lists all items in the inventory. - At `DELETE /inventory/:itemName` I have added a route which let's you delete inventory items so that you can use to fix the `undo` functionality - I've used `koa-socket-2` to add support for `socket.io` - The `POST /inventory/:itemName` will now push updates to all clients but the one which added an item. ================================================ FILE: chapter8/server/authenticationController.js ================================================ const crypto = require("crypto"); const { db } = require("./dbConnection"); const hashPassword = password => { const hash = crypto.createHash("sha256"); hash.update(password); return hash.digest("hex"); }; const credentialsAreValid = async (username, password) => { const user = await db .select() .from("users") .where({ username }) .first(); if (!user) return false; return hashPassword(password) === user.passwordHash; }; const authenticationMiddleware = async (ctx, next) => { try { const authHeader = ctx.request.headers.authorization; const credentials = Buffer.from( authHeader.slice("basic".length + 1), "base64" ).toString(); const [username, password] = credentials.split(":"); const validCredentialsSent = await credentialsAreValid(username, password); if (!validCredentialsSent) throw new Error("invalid credentials"); } catch (e) { ctx.status = 401; ctx.body = { message: "please provide valid credentials" }; return; } await next(); }; module.exports = { hashPassword, credentialsAreValid, authenticationMiddleware }; ================================================ FILE: chapter8/server/authenticationController.test.js ================================================ const crypto = require("crypto"); const { hashPassword, credentialsAreValid, authenticationMiddleware } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); describe("hashPassword", () => { test("hashing passwords", () => { const plainTextPassword = "password_example"; const hash = crypto.createHash("sha256"); hash.update(plainTextPassword); const expectedHash = hash.digest("hex"); expect(hashPassword(plainTextPassword)).toBe(expectedHash); }); }); describe("credentialsAreValid", () => { test("validating credentials", async () => { expect(await credentialsAreValid(globalUser.username, "a_password")).toBe( true ); }); }); describe("authenticationMiddleware", () => { test("returning an error if the credentials are not valid", async () => { const fakeAuth = Buffer.from("invalid:credentials").toString("base64"); const ctx = { request: { headers: { authorization: `Basic ${fakeAuth}` } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(0); expect(ctx).toEqual({ ...ctx, status: 401, body: { message: "please provide valid credentials" } }); }); test("authenticating properly", async () => { const ctx = { request: { headers: { authorization: globalUser.authHeader } } }; const next = jest.fn(); await authenticationMiddleware(ctx, next); expect(next.mock.calls).toHaveLength(1); }); }); ================================================ FILE: chapter8/server/cartController.js ================================================ const { db } = require("./dbConnection"); const { removeFromInventory } = require("./inventoryController"); const logger = require("./logger"); const addItemToCart = async (username, itemName) => { await removeFromInventory(itemName); const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { const userNotFound = new Error("user not found"); userNotFound.code = 404; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName }) .first(); if (itemEntry && itemEntry.quantity + 1 > 3) { const limitError = new Error( "You can't have more than three units of an item in your cart" ); limitError.code = 400; throw limitError; } if (itemEntry) { await db("carts_items") .increment("quantity") .update({ updatedAt: new Date().toISOString() }) .where({ userId: itemEntry.userId, itemName }); } else { await db("carts_items").insert({ userId: user.id, itemName, quantity: 1, updatedAt: new Date().toISOString() }); } logger.log(`${itemName} added to ${username}'s cart`); return db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }; const hoursInMs = n => 1000 * 60 * 60 * n; const removeStaleItems = async () => { const fourHoursAgo = new Date(Date.now() - hoursInMs(4)).toISOString(); const staleItems = await db .select() .from("carts_items") .where("updatedAt", "<", fourHoursAgo); if (staleItems.length === 0) return; // Put stale items back in the inventory const inventoryUpdates = staleItems.map(staleItem => db("inventory") .increment("quantity", staleItem.quantity) .where({ itemName: staleItem.itemName }) ); await Promise.all(inventoryUpdates); // Delete stale items from cart const staleItemTuples = staleItems.map(i => [i.itemName, i.userId]); await db("carts_items") .del() .whereIn(["itemName", "userId"], staleItemTuples); }; const monitorStaleItems = () => setInterval(removeStaleItems, hoursInMs(2)); module.exports = { addItemToCart, monitorStaleItems }; ================================================ FILE: chapter8/server/cartController.test.js ================================================ const { db } = require("./dbConnection"); const { addItemToCart, monitorStaleItems } = require("./cartController"); const { hashPassword } = require("./authenticationController"); const { user: globalUser } = require("./userTestUtils"); const FakeTimers = require("@sinonjs/fake-timers"); const fs = require("fs"); describe("addItemToCart", () => { beforeEach(() => { fs.writeFileSync("/tmp/logs.out", ""); }); test("adding unavailable items to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error("cheesecake is unavailable"); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.*") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); expect.assertions(2); }); test("adding items above limit to cart", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 3 }); try { await addItemToCart(globalUser.username, "cheesecake"); } catch (e) { const expectedError = new Error( "You can't have more than three units of an item in your cart" ); expectedError.code = 400; expect(e).toEqual(expectedError); } const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([{ itemName: "cheesecake", quantity: 3 }]); expect.assertions(2); }); test("logging added items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); const logs = fs.readFileSync("/tmp/logs.out", "utf-8"); expect(logs).toContain( `cheesecake added to ${globalUser.username}'s cart\n` ); }); }); const withRetries = async fn => { // Capture the assertion error since Jest does not export it const JestAssertionError = (() => { try { expect(false).toBe(true); } catch (e) { return e.constructor; } })(); try { await fn(); } catch (e) { if (e.constructor === JestAssertionError) { // Wait 100ms before retrying await new Promise(resolve => setTimeout(resolve, 100)); await withRetries(fn); } else { throw e; } } }; describe("timers", () => { const hoursInMs = n => 1000 * 60 * 60 * n; let clock; beforeEach(() => { clock = FakeTimers.install({ toFake: ["Date", "setInterval"] }); }); afterEach(() => { clock = clock.uninstall(); }); test("removing stale items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 1 }); await addItemToCart(globalUser.username, "cheesecake"); clock.tick(hoursInMs(4)); timer = monitorStaleItems(); clock.tick(hoursInMs(2)); await withRetries(async () => { const finalCartContent = await db .select() .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); await withRetries(async () => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory"); expect(inventoryContent).toEqual([ { itemName: "cheesecake", quantity: 1 } ]); }); }); }); ================================================ FILE: chapter8/server/dbConnection.js ================================================ const environmentName = process.env.NODE_ENV; const db = require("knex")(require("./knexfile")[environmentName]); const closeConnection = () => db.destroy(); module.exports = { db, closeConnection }; ================================================ FILE: chapter8/server/disconnectFromDb.js ================================================ const { db } = require("./dbConnection"); afterAll(() => db.destroy()); ================================================ FILE: chapter8/server/inventoryController.js ================================================ const { db } = require("./dbConnection"); const removeFromInventory = async itemName => { const inventoryEntry = await db .select() .from("inventory") .where({ itemName }) .first(); if (!inventoryEntry || inventoryEntry.quantity === 0) { const err = new Error(`${itemName} is unavailable`); err.code = 400; throw err; } await db("inventory") .decrement("quantity") .where({ itemName }); }; module.exports = { removeFromInventory }; ================================================ FILE: chapter8/server/jest.config.js ================================================ module.exports = { testEnvironment: "node", globalSetup: "./migrateDatabases.js", setupFilesAfterEnv: [ "/truncateTables.js", "/seedUser.js", "/disconnectFromDb.js" ] }; ================================================ FILE: chapter8/server/knexfile.js ================================================ module.exports = { test: { client: "sqlite3", connection: { filename: "./test.sqlite" }, useNullAsDefault: true }, development: { client: "sqlite3", connection: { filename: "./dev.sqlite" }, useNullAsDefault: true } }; ================================================ FILE: chapter8/server/logger.js ================================================ const fs = require("fs"); const logger = { log: msg => fs.appendFileSync("/tmp/logs.out", msg + "\n") }; module.exports = logger; ================================================ FILE: chapter8/server/migrateDatabases.js ================================================ const environmentName = process.env.NODE_ENV || "test"; const environmentConfig = require("./knexfile")[environmentName]; const db = require("knex")(environmentConfig); module.exports = async () => { // Migrate the database to the latest state await db.migrate.latest(); // Close the connection to the database so that tests won't hang await db.destroy(); }; ================================================ FILE: chapter8/server/migrations/20200325082401_initial_schema.js ================================================ exports.up = async knex => { await knex.schema.createTable("users", table => { table.increments("id"); table.string("username"); table.unique("username"); table.string("email"); table.string("passwordHash"); }); await knex.schema.createTable("carts_items", table => { table.integer("userId").references("users.id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); await knex.schema.createTable("inventory", table => { table.increments("id"); table.string("itemName"); table.unique("itemName"); table.integer("quantity"); }); }; exports.down = async knex => { await knex.schema.dropTable("users"); await knex.schema.dropTable("carts_items"); await knex.schema.dropTable("inventory"); }; ================================================ FILE: chapter8/server/migrations/20200331210311_updatedAt_field.js ================================================ exports.up = knex => { return knex.schema.alterTable("carts_items", table => { table.timestamp("updatedAt"); }); }; exports.down = knex => { return knex.schema.alterTable("carts_items", table => { table.dropColumn("updatedAt"); }); }; ================================================ FILE: chapter8/server/package.json ================================================ { "name": "chapter5_server", "version": "1.0.0", "scripts": { "test": "jest --runInBand", "start": "cross-env NODE_ENV=development node server.js", "migrate:dev": "knex migrate:latest --env development", "seed:dev": "knex seed:run" }, "devDependencies": { "@sinonjs/fake-timers": "github:sinonjs/fake-timers", "jest": "^24.9.0", "supertest": "^4.0.2" }, "dependencies": { "@koa/cors": "^3.0.0", "cross-env": "^7.0.2", "isomorphic-fetch": "^2.2.1", "knex": "^0.20.13", "koa": "^2.11.0", "koa-body-parser": "^1.1.2", "koa-router": "^7.4.0", "koa-socket-2": "^1.2.0", "nock": "^12.0.3", "socket.io": "^2.3.0", "sqlite3": "^4.1.1" }, "main": "alertController.spec.js", "keywords": [], "author": "", "license": "ISC", "description": "" } ================================================ FILE: chapter8/server/seedUser.js ================================================ const { createUser } = require("./userTestUtils"); beforeEach(createUser); ================================================ FILE: chapter8/server/seeds/initial_inventory.js ================================================ exports.seed = async knex => { await knex("inventory").del(); return knex("inventory").insert([ { itemName: "cheesecake", quantity: 8 }, { itemName: "apple pie", quantity: 2 }, { itemName: "carrot cake", quantity: 5 } ]); }; ================================================ FILE: chapter8/server/server.js ================================================ const fetch = require("isomorphic-fetch"); const Koa = require("koa"); const http = require("http"); const IO = require("koa-socket-2"); const cors = require("@koa/cors"); const Router = require("koa-router"); const bodyParser = require("koa-body-parser"); const { db } = require("./dbConnection"); const { addItemToCart } = require("./cartController"); const { hashPassword, authenticationMiddleware } = require("./authenticationController"); const PORT = process.env.NODE_ENV === "test" ? 5000 : 3000; const app = new Koa(); const io = new IO(); io.attach(app); const router = new Router(); app.use(cors()); app.use(bodyParser()); app.use(async (ctx, next) => { if (ctx.url.startsWith("/carts")) { return await authenticationMiddleware(ctx, next); } await next(); }); router.put("/users/:username", async ctx => { const { username } = ctx.params; const { email, password } = ctx.request.body; const userAlreadyExists = await db .select() .from("users") .where({ username }) .first(); if (userAlreadyExists) { ctx.body = { message: `${username} already exists` }; ctx.status = 409; return; } await db("users").insert({ username, email, passwordHash: hashPassword(password) }); return (ctx.body = { message: `${username} created successfully` }); }); router.post("/carts/:username/items", async ctx => { const { username } = ctx.params; const { item, quantity } = ctx.request.body; for (let i = 0; i < quantity; i++) { try { const newItems = await addItemToCart(username, item); ctx.body = newItems; } catch (e) { ctx.body = { message: e.message }; ctx.status = e.code; return; } } }); router.delete("/carts/:username/items/:item", async ctx => { const { username, item } = ctx.params; const user = await db .select() .from("users") .where({ username }) .first(); if (!user) { ctx.body = { message: "user not found" }; ctx.status = 404; return; } const itemEntry = await db .select() .from("carts_items") .where({ userId: user.id, itemName: item }) .first(); if (!itemEntry || itemEntry.quantity === 0) { ctx.body = { message: `${item} is not in the cart` }; ctx.status = 400; return; } await db("carts_items") .decrement("quantity") .where({ userId: user.id, itemName: item }); const inventoryEntry = await db .select() .from("inventory") .where({ itemName: item }) .first(); if (inventoryEntry) { await db("inventory") .increment("quantity") .where({ userId: itemEntry.userId, itemName: item }); } else { await db("inventory").insert({ itemName: item, quantity: 1 }); } ctx.body = await db .select("itemName", "quantity") .from("carts_items") .where({ userId: user.id }); }); router.post("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const clientId = ctx.request.headers["x-socket-client-id"]; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const itemExists = current && current.quantity > 0; const newRecord = { itemName, quantity: (itemExists ? current.quantity : 0) + quantity }; if (current) { await db("inventory") .increment("quantity", quantity) .where({ itemName }); } else { await db("inventory").insert(newRecord); } Object.entries(io.socket.sockets.connected).forEach(([id, socket]) => { if (id === clientId) return; socket.emit("add_item", { itemName, quantity }); }); ctx.body = newRecord; }); router.delete("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const { quantity } = ctx.request.body; const current = await db .select("itemName", "quantity") .from("inventory") .where({ itemName }) .first(); const canDelete = current && current.quantity > quantity; if (canDelete) { await db("inventory") .decrement("quantity", quantity) .where({ itemName }); ctx.body = { message: `Removed ${quantity} units of ${itemName}` }; } else { ctx.status = 404; ctx.body = { message: `There aren't ${quantity} units of ${itemName} available.` }; } }); router.get("/inventory", async ctx => { const inventoryContent = await db .select("itemName", "quantity") .from("inventory") .where("quantity", ">", 0) .orderBy("quantity", "desc"); ctx.body = inventoryContent.reduce((acc, { itemName, quantity }) => { return { ...acc, [itemName]: quantity }; }, {}); }); router.get("/inventory/:itemName", async ctx => { const { itemName } = ctx.params; const response = await fetch(`http://recipepuppy.com/api?i=${itemName}`); const { title, href, results: recipes } = await response.json(); const inventoryItem = await db .select() .from("inventory") .where({ itemName }) .first(); ctx.body = { ...inventoryItem, info: `Data obtained from ${title} - ${href}`, recipes }; }); app.use(router.routes()); module.exports = { app: app.listen(PORT, "127.0.0.1") }; ================================================ FILE: chapter8/server/server.test.js ================================================ const { user: globalUser } = require("./userTestUtils"); const { db } = require("./dbConnection"); const request = require("supertest"); const { app } = require("./server.js"); const { hashPassword } = require("./authenticationController.js"); const nock = require("nock"); afterAll(() => app.close()); describe("add items to a cart", () => { test("adding available items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 3 }); const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 3 }) .expect(200) .expect("Content-Type", /json/); const newItems = [{ itemName: "cheesecake", quantity: 3 }]; expect(response.body).toEqual(newItems); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(newItems); }); test("adding unavailable items", async () => { const response = await request(app) .post(`/carts/${globalUser.username}/items`) .set("authorization", globalUser.authHeader) .send({ item: "cheesecake", quantity: 1 }) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is unavailable" }); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual([]); }); }); describe("removing items from a cart", () => { test("removing existing items", async () => { await db("carts_items").insert({ userId: globalUser.id, itemName: "cheesecake", quantity: 1 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(200) .expect("Content-Type", /json/); const expectedFinalContent = [{ itemName: "cheesecake", quantity: 0 }]; expect(response.body).toEqual(expectedFinalContent); const finalCartContent = await db .select("carts_items.itemName", "carts_items.quantity") .from("carts_items") .join("users", "users.id", "carts_items.userId") .where("users.username", globalUser.username); expect(finalCartContent).toEqual(expectedFinalContent); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(1); }); test("removing non-existing items", async () => { await db("inventory").insert({ itemName: "cheesecake", quantity: 0 }); const response = await request(app) .del(`/carts/${globalUser.username}/items/cheesecake`) .set("authorization", globalUser.authHeader) .expect(400) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "cheesecake is not in the cart" }); const { quantity: inventoryCheesecakes } = await db .select() .from("inventory") .where({ itemName: "cheesecake" }) .first(); expect(inventoryCheesecakes).toEqual(0); }); }); describe("create accounts", () => { test("creating a new account", async () => { const response = await request(app) .put("/users/another_user") .send({ email: "another_user@example.org", password: "a_password" }) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: "another_user created successfully" }); const savedUser = await db .select("email", "passwordHash") .from("users") .where({ username: "another_user" }) .first(); expect(savedUser).toEqual({ email: "another_user@example.org", passwordHash: hashPassword("a_password") }); }); test("creating a duplicate account", async () => { const response = await request(app) .put(`/users/${globalUser.username}`) .send({ email: globalUser.email, password: "a_password" }) .expect(409) .expect("Content-Type", /json/); expect(response.body).toEqual({ message: `${globalUser.username} already exists` }); }); }); describe("list inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; const carrotCake = { itemName: "carrot cake", quantity: 0 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie, carrotCake]); }); test("fetching all available items", async () => { const { body } = await request(app) .get("/inventory") .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ eggs: 3, "apple pie": 1 }); }); }); describe("add inventory items", () => { test("adding a new item", async () => { const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 3 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); test("adding an existing item", async () => { const eggs = { itemName: "eggs", quantity: 2 }; await db("inventory").insert(eggs); const { body } = await request(app) .post("/inventory/eggs") .send({ quantity: 3 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ itemName: "eggs", quantity: 5 }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 5 }); }); }); describe("remove inventory items", () => { beforeEach(async () => { await db("inventory").insert({ itemName: "eggs", quantity: 3 }); }); test("removing an item", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 2 }) .expect(200) .expect("Content-Type", /json/); expect(body).toEqual({ message: "Removed 2 units of eggs" }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 1 }); }); test("removing more than the inventory quantity", async () => { const { body } = await request(app) .del("/inventory/eggs") .send({ quantity: 4 }) .expect(404) .expect("Content-Type", /json/); expect(body).toEqual({ message: "There aren't 4 units of eggs available." }); expect( await db .select("itemName", "quantity") .from("inventory") .where("itemName", "eggs") .first() ).toEqual({ itemName: "eggs", quantity: 3 }); }); }); describe("fetch inventory items", () => { const eggs = { itemName: "eggs", quantity: 3 }; const applePie = { itemName: "apple pie", quantity: 1 }; beforeEach(async () => { await db("inventory").insert([eggs, applePie]); const { id: eggsId } = await db .select() .from("inventory") .where({ itemName: "eggs" }) .first(); eggs.id = eggsId; }); test("fetching an item from the inventory", async () => { const eggsResponse = { title: "FakeAPI", href: "example.org", results: [{ name: "Omelette du Fromage" }] }; nock("http://recipepuppy.com") .get("/api") .query({ i: "eggs" }) .reply(200, eggsResponse); const response = await request(app) .get(`/inventory/eggs`) .expect(200) .expect("Content-Type", /json/); expect(response.body).toEqual({ ...eggs, info: `Data obtained from ${eggsResponse.title} - ${eggsResponse.href}`, recipes: eggsResponse.results }); }); }); ================================================ FILE: chapter8/server/truncateTables.js ================================================ const { db } = require("./dbConnection"); const tablesToTruncate = ["users", "inventory", "carts_items"]; beforeEach(() => { return Promise.all(tablesToTruncate.map(t => db(t).truncate())); }); ================================================ FILE: chapter8/server/userTestUtils.js ================================================ const { db } = require("./dbConnection"); const { hashPassword } = require("./authenticationController"); const username = "test_user"; const password = "a_password"; const passwordHash = hashPassword(password); const email = "test_user@example.org"; const validAuth = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = `Basic ${validAuth}`; const user = { username, password, email, authHeader }; const createUser = async () => { await db("users").insert({ username, email, passwordHash }); const { id } = await db .select() .from("users") .where({ username }) .first(); user.id = id; }; module.exports = { user, createUser }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/1_small_test/calculateCartPrice.js ================================================ const calculateCartPrice = () => 7; module.exports = { calculateCartPrice }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/1_small_test/calculateCartPrice.test.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); test("calculating total values", () => { expect(calculateCartPrice([1, 1, 2, 3])).toBe(7); }); ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/1_small_test/package.json ================================================ { "name": "1_small_test", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/2_partial_test/calculateCartPrice.js ================================================ const calculateCartPrice = prices => { return prices.reduce((sum, price) => { return sum + price; }, 0); }; module.exports = { calculateCartPrice }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/2_partial_test/calculateCartPrice.test.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); test("calculating total values", () => { expect(calculateCartPrice([1, 1, 2, 3])).toBe(7); expect(calculateCartPrice([3, 5, 8])).toBe(16); expect(calculateCartPrice([13, 21])).toBe(34); expect(calculateCartPrice([55])).toBe(55); }); ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/2_partial_test/package.json ================================================ { "name": "2_partial_test", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/3_extra_test/calculateCartPrice.js ================================================ const calculateCartPrice = (prices, discountPercentage) => { const total = prices.reduce((sum, price) => { return sum + price; }, 0); return discountPercentage ? ((100 - discountPercentage) / 100) * total : total; }; module.exports = { calculateCartPrice }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/3_extra_test/calculateCartPrice.test.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); test("calculating total values", () => { expect(calculateCartPrice([1, 1, 2, 3])).toBe(7); expect(calculateCartPrice([3, 5, 8])).toBe(16); expect(calculateCartPrice([13, 21])).toBe(34); expect(calculateCartPrice([55])).toBe(55); }); test("applying a discount", () => { expect(calculateCartPrice([1, 2, 3], 50)).toBe(3); expect(calculateCartPrice([2, 5, 5], 25)).toBe(9); expect(calculateCartPrice([9, 21], 10)).toBe(27); expect(calculateCartPrice([50, 50], 100)).toBe(0); }); ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/3_extra_test/package.json ================================================ { "name": "3_extra_test", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/4_handling_edge_cases/calculateCartPrice.js ================================================ const calculateCartPrice = (prices, discountPercentage) => { const total = prices.reduce((sum, price) => { return sum + price; }, 0); return typeof discountPercentage === "number" && !isNaN(discountPercentage) ? ((100 - discountPercentage) / 100) * total : total; }; module.exports = { calculateCartPrice }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/4_handling_edge_cases/calculateCartPrice.test.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); test("calculating total values", () => { expect(calculateCartPrice([1, 1, 2, 3])).toBe(7); expect(calculateCartPrice([3, 5, 8])).toBe(16); expect(calculateCartPrice([13, 21])).toBe(34); expect(calculateCartPrice([55])).toBe(55); }); test("applying a discount", () => { expect(calculateCartPrice([1, 2, 3], 50)).toBe(3); expect(calculateCartPrice([2, 5, 5], 25)).toBe(9); expect(calculateCartPrice([9, 21], 10)).toBe(27); expect(calculateCartPrice([50, 50], 100)).toBe(0); }); test("handling strings", () => { expect(calculateCartPrice([1, 2, 3], "string")).toBe(6); }); test("handling NaN", () => { expect(calculateCartPrice([1, 2, 3], NaN)).toBe(6); }); ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/1_what_tdd_is/4_handling_edge_cases/package.json ================================================ { "name": "4_handling_edge_cases", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/2_adjusting_iteration_size/1_bigger_steps/calculateCartPrice.js ================================================ const calculateCartPrice = (prices, discountPercentage) => { const total = prices.reduce((sum, price) => { return sum + price; }, 0); return discountPercentage ? ((100 - discountPercentage) / 100) * total : total; }; module.exports = { calculateCartPrice }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/2_adjusting_iteration_size/1_bigger_steps/calculateCartPrice.test.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); test("calculating total values", () => { expect(calculateCartPrice([1, 1, 2, 3])).toBe(7); expect(calculateCartPrice([3, 5, 8])).toBe(16); expect(calculateCartPrice([13, 21])).toBe(34); expect(calculateCartPrice([55])).toBe(55); }); test("applying a discount", () => { expect(calculateCartPrice([1, 2, 3], 50)).toBe(3); expect(calculateCartPrice([2, 5, 5], 25)).toBe(9); expect(calculateCartPrice([9, 21], 10)).toBe(27); expect(calculateCartPrice([50, 50], 100)).toBe(0); }); ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/2_adjusting_iteration_size/1_bigger_steps/package.json ================================================ { "name": "2_adjusting_iteration_size", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/2_adjusting_iteration_size/1_bigger_steps/pickMostExpensive.js ================================================ const { calculateCartPrice } = require("./calculateCartPrice"); const pickMostExpensive = carts => { let mostExpensivePrice = 0; let mostExpensiveCart = null; for (let i = 0; i < carts.length; i++) { const currentCart = carts[i]; const currentCartPrice = calculateCartPrice(currentCart); if (currentCartPrice >= mostExpensivePrice) { mostExpensivePrice = currentCartPrice; mostExpensiveCart = currentCart; } } return mostExpensiveCart; }; module.exports = { pickMostExpensive }; ================================================ FILE: chapter9/1_the_philosophy_behind_tdd/2_adjusting_iteration_size/1_bigger_steps/pickMostExpensive.test.js ================================================ const { pickMostExpensive } = require("./pickMostExpensive"); test("picking the most expensive cart", () => { expect(pickMostExpensive([[3, 2, 1, 4], [5], [50]])).toEqual([50]); expect(pickMostExpensive([[2, 8, 9], [0], [20]])).toEqual([20]); expect(pickMostExpensive([[0], [0], [0]])).toEqual([0]); expect(pickMostExpensive([[], [5], []])).toEqual([5]); }); test("null for an empty cart array", () => { expect(pickMostExpensive([])).toEqual(null); }); ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/1_generating_item_rows/inventoryReport.js ================================================ const generateItemRow = ({ name, quantity, price }) => { if (quantity === 0 || price === 0) return null; return `${name},${quantity},${price},${price * quantity}`; }; module.exports = { generateItemRow }; ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/1_generating_item_rows/inventoryReport.test.js ================================================ const { generateItemRow } = require("./inventoryReport"); test("generating an item's row", () => { expect(generateItemRow({ name: "macaroon", quantity: 12, price: 3 })).toBe( "macaroon,12,3,36" ); expect(generateItemRow({ name: "cheesecake", quantity: 6, price: 12 })).toBe( "cheesecake,6,12,72" ); expect(generateItemRow({ name: "apple pie", quantity: 5, price: 15 })).toBe( "apple pie,5,15,75" ); }); test("ommitting soldout items", () => { expect(generateItemRow({ name: "macaroon", quantity: 0, price: 3 })).toBe( null ); expect(generateItemRow({ name: "cheesecake", quantity: 0, price: 12 })).toBe( null ); }); test("ommitting free items", () => { expect( generateItemRow({ name: "plastic cups", quantity: 99, price: 0 }) ).toBe(null); expect(generateItemRow({ name: "napkins", quantity: 200, price: 0 })).toBe( null ); }); ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/1_generating_item_rows/package.json ================================================ { "name": "1_generating_item_rows", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/2_generating_total_row/inventoryReport.js ================================================ const generateItemRow = ({ name, quantity, price }) => { if (quantity === 0 || price === 0) return null; return `${name},${quantity},${price},${price * quantity}`; }; const generateTotalRow = items => { const total = items.reduce( (t, { price, quantity }) => t + price * quantity, 0 ); return `Total,,,${total}`; }; module.exports = { generateItemRow, generateTotalRow }; ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/2_generating_total_row/inventoryReport.test.js ================================================ const { generateItemRow, generateTotalRow } = require("./inventoryReport"); describe("generateItemRow", () => { test("generating an item's row", () => { expect(generateItemRow({ name: "macaroon", quantity: 12, price: 3 })).toBe( "macaroon,12,3,36" ); expect( generateItemRow({ name: "cheesecake", quantity: 6, price: 12 }) ).toBe("cheesecake,6,12,72"); expect(generateItemRow({ name: "apple pie", quantity: 5, price: 15 })).toBe( "apple pie,5,15,75" ); }); test("ommitting soldout items", () => { expect(generateItemRow({ name: "macaroon", quantity: 0, price: 3 })).toBe( null ); expect( generateItemRow({ name: "cheesecake", quantity: 0, price: 12 }) ).toBe(null); }); test("ommitting free items", () => { expect( generateItemRow({ name: "plastic cups", quantity: 99, price: 0 }) ).toBe(null); expect(generateItemRow({ name: "napkins", quantity: 200, price: 0 })).toBe( null ); }); }); describe("generateTotalRow", () => { test("generating a total row", () => { const items = [ { name: "apple pie", quantity: 3, price: 15 }, { name: "plastic cups", quantity: 0, price: 55 }, { name: "macaroon", quantity: 12, price: 3 }, { name: "cheesecake", quantity: 0, price: 12 } ]; expect(generateTotalRow(items)).toBe("Total,,,81"); expect(generateTotalRow(items.slice(1))).toBe("Total,,,36"); expect(generateTotalRow(items.slice(3))).toBe("Total,,,0"); }); }); ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/2_generating_total_row/package.json ================================================ { "name": "2_generating_total_row", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/3_creating_report/inventoryReport.js ================================================ const fs = require("fs"); const generateItemRow = ({ name, quantity, price }) => { if (quantity === 0 || price === 0) return null; return `${name},${quantity},${price},${price * quantity}`; }; const generateTotalRow = items => { const total = items.reduce( (t, { price, quantity }) => t + price * quantity, 0 ); return `Total,,,${total}`; }; const createInventoryValuesReport = items => { const itemRows = items.map(generateItemRow).join("\n"); const totalRow = generateTotalRow(items); const reportContents = itemRows + "\n" + totalRow; fs.writeFileSync("/tmp/inventoryValues.csv", reportContents); }; module.exports = { generateItemRow, generateTotalRow, createInventoryValuesReport }; ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/3_creating_report/inventoryReport.test.js ================================================ const fs = require("fs"); const { generateItemRow, generateTotalRow, createInventoryValuesReport } = require("./inventoryReport"); describe("generateItemRow", () => { test("generating an item's row", () => { expect(generateItemRow({ name: "macaroon", quantity: 12, price: 3 })).toBe( "macaroon,12,3,36" ); expect( generateItemRow({ name: "cheesecake", quantity: 6, price: 12 }) ).toBe("cheesecake,6,12,72"); expect(generateItemRow({ name: "apple pie", quantity: 5, price: 15 })).toBe( "apple pie,5,15,75" ); }); test("ommitting soldout items", () => { expect(generateItemRow({ name: "macaroon", quantity: 0, price: 3 })).toBe( null ); expect( generateItemRow({ name: "cheesecake", quantity: 0, price: 12 }) ).toBe(null); }); test("ommitting free items", () => { expect( generateItemRow({ name: "plastic cups", quantity: 99, price: 0 }) ).toBe(null); expect(generateItemRow({ name: "napkins", quantity: 200, price: 0 })).toBe( null ); }); }); describe("generateTotalRow", () => { test("generating a total row", () => { const items = [ { name: "apple pie", quantity: 3, price: 15 }, { name: "plastic cups", quantity: 0, price: 55 }, { name: "macaroon", quantity: 12, price: 3 }, { name: "cheesecake", quantity: 0, price: 12 } ]; expect(generateTotalRow(items)).toBe("Total,,,81"); expect(generateTotalRow(items.slice(1))).toBe("Total,,,36"); expect(generateTotalRow(items.slice(3))).toBe("Total,,,0"); }); }); describe("createInventoryValuesReport", () => { test("creating reports", () => { const items = [ { name: "apple pie", quantity: 3, price: 15 }, { name: "cheesecake", quantity: 2, price: 12 }, { name: "macaroon", quantity: 20, price: 3 } ]; createInventoryValuesReport(items); expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe( "apple pie,3,15,45\ncheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,129" ); createInventoryValuesReport(items.slice(1)); expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe( "cheesecake,2,12,24\nmacaroon,20,3,60\nTotal,,,84" ); createInventoryValuesReport(items.slice(2)); expect(fs.readFileSync("/tmp/inventoryValues.csv", "utf8")).toBe( "macaroon,20,3,60\nTotal,,,60" ); }); }); ================================================ FILE: chapter9/2_writing_a_js_module_using_tdd/3_creating_report/package.json ================================================ { "name": "3_creating_report", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "jest": "^26.1.0" } } ================================================ FILE: package.json ================================================ { "name": "testing-javascript-applications", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "lint:fix": "prettier --write .", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/lucasfcosta/testing-javascript-applications.git" }, "keywords": [], "author": "", "license": "ISC", "bugs": { "url": "https://github.com/lucasfcosta/testing-javascript-applications/issues" }, "homepage": "https://github.com/lucasfcosta/testing-javascript-applications#readme", "devDependencies": { "husky": "^3.1.0", "prettier": "^1.19.1", "pretty-quick": "^2.0.1" }, "husky": { "hooks": { "pre-commit": "pretty-quick --staged" } } }