diff --git a/app/controllers/api/abstract_controller.rb b/app/controllers/api/abstract_controller.rb index be21d76ab..9011f3e9f 100644 --- a/app/controllers/api/abstract_controller.rb +++ b/app/controllers/api/abstract_controller.rb @@ -13,6 +13,9 @@ module Api before_action :authenticate_user! skip_before_action :verify_authenticity_token after_action :skip_set_cookies_header + + rescue_from(User::AlreadyVerified) { sorry "Already verified.", 409 } + rescue_from(JWT::VerificationError) { |e| auth_err } rescue_from(ActionDispatch::Http::Parameters::ParseError) do @@ -23,6 +26,7 @@ module Api rescue_from(ActiveRecord::ValueTooLong) do sorry "Please use reasonable lengths on string inputs", 422 end + rescue_from Errors::Forbidden do |exc| sorry "You can't perform that action. #{exc.message}", 403 end diff --git a/app/models/user.rb b/app/models/user.rb index 720ca2327..066928267 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,6 @@ # A human class User < ApplicationRecord + class AlreadyVerified < StandardError; end ENFORCE_TOS = ENV.fetch("TOS_URL") { false } SKIP_EMAIL_VALIDATION = ENV.fetch("NO_EMAILS") { false } validates :email, uniqueness: true @@ -32,4 +33,22 @@ class User < ApplicationRecord def verified? SKIP_EMAIL_VALIDATION ? true : !!confirmed_at end + + BAD_SUB = "SUB was neither string nor number" + class BadSub < StandardError; end # Safe to remove December '17 -RC + def self.find_by_email_or_id(sub) # Safe to remove December '17 -RC + case sub + when Integer then User.find(sub) + # HISTORICAL CONTEXT: We once used emails as a `sub` field. At the time, + # it seemed nice because it was human readable. The problem was that + # emails are mutable. Under this scheme, changing your email address + # would invalidate your JWT. Switching it to user_id (that does not + # change) gets around this issue. We still need to support emails in + # JWTs, atleast for another month or so because it would invalidate + # existing tokens otherwise. + # TODO: Only use user_id (not email) for validation after 25 OCT 17 - RC + when String then User.find_by!(email: sub) + else raise BadSub, BAD_SUB + end + end end diff --git a/app/mutations/auth/from_jwt.rb b/app/mutations/auth/from_jwt.rb index bf50a915b..59fd6cfed 100644 --- a/app/mutations/auth/from_jwt.rb +++ b/app/mutations/auth/from_jwt.rb @@ -7,22 +7,9 @@ module Auth def execute token = SessionToken.decode!(just_the_token) claims = token.unencoded - sub = claims["sub"] - case sub - when Integer then User.find(sub) - # HISTORICAL CONTEXT: We once used emails as a `sub` field. At the time, - # it seemed nice because it was human readable. The problem was that - # emails are mutable. Under this scheme, changing your email address - # would invalidate your JWT. Switching it to user_id (that does not - # change) gets around this issue. We still need to support emails in - # JWTs, atleast for another month or so because it would invalidate - # existing tokens otherwise. - # TODO: Only use user_id (not email) for validation after 25 OCT 17 - RC - when String then User.find_by!(email: sub) - else raise "SUB was neither string nor number" - end - rescue JWT::DecodeError, ActiveRecord::RecordNotFound - add_error :jwt, :decode_error, "JSON Web Token is not valid." + User.find_by_email_or_id(claims["sub"]) + rescue JWT::DecodeError, ActiveRecord::RecordNotFound, User::BadSub + add_error :jwt, :decode_error, Auth::ReloadToken::BAD_SUB end def just_the_token diff --git a/app/mutations/auth/reload_token.rb b/app/mutations/auth/reload_token.rb index 808c5c64c..8751d0f89 100644 --- a/app/mutations/auth/reload_token.rb +++ b/app/mutations/auth/reload_token.rb @@ -2,11 +2,17 @@ module Auth # The API supports a number of authentication strategies (Cookies, Bot token, # JWT). This service helps determine which auth strategy to use. class ReloadToken < Mutations::Command + attr_reader :user + BAD_SUB = "Please log out and try again." + required { string :jwt } + def validate + @user = User.find_by_email_or_id(claims["sub"]) + end + def execute - # Prevent never ending sessions. - security_criticial_danger = claims["exp"] + security_criticial_danger = claims["exp"] # Stop infinite sessions token = SessionToken.issue_to(user, aud: claims["aud"], exp: security_criticial_danger) @@ -17,10 +23,6 @@ module Auth @claims ||= SessionToken.decode!(jwt.split(" ").last).unencoded end - def user - @user ||= User.find(claims["sub"]) - end - def nope add_error :jwt, :decode_error, "JSON Web Token is not valid." end diff --git a/app/mutations/users/verify.rb b/app/mutations/users/verify.rb index 2ae5017fc..b0e619eb2 100644 --- a/app/mutations/users/verify.rb +++ b/app/mutations/users/verify.rb @@ -3,17 +3,21 @@ module Users required { string :token, min_length: 5 } def validate + prevent_token_reuse end def execute - user.confirmed_at = Time.now - # Prevent token reuse: - user.confirmation_token = "" + user.verified_at = Time.now user.save! SessionToken.as_json(user.reload, AbstractJwtToken::HUMAN_AUD) end private + + def prevent_token_reuse + raise User::AlreadyVerified if user.verified_at.present? + end + def user @user ||= User.find_by!(confirmation_token: token) end diff --git a/app/views/dashboard/_common_assets.html.erb b/app/views/dashboard/_common_assets.html.erb index 1897a9829..679253796 100644 --- a/app/views/dashboard/_common_assets.html.erb +++ b/app/views/dashboard/_common_assets.html.erb @@ -10,6 +10,7 @@ window.globalConfig = <%= raw($FRONTEND_SHARED_DATA) %> // SEE COMMENTS!; +<%= javascript_include_tag *webpack_asset_paths("commons") %> <% if false %> diff --git a/config/webpack.dev.js b/config/webpack.dev.js index 5fd2b601d..4308d7182 100644 --- a/config/webpack.dev.js +++ b/config/webpack.dev.js @@ -2,7 +2,7 @@ var devServerPort = 3808; var path = require("path"); var genConfig = require("./webpack.base"); var conf = genConfig(); -var webpack = require("webpack"); + conf.output = { // must match config.webpack.output_dir diff --git a/config/webpack.prod.js b/config/webpack.prod.js index 519b6c2ee..2a63701d1 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -19,7 +19,18 @@ conf.output = { [ new webpack.optimize.CommonsChunkPlugin({ name: "commons", - async: true + chunks: [ + "bundle", + "front_page", + "verification", + "password_reset", + "tos_update" + ], + minChunks: ({ resource }) => ( + resource && + resource.indexOf('node_modules') >= 0 && + resource.match(/\.js$/) + ), }), new ExtractTextPlugin({ // Temporary hotfix for some issues on staging. diff --git a/package.json b/package.json index d41784d90..bdda82a9f 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,8 @@ "@blueprintjs/core": "^1.29.0", "@blueprintjs/datetime": "^1.22.0", "@blueprintjs/labs": "^0.4.0", - "@types/deep-freeze": "^0.1.1", "@types/enzyme": "^2.8.9", "@types/fastclick": "^1.0.28", - "@types/handlebars": "^4.0.36", "@types/history": "^4.6.0", "@types/i18next": "^8.4.2", "@types/jest": "^21.1.2", @@ -41,37 +39,34 @@ "@types/moxios": "^0.4.5", "@types/mqtt": "^0.0.34", "@types/node": "^8.0.34", - "@types/offline-js": "^0.7.28", + "@types/react": "15.0.39", "@types/react-color": "^2.13.2", "@types/react-dom": "^15.5.5", "@types/react-redux": "^4.4.47", "@types/react-router": "3", - "@types/react": "15.0.39", "@types/redux": "^3.6.31", "axios": "^0.16.2", "boxed_value": "^1.0.0", "coveralls": "^3.0.0", "css-loader": "^0.28.7", - "deep-freeze": "^0.0.1", "enzyme": "^2.9.1", "extract-text-webpack-plugin": "^3.0.1", + "farmbot": "5.0.1-rc13", "farmbot-toastr": "^1.0.3", - "farmbot": "5.0.1-rc12", "fastclick": "^1.0.6", "file-loader": "^1.1.5", - "handlebars": "^4.0.10", "i18next": "^9.0.0", "imports-loader": "^0.7.0", "jest": "^21.2.1", "json-loader": "^0.5.7", "lodash": "^4.17.4", - "markdown-it-emoji": "^1.4.0", "markdown-it": "^8.4.0", + "markdown-it-emoji": "^1.4.0", "moment": "^2.19.1", "moxios": "^0.4.0", "node-sass": "^4.5.3", - "offline-js": "^0.7.19", "optimize-css-assets-webpack-plugin": "^3.2.0", + "react": "^15.6.1", "react-addons-css-transition-group": "^15.6.2", "react-addons-test-utils": "^15.6.2", "react-color": "^2.13.8", @@ -79,10 +74,9 @@ "react-redux": "^5.0.6", "react-router": "^3.0.0", "react-test-renderer": "^15.6.1", - "react": "^15.6.1", + "redux": "^3.7.2", "redux-immutable-state-invariant": "^2.1.0", "redux-thunk": "^2.0.1", - "redux": "^3.7.2", "sass-loader": "^6.0.6", "stats-webpack-plugin": "^0.6.1", "style-loader": "^0.19.0", @@ -92,8 +86,8 @@ "tslint": "^5.7.0", "typescript": "^2.5.3", "url-loader": "^0.6.2", - "webpack-uglify-js-plugin": "^1.1.9", "webpack": "^3.7.1", + "webpack-uglify-js-plugin": "^1.1.9", "weinre": "^2.0.0-pre-I0Z7U9OV", "which": "^1.3.0", "yarn": "^1.2.1" diff --git a/spec/controllers/api/tokens/tokens_controller_show_spec.rb b/spec/controllers/api/tokens/tokens_controller_show_spec.rb index afab1cc15..5a859332c 100644 --- a/spec/controllers/api/tokens/tokens_controller_show_spec.rb +++ b/spec/controllers/api/tokens/tokens_controller_show_spec.rb @@ -30,7 +30,26 @@ describe Api::TokensController do it 'denies bad tokens' do request.headers["Authorization"] = "bearer #{auth_token.encoded + "no..."}" get :show - expect(json.dig(:error, :jwt)).to include("not valid") + expect(json.dig(:error, :jwt)).to eq(Auth::ReloadToken::BAD_SUB) + end + + it 'handles bad `sub` claims' do + # Simulate a legacy API token. + token = AbstractJwtToken.new([{ + aud: AbstractJwtToken::HUMAN_AUD, + sub: "this_is_wrong@example.com", + iat: Time.now.to_i, + jti: SecureRandom.uuid, + iss: "whatever", + exp: (Time.now + 40.days).to_i, + mqtt: "", + mqtt_ws: "", + os_update_server: "", + bot: "device_#{user.device.id}" }]) + request.headers["Authorization"] = "bearer #{token.encoded}" + get :show + expect(response.status).to be(401) + expect(json[:error].values).to include(Auth::ReloadToken::BAD_SUB) end end end diff --git a/spec/controllers/api/users/users_controller_spec.rb b/spec/controllers/api/users/users_controller_spec.rb index eb90cc422..4b72ac1be 100644 --- a/spec/controllers/api/users/users_controller_spec.rb +++ b/spec/controllers/api/users/users_controller_spec.rb @@ -111,6 +111,13 @@ describe Api::UsersController do end end + it 'can not re-verify' do + user.update_attributes(verified_at: Time.now) + sign_in user + put :verify, params: { token: user.verification_token }, format: :json + expect(response.status).to eq(409) + end + it 'handles password confirmation mismatch' do email = Faker::Internet.email original_count = User.count diff --git a/spec/controllers/api/users/verification_spec.rb b/spec/controllers/api/users/verification_spec.rb index d8a83119d..744711b70 100644 --- a/spec/controllers/api/users/verification_spec.rb +++ b/spec/controllers/api/users/verification_spec.rb @@ -9,8 +9,8 @@ describe Api::UsersController do expect(user.confirmed_at).to eq(nil) put :verify, params: params user.reload - expect(user.confirmation_token).to eq("") - expect(user.confirmed_at).to be - expect(user.confirmed_at - Time.now).to be < 3 + expect(user.verification_token).to eq(params[:token]) + expect(user.verified_at).to be + expect(user.verified_at - Time.now).to be < 3 end end diff --git a/spec/lib/session_token_spec.rb b/spec/lib/session_token_spec.rb index f32ea8d82..3b258a382 100644 --- a/spec/lib/session_token_spec.rb +++ b/spec/lib/session_token_spec.rb @@ -38,8 +38,10 @@ describe SessionToken do token = SessionToken.issue_to(user, iat: 000, exp: 1, iss: "//lycos.com:9867") result = Auth::FromJWT.run(jwt: token.encoded) expect(result.success?).to be(false) - expect(result.errors.values.first.message).to include("is not valid") + expect(result.errors.values.first.message) + .to eq(Auth::ReloadToken::BAD_SUB) end + unless ENV["NO_EMAILS"] it "doesn't mint tokens for unverified users" do user.update_attributes!(confirmed_at: nil) diff --git a/spec/mutations/auth/from_jwt_spec.rb b/spec/mutations/auth/from_jwt_spec.rb index f17ec27cc..6a62275dd 100644 --- a/spec/mutations/auth/from_jwt_spec.rb +++ b/spec/mutations/auth/from_jwt_spec.rb @@ -26,8 +26,8 @@ describe Auth::FromJWT do end it "crashes when sub is neither string nor Integer" do - t = fake[1.23] - bad_sub = "SUB was neither string nor number" - expect { Auth::FromJWT.run!(jwt: t) }.to raise_error(bad_sub) + expect { + Auth::FromJWT.run!(jwt: fake[1.23]) + }.to raise_error(Auth::ReloadToken::BAD_SUB) end end diff --git a/webpack/__test_support__/fake_state/resources.ts b/webpack/__test_support__/fake_state/resources.ts index 7b38234c9..3dc69b016 100644 --- a/webpack/__test_support__/fake_state/resources.ts +++ b/webpack/__test_support__/fake_state/resources.ts @@ -3,7 +3,7 @@ import { buildResourceIndex } from "../resource_index_builder"; import { TaggedFarmEvent, TaggedSequence, TaggedRegimen, TaggedImage, TaggedTool, TaggedToolSlotPointer, TaggedUser, TaggedWebcamFeed, - TaggedPlantPointer, TaggedGenericPointer + TaggedPlantPointer, TaggedGenericPointer, TaggedPeripheral } from "../../resources/tagged_resources"; import { ExecutableType } from "../../farm_designer/interfaces"; import { fakeResource } from "../fake_resource"; @@ -123,3 +123,11 @@ export function fakeWebcamFeed(): TaggedWebcamFeed { name: "wcf #" + id }); } + +export function fakePeripheral(): TaggedPeripheral { + return fakeResource("peripherals", { + id: idCounter++, + label: "Fake Pin", + pin: 1 + }); +} diff --git a/webpack/__test_support__/fake_state/token.ts b/webpack/__test_support__/fake_state/token.ts index 7b0762b71..aedf132af 100644 --- a/webpack/__test_support__/fake_state/token.ts +++ b/webpack/__test_support__/fake_state/token.ts @@ -1,13 +1,9 @@ -import { Everything } from "../../interfaces"; +import { AuthState } from "../../auth/interfaces"; -export let auth: Everything["auth"] = { +export let auth: AuthState = { "token": { "unencoded": { - "iat": 1495569084, - "jti": "b38915ca-3d7a-4754-8152-d4306b88504c", "iss": "//localhost:3000", - "exp": 1499025084, - "mqtt": "10.0.0.6", "os_update_server": "https://api.github.com/repos/farmbot/" + "farmbot_os/releases/latest" }, @@ -24,7 +20,6 @@ export let auth: Everything["auth"] = { "XV6SGE624zdr7S7mQ6uj7qpa2LMH4P37R3BIB26G7E8xDcVOGqL5Oiwr9DPajBX3zd" + "hXSbH3k4PyxqvPOLYso-R7kjfpOnfFCMfMZLW8TQtg-yj82zs93RP2DHOOx-jxek69" + "tmgNyP3FJaoWHwHW7bXOEv09p3dhNVTCSVNKD9LZczLpuXV7U4oSmL6KLkbzsM6G0P" + - "9rrbJ9ASYaOw" } }; diff --git a/webpack/__tests__/refresh_token_no_test.ts b/webpack/__tests__/refresh_token_no_test.ts index b93dca036..848c40438 100644 --- a/webpack/__tests__/refresh_token_no_test.ts +++ b/webpack/__tests__/refresh_token_no_test.ts @@ -19,7 +19,6 @@ jest.mock("../session", () => { import { maybeRefreshToken } from "../refresh_token"; import { API } from "../api/index"; -import { Session } from "../session"; API.setBaseUrl("http://blah.whatever.party"); @@ -30,8 +29,6 @@ describe("maybeRefreshToken()", () => { token: { encoded: "---", unencoded: { - iat: 123, - jti: "111", iss: "---", exp: 456, mqtt: "---", @@ -39,8 +36,8 @@ describe("maybeRefreshToken()", () => { } } }; - maybeRefreshToken(t).then(() => { - expect(Session.clear).toHaveBeenCalled(); + maybeRefreshToken(t).then((result) => { + expect(result).toBeUndefined(); done(); }); }); diff --git a/webpack/__tests__/refresh_token_ok_test.ts b/webpack/__tests__/refresh_token_ok_test.ts index 15c352ea2..2147ad360 100644 --- a/webpack/__tests__/refresh_token_ok_test.ts +++ b/webpack/__tests__/refresh_token_ok_test.ts @@ -1,14 +1,7 @@ -const mockAuth = (jti = "456"): AuthState => ({ +const mockAuth = (iss = "987"): AuthState => ({ token: { encoded: "---", - unencoded: { - iat: 123, - jti, - iss: "---", - exp: 456, - mqtt: "---", - os_update_server: "---" - } + unencoded: { iss, os_update_server: "---" } } }); @@ -34,8 +27,12 @@ describe("maybeRefreshToken()", () => { it("gives you back your token when things fail", (done) => { maybeRefreshToken(mockAuth("111")) .then((nextToken) => { - expect(nextToken.token.unencoded.jti).toEqual("000"); - done(); + if (nextToken) { + expect(nextToken.token.unencoded.iss).toEqual("000"); + done(); + } else { + fail(); + } }); }); }); diff --git a/webpack/__tests__/verification_support_test.ts b/webpack/__tests__/verification_support_test.ts index b58447fea..ffcf50edd 100644 --- a/webpack/__tests__/verification_support_test.ts +++ b/webpack/__tests__/verification_support_test.ts @@ -2,7 +2,7 @@ jest.mock("../util", () => ({ getParam: () => "STUB_PARAM" })); jest.mock("axios", () => ({ default: { - put: () => ({ data: { FAKE_TOKEN: true } }) + put: jest.fn(() => ({ data: { FAKE_TOKEN: true } })) } })); @@ -23,17 +23,25 @@ jest.mock("../api/api", () => ({ } })); -import { fail, FAILURE_PAGE, attempt } from "../verification_support"; +import { + fail, + FAILURE_PAGE, + attempt +} from "../verification_support"; import { API } from "../api/api"; import { Session } from "../session"; import axios from "axios"; import { getParam } from "../util"; describe("fail()", () => { - it("writes a failure message", () => { + it("writes a failure message - base case", () => { expect(fail).toThrow(); expect(document.documentElement.outerHTML).toContain(FAILURE_PAGE); }); + + it("writes a failure message - base case", () => { + expect(() => fail({ response: { status: 409 } } as any)).toThrow(); + }); }); describe("attempt()", () => { diff --git a/webpack/auth/__tests__/actions_test.ts b/webpack/auth/__tests__/actions_test.ts new file mode 100644 index 000000000..35ed246a3 --- /dev/null +++ b/webpack/auth/__tests__/actions_test.ts @@ -0,0 +1,105 @@ +jest.mock("../../session", () => ({ + Session: { + clear: jest.fn(), + getBool: () => true, + fetchStoredToken: () => ({}) + } +})); + +jest.mock("farmbot-toastr", () => ({ + success: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warning: jest.fn() +})); + +jest.mock("axios", () => ({ + default: { + interceptors: { + response: { use: jest.fn() }, + request: { use: jest.fn() } + }, + post: jest.fn(() => { return Promise.resolve({ data: { foo: "bar" } }); }), + get: jest.fn(() => { return Promise.resolve({ data: { foo: "bar" } }); }), + } +})); + +jest.mock("../../api/api", () => ({ + API: { + setBaseUrl: jest.fn(), + inferPort: () => 443, + current: { + tokensPath: "/api/tokenStub", + usersPath: "/api/userStub" + } + } +})); + +import { Session } from "../../session"; +import { logout, requestToken, requestRegistration, didLogin } from "../actions"; +import { Actions } from "../../constants"; +import { success } from "farmbot-toastr"; +import { API } from "../../api/api"; +import axios from "axios"; +import { AuthState } from "../interfaces"; + +describe("logout()", () => { + it("displays the toast if you are logged out", () => { + const result = logout(); + expect(result.type).toEqual(Actions.LOGOUT); + expect(success).toHaveBeenCalledWith("You have been logged out."); + expect(Session.clear).toHaveBeenCalled(); + }); +}); + +describe("requestToken()", () => { + it("requests an auth token over HTTP", async () => { + const url = "geocities.com"; + const email = "foo@bar.com"; + const password = "password123"; + const result = await requestToken(email, password, url); + + expect(axios.post).toHaveBeenCalledWith("/api/tokenStub", + { user: { email, password } }); + expect(result).toBeTruthy(); + expect(result.data.foo).toEqual("bar"); + expect(API.setBaseUrl).toHaveBeenCalledWith(url); + }); +}); + +describe("requestRegistration", () => { + it("sends registration to the API", async () => { + const inputs = { + email: "foo@bar.co", + password: "password", + password_confirmation: "password", + name: "Paul" + }; + const resp = await requestRegistration(inputs.name, + inputs.email, + inputs.password, + inputs.password_confirmation); + + expect(resp.data.foo).toEqual("bar"); + expect(axios.post).toHaveBeenCalledWith("/api/userStub", { user: inputs }); + }); +}); + +describe("didLogin()", () => { + it("bootstraps the user session", () => { + const mockToken: AuthState = { + token: { + encoded: "---", + unencoded: { iss: "iss", os_update_server: "os_update_server" } + } + }; + const dispatch = jest.fn(); + const result = didLogin(mockToken, dispatch); + expect(result).toBeUndefined(); + + const { iss } = mockToken.token.unencoded; + expect(API.setBaseUrl).toHaveBeenCalledWith(iss); + const actions = dispatch.mock.calls.map(x => x && x[0] && x[0].type); + expect(actions).toContain(Actions.REPLACE_TOKEN); + }); +}); diff --git a/webpack/auth/actions.ts b/webpack/auth/actions.ts index de6cc6446..321a35309 100644 --- a/webpack/auth/actions.ts +++ b/webpack/auth/actions.ts @@ -70,13 +70,8 @@ export function register(name: string, confirmation: string, url: string): Thunk { return dispatch => { - const p = requestRegistration(name, - email, - password, - confirmation, - url); - return p.then(onLogin(dispatch), - onRegistrationErr(dispatch)); + return requestRegistration(name, email, password, confirmation) + .then(onLogin(dispatch), onRegistrationErr(dispatch)); }; } @@ -87,24 +82,17 @@ export function onRegistrationErr(dispatch: Function) { /** Build a JSON object in preparation for an HTTP POST * to registration endpoint */ -function requestRegistration(name: string, +export function requestRegistration(name: string, email: string, password: string, - confirmation: string, - url: string) { - const form = { - user: { - email: email, - password: password, - password_confirmation: confirmation, - name: name - } - }; + password_confirmation: string) { + + const form = { user: { email, password, password_confirmation, name } }; return axios.post(API.current.usersPath, form); } /** Fetch API token if already registered. */ -function requestToken(email: string, +export function requestToken(email: string, password: string, url: string) { const payload = { user: { email: email, password: password } }; diff --git a/webpack/auth/interfaces.ts b/webpack/auth/interfaces.ts index 30d271fb7..31649f799 100644 --- a/webpack/auth/interfaces.ts +++ b/webpack/auth/interfaces.ts @@ -8,16 +8,10 @@ export interface AuthState { } export interface UnencodedToken { - /** ISSUED AT */ - iat: number; - /** JSON TOKEN IDENTIFIER - a serial number for the token. */ - jti: string; /** ISSUER - Where token came from (API URL). */ iss: string; - /** EXPIRATION DATE */ - exp: number; /** MQTT server address */ - mqtt: string; + // mqtt: string; /** Where to download RPi software */ os_update_server: string; } diff --git a/webpack/config/actions.ts b/webpack/config/actions.ts index 1a4c413e5..693468ef8 100644 --- a/webpack/config/actions.ts +++ b/webpack/config/actions.ts @@ -5,10 +5,15 @@ import { maybeRefreshToken } from "../refresh_token"; import { withTimeout } from "../util"; import { AuthState } from "../auth/interfaces"; -export const storeToken = (auth: AuthState, dispatch: Function) => () => { - dispatch(setToken(auth)); - didLogin(auth, dispatch); -}; +export const storeToken = + (old: AuthState, dispatch: Function) => (_new: AuthState | undefined) => { + const t = _new || old; + if (!_new) { + console.warn("Failed to refresh token. Something is wrong."); + } + dispatch(setToken(t)); + didLogin(t, dispatch); + }; /** Amount of time we're willing to wait before concluding that the token is bad * or the API is down. */ @@ -22,8 +27,10 @@ export function ready(): Thunk { return (dispatch, getState) => { const auth = Session.fetchStoredToken() || getState().auth; if (auth) { - withTimeout(MAX_TOKEN_WAIT_TIME, maybeRefreshToken(auth)) - .then(storeToken(auth, dispatch), Session.clear); + const ok = storeToken(auth, dispatch); + const no = () => ok(undefined); + const p = maybeRefreshToken(auth); + withTimeout(MAX_TOKEN_WAIT_TIME, p).then(ok, no); } else { Session.clear(); } diff --git a/webpack/controls/peripherals/__tests__/index_test.tsx b/webpack/controls/peripherals/__tests__/index_test.tsx new file mode 100644 index 000000000..8e2f0cc18 --- /dev/null +++ b/webpack/controls/peripherals/__tests__/index_test.tsx @@ -0,0 +1,74 @@ +const mockError = jest.fn(); +jest.mock("farmbot-toastr", () => ({ + error: mockError +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { Peripherals } from "../index"; +import { bot } from "../../../__test_support__/fake_state/bot"; +import { PeripheralsProps } from "../../../devices/interfaces"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import { fakePeripheral } from "../../../__test_support__/fake_state/resources"; + +describe("", () => { + beforeEach(function () { + jest.clearAllMocks(); + }); + + function fakeProps(): PeripheralsProps { + return { + bot, + peripherals: [fakePeripheral()], + dispatch: jest.fn(), + resources: buildResourceIndex([]), + disabled: false + }; + } + + it("renders", () => { + const wrapper = mount(); + ["Peripherals", "Edit", "Save", "Fake Pin", "1"].map(string => + expect(wrapper.text()).toContain(string)); + const saveButton = wrapper.find("button").at(1); + expect(saveButton.text()).toContain("Save"); + expect(saveButton.props().hidden).toBeTruthy(); + }); + + it("isEditing", () => { + const wrapper = mount(); + expect(wrapper.state().isEditing).toBeFalsy(); + const edit = wrapper.find("button").at(0); + expect(edit.text()).toEqual("Edit"); + edit.simulate("click"); + expect(wrapper.state().isEditing).toBeTruthy(); + }); + + function attemptSave(num: number, error: string) { + const p = fakeProps(); + p.peripherals[0].body.pin = num; + const wrapper = mount(); + const save = wrapper.find("button").at(1); + expect(save.text()).toContain("Save"); + save.simulate("click"); + expect(mockError).toHaveBeenLastCalledWith(error); + } + + it("save attempt: pin number too small", () => { + attemptSave(0, "Pin numbers are required and must be positive and unique."); + }); + + it("save attempt: pin number too large", () => { + attemptSave(9999, "Pin numbers must be less than 1000."); + }); + + it("saves", () => { + const p = fakeProps(); + p.peripherals[0].body.pin = 1; + const wrapper = mount(); + const save = wrapper.find("button").at(1); + expect(save.text()).toContain("Save"); + save.simulate("click"); + expect(p.dispatch).toHaveBeenCalled(); + }); +}); diff --git a/webpack/devices/actions.ts b/webpack/devices/actions.ts index 317b4b9b2..ce5179199 100644 --- a/webpack/devices/actions.ts +++ b/webpack/devices/actions.ts @@ -154,7 +154,7 @@ export let fetchReleases = const version = resp.data.tag_name; const versionWithoutV = version.toLowerCase().replace("v", ""); dispatch({ - type: "FETCH_OS_UPDATE_INFO_OK", + type: Actions.FETCH_OS_UPDATE_INFO_OK, payload: versionWithoutV }); }) diff --git a/webpack/devices/components/__tests__/farmbot_os_settings_test.tsx b/webpack/devices/components/__tests__/farmbot_os_settings_test.tsx index b543530fd..33ccbe872 100644 --- a/webpack/devices/components/__tests__/farmbot_os_settings_test.tsx +++ b/webpack/devices/components/__tests__/farmbot_os_settings_test.tsx @@ -15,14 +15,8 @@ describe("", () => { auth={fakeState().auth as AuthState} />); expect(osSettings.find("input").length).toBe(1); expect(osSettings.find("button").length).toBe(9); - expect(osSettings.text()).toContain("NAME"); - expect(osSettings.text()).toContain("TIME ZONE"); - expect(osSettings.text()).toContain("LAST SEEN"); - expect(osSettings.text()).toContain("FARMBOT OS"); - expect(osSettings.text()).toContain("RESTART FARMBOT"); - expect(osSettings.text()).toContain("SHUTDOWN FARMBOT"); - expect(osSettings.text()).toContain("FACTORY RESET"); - expect(osSettings.text()).toContain("CAMERA"); - expect(osSettings.text()).toContain("FIRMWARE"); + ["NAME", "TIME ZONE", "LAST SEEN", "FARMBOT OS", "RESTART FARMBOT", + "SHUTDOWN FARMBOT", "FACTORY RESET", "CAMERA", "FIRMWARE"].map(string => + expect(osSettings.text()).toContain(string)); }); }); diff --git a/webpack/devices/components/__tests__/hardware_settings_test.tsx b/webpack/devices/components/__tests__/hardware_settings_test.tsx index 4f78a659a..28d55b541 100644 --- a/webpack/devices/components/__tests__/hardware_settings_test.tsx +++ b/webpack/devices/components/__tests__/hardware_settings_test.tsx @@ -3,6 +3,7 @@ import { mount } from "enzyme"; import { HardwareSettings } from "../hardware_settings"; import { fakeState } from "../../../__test_support__/fake_state"; import { ControlPanelState } from "../../interfaces"; +import { Actions } from "../../../constants"; describe("", () => { beforeEach(() => { @@ -23,9 +24,8 @@ describe("", () => { controlPanelState={panelState()} dispatch={jest.fn()} bot={fakeState().bot} />); - expect(wrapper.text().toLowerCase()).toContain("expand all"); - expect(wrapper.text().toLowerCase()).toContain("x axis"); - expect(wrapper.text().toLowerCase()).toContain("motors"); + ["expand all", "x axis", "motors"].map(string => + expect(wrapper.text().toLowerCase()).toContain(string)); }); function checkDispatch( @@ -46,14 +46,17 @@ describe("", () => { } it("expands all", () => { - checkDispatch("button", 1, "expand all", "BULK_TOGGLE_CONTROL_PANEL", true); + checkDispatch("button", 1, "expand all", + Actions.BULK_TOGGLE_CONTROL_PANEL, true); }); it("collapses all", () => { - checkDispatch("button", 2, "collapse all", "BULK_TOGGLE_CONTROL_PANEL", false); + checkDispatch("button", 2, "collapse all", + Actions.BULK_TOGGLE_CONTROL_PANEL, false); }); it("toggles motor category", () => { - checkDispatch("h4", 1, "motors", "TOGGLE_CONTROL_PANEL_OPTION", "motors"); + checkDispatch("h4", 1, "motors", + Actions.TOGGLE_CONTROL_PANEL_OPTION, "motors"); }); }); diff --git a/webpack/farm_designer/farm_events/__tests__/add_farm_event_test.tsx b/webpack/farm_designer/farm_events/__tests__/add_farm_event_test.tsx new file mode 100644 index 000000000..4d836b27a --- /dev/null +++ b/webpack/farm_designer/farm_events/__tests__/add_farm_event_test.tsx @@ -0,0 +1,51 @@ +jest.mock("react-redux", () => ({ + connect: jest.fn() +})); + +jest.mock("../../../history", () => ({ + history: { + push: jest.fn() + } +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { AddFarmEvent } from "../add_farm_event"; +import { AddEditFarmEventProps } from "../../interfaces"; +import { fakeFarmEvent, fakeSequence } from "../../../__test_support__/fake_state/resources"; + +describe("", () => { + function fakeProps(): AddEditFarmEventProps { + const sequence = fakeSequence(); + sequence.body.id = 1; + const farmEvent = fakeFarmEvent("Sequence", 1); + farmEvent.uuid = "farm_events"; + return { + deviceTimezone: "", + dispatch: jest.fn(), + regimensById: {}, + sequencesById: { "1": sequence }, + farmEventsById: { "1": farmEvent }, + executableOptions: [], + repeatOptions: [], + formatDate: jest.fn(), + formatTime: jest.fn(), + handleTime: jest.fn(), + farmEvents: [farmEvent], + getFarmEvent: () => farmEvent, + findExecutable: () => sequence + }; + } + + it("renders", () => { + const wrapper = mount(); + wrapper.setState({ uuid: "farm_events" }); + ["Add Farm Event", "Sequence or Regimen", "fake"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("redirects", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("Loading"); + }); +}); diff --git a/webpack/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx b/webpack/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx new file mode 100644 index 000000000..0863f3117 --- /dev/null +++ b/webpack/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx @@ -0,0 +1,50 @@ +jest.mock("react-redux", () => ({ + connect: jest.fn() +})); + +jest.mock("../../../history", () => ({ + history: { + push: jest.fn() + } +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { EditFarmEvent } from "../edit_farm_event"; +import { AddEditFarmEventProps } from "../../interfaces"; +import { fakeFarmEvent, fakeSequence } from "../../../__test_support__/fake_state/resources"; + +describe("", () => { + function fakeProps(): AddEditFarmEventProps { + const sequence = fakeSequence(); + sequence.body.id = 1; + return { + deviceTimezone: "", + dispatch: jest.fn(), + regimensById: {}, + sequencesById: { "1": sequence }, + farmEventsById: { "1": fakeFarmEvent("Sequence", 1) }, + executableOptions: [], + repeatOptions: [], + formatDate: jest.fn(), + formatTime: jest.fn(), + handleTime: jest.fn(), + farmEvents: [], + getFarmEvent: () => fakeFarmEvent("Sequence", 1), + findExecutable: () => sequence + }; + } + + it("renders", () => { + const wrapper = mount(); + ["Edit Farm Event", "Sequence or Regimen", "fake"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("redirects", () => { + const p = fakeProps(); + p.getFarmEvent = jest.fn(); + const wrapper = mount(); + expect(wrapper.text()).toContain("Loading"); + }); +}); diff --git a/webpack/farm_designer/farm_events/__tests__/map_state_to_props_test.ts b/webpack/farm_designer/farm_events/__tests__/map_state_to_props_test.ts new file mode 100644 index 000000000..978760456 --- /dev/null +++ b/webpack/farm_designer/farm_events/__tests__/map_state_to_props_test.ts @@ -0,0 +1,107 @@ +import { mapStateToProps } from "../map_state_to_props"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { + fakeSequence, + fakeRegimen, + fakeFarmEvent +} from "../../../__test_support__/fake_state/resources"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import * as moment from "moment"; + +describe("mapStateToProps()", () => { + function testState(time: number) { + const sequence = fakeSequence(); + sequence.body.id = 1; + sequence.body.body = [{ kind: "take_photo", args: {} }]; + const regimen = fakeRegimen(); + regimen.body.id = 1; + regimen.body.regimen_items = [{ + sequence_id: 1, + time_offset: moment(time).add(10, "minutes") + .diff(moment(time).clone().startOf("day"), "milliseconds") + }]; + + const getFutureTime = (t: number, value: number, label: string) => + // tslint:disable-next-line:no-any + moment(t).add(value as any, label).toISOString(); + + const sequenceFarmEvent = fakeFarmEvent("Sequence", 1); + sequenceFarmEvent.body.id = 1; + const plusOneDay = moment(getFutureTime(time, 1, "day")).valueOf(); + sequenceFarmEvent.body.start_time = getFutureTime(plusOneDay, 2, "minutes"); + sequenceFarmEvent.body.end_time = getFutureTime(plusOneDay, 3, "minutes"); + + const regimenFarmEvent = fakeFarmEvent("Regimen", 1); + regimenFarmEvent.body.id = 2; + const plusTwoDays = moment(getFutureTime(time, 2, "days")).valueOf(); + regimenFarmEvent.body.start_time = getFutureTime(plusTwoDays, 1, "minute"); + regimenFarmEvent.body.end_time = getFutureTime(plusTwoDays, 2, "minutes"); + + const fakeResources = [ + sequence, + regimen, + sequenceFarmEvent, + regimenFarmEvent + ]; + + const state = fakeState(); + state.resources = buildResourceIndex(fakeResources); + return state; + } + + it("returns calendar rows", () => { + const testTime = moment().startOf("hour").valueOf(); + const { calendarRows, push } = mapStateToProps(testState(testTime)); + + // Day 1: Sequence Farm Event + const day1 = calendarRows[0]; + const day1Time = moment(testTime).add(1, "day"); + expect(day1.year).toEqual(day1Time.year() - 2000); + expect(day1.month).toEqual(day1Time.format("MMM")); + expect(day1.day).toEqual(day1Time.date()); + const day1ItemTime = day1Time.add(2, "minutes"); + expect(day1.sortKey).toEqual(day1ItemTime.unix()); + expect(day1.items).toEqual([{ + executableId: 1, + heading: "fake", + id: 1, + mmddyy: day1ItemTime.format("MMDDYY"), + sortKey: day1ItemTime.unix(), + timeStr: day1ItemTime.format("hh:mma") + }]); + + // Day 2: Regimen Farm Event + const day2 = calendarRows[1]; + const day2Time = moment(testTime).add(2, "days"); + expect(day2.year).toEqual(day2Time.year() - 2000); + expect(day2.month).toEqual(day2Time.format("MMM")); + expect(day2.day).toEqual(day2Time.date()); + + // Regimen + const regimenStartTime = day2Time.clone().add(1, "minutes"); + expect(day2.sortKey).toEqual(regimenStartTime.unix()); + expect(day2.items[0]).toEqual({ + executableId: 1, + heading: "Foo", + id: 2, + mmddyy: regimenStartTime.format("MMDDYY"), + sortKey: regimenStartTime.unix(), + subheading: "", + timeStr: regimenStartTime.format("hh:mma") + }); + + // Regimen Item + const regimenItemTime = day2Time.clone().add(10, "minutes"); + expect(day2.items[1]).toEqual({ + executableId: 1, + heading: "Foo", + id: 2, + mmddyy: regimenItemTime.format("MMDDYY"), + sortKey: regimenItemTime.unix(), + subheading: "fake", + timeStr: regimenItemTime.format("hh:mma") + }); + + expect(push).toBeTruthy(); + }); +}); diff --git a/webpack/farm_designer/map/__tests__/spread_overlap_helper_test.tsx b/webpack/farm_designer/map/__tests__/spread_overlap_helper_test.tsx index 8428be376..0687d853c 100644 --- a/webpack/farm_designer/map/__tests__/spread_overlap_helper_test.tsx +++ b/webpack/farm_designer/map/__tests__/spread_overlap_helper_test.tsx @@ -139,9 +139,8 @@ describe("SpreadOverlapHelper functions", () => { it("overlapText()", () => { const spreadData = { active: 100, inactive: 200 }; const svgText = shallow(overlapText(100, 100, 150, spreadData)); - expect(svgText.text()).toContain("Active: 80%"); - expect(svgText.text()).toContain("Inactive: 40%"); - expect(svgText.text()).toContain("orange"); + ["Active: 80%", "Inactive: 40%", "orange"].map(string => + expect(svgText.text()).toContain(string)); }); }); diff --git a/webpack/farm_designer/map/layers/__tests__/drag_helper_layer_test.tsx b/webpack/farm_designer/map/layers/__tests__/drag_helper_layer_test.tsx index 13c087f4f..f7f3b19f2 100644 --- a/webpack/farm_designer/map/layers/__tests__/drag_helper_layer_test.tsx +++ b/webpack/farm_designer/map/layers/__tests__/drag_helper_layer_test.tsx @@ -21,10 +21,11 @@ describe("", () => { it("shows drag helpers", () => { const p = fakeProps(); const wrapper = shallow(); - expect(wrapper.html()).toContain("drag-helpers"); - expect(wrapper.html()).toContain("coordinates-tooltip"); - expect(wrapper.html()).toContain("long-crosshair"); - expect(wrapper.html()).toContain("short-crosshair"); + ["drag-helpers", + "coordinates-tooltip", + "long-crosshair", + "short-crosshair"].map(string => + expect(wrapper.html()).toContain(string)); }); it("doesn't show drag helpers", () => { diff --git a/webpack/farm_designer/map/layers/__tests__/plant_layer_test.tsx b/webpack/farm_designer/map/layers/__tests__/plant_layer_test.tsx index af0e8dacd..7599b4ae5 100644 --- a/webpack/farm_designer/map/layers/__tests__/plant_layer_test.tsx +++ b/webpack/farm_designer/map/layers/__tests__/plant_layer_test.tsx @@ -35,13 +35,15 @@ describe("", () => { const wrapper = shallow(); const layer = wrapper.find("#plant-layer"); expect(layer.find(".plant-link-wrapper").length).toEqual(1); - expect(layer.html()).toContain("soil-cloud"); - expect(layer.html()).toContain("plant-icon"); - expect(layer.html()).toContain("image visibility=\"visible\""); - expect(layer.html()).toContain("/app-resources/img/generic-plant.svg"); - expect(layer.html()).toContain("height=\"50\" width=\"50\" x=\"75\" y=\"175\""); - expect(layer.html()).toContain("drag-helpers"); - expect(layer.html()).toContain("plant-icon"); + ["soil-cloud", + "plant-icon", + "image visibility=\"visible\"", + "/app-resources/img/generic-plant.svg", + "height=\"50\" width=\"50\" x=\"75\" y=\"175\"", + "drag-helpers", + "plant-icon" + ].map(string => + expect(layer.html()).toContain(string)); }); it("toggles visibility off", () => { diff --git a/webpack/farm_designer/plants/__tests__/plant_inventory_item_test.tsx b/webpack/farm_designer/plants/__tests__/plant_inventory_item_test.tsx index 66873d13d..608df52e4 100644 --- a/webpack/farm_designer/plants/__tests__/plant_inventory_item_test.tsx +++ b/webpack/farm_designer/plants/__tests__/plant_inventory_item_test.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { PlantInventoryItem } from "../plant_inventory_item"; import { shallow } from "enzyme"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; +import { Actions } from "../../../constants"; describe("", () => { it("renders", () => { @@ -24,7 +25,7 @@ describe("", () => { icon: "", plantUUID: "points.1.17" }, - type: "TOGGLE_HOVERED_PLANT" + type: Actions.TOGGLE_HOVERED_PLANT }); }); @@ -40,7 +41,7 @@ describe("", () => { icon: "", plantUUID: undefined }, - type: "TOGGLE_HOVERED_PLANT" + type: Actions.TOGGLE_HOVERED_PLANT }); }); }); diff --git a/webpack/farm_designer/plants/__tests__/plant_inventory_test.tsx b/webpack/farm_designer/plants/__tests__/plant_inventory_test.tsx index 723e68f79..f500ccbce 100644 --- a/webpack/farm_designer/plants/__tests__/plant_inventory_test.tsx +++ b/webpack/farm_designer/plants/__tests__/plant_inventory_test.tsx @@ -13,8 +13,12 @@ describe("", () => { ); - expect(wrapper.text()).toContain("DesignerPlantsFarm Events"); - expect(wrapper.text()).toContain("Strawberry Plant 11 days old"); + ["Designer", + "Plants", + "Farm Events", + "Strawberry Plant", + "11 days old" + ].map(string => expect(wrapper.text()).toContain(string)); expect(wrapper.find("input").props().placeholder) .toEqual("Search your plants..."); }); diff --git a/webpack/farm_designer/plants/__tests__/select_plants_test.tsx b/webpack/farm_designer/plants/__tests__/select_plants_test.tsx index f7c871e07..e78c6da0b 100644 --- a/webpack/farm_designer/plants/__tests__/select_plants_test.tsx +++ b/webpack/farm_designer/plants/__tests__/select_plants_test.tsx @@ -38,9 +38,8 @@ describe("", () => { const p = fakeProps(); p.selected = ["plant.1", "plant.2"]; const wrapper = mount(); - expect(wrapper.text()).toContain("Strawberry"); - expect(wrapper.text()).toContain("Blueberry"); - expect(wrapper.text()).toContain("Delete"); + ["Strawberry", "Blueberry", "Delete"].map(string => + expect(wrapper.text()).toContain(string)); }); it("displays no selected plants: selection empty", () => { diff --git a/webpack/farmware/__tests__/farmware_forms_test.tsx b/webpack/farmware/__tests__/farmware_forms_test.tsx index 99ed646f1..e152e4243 100644 --- a/webpack/farmware/__tests__/farmware_forms_test.tsx +++ b/webpack/farmware/__tests__/farmware_forms_test.tsx @@ -49,9 +49,8 @@ describe("", () => { const wrapper = mount(); - expect(wrapper.text()).toContain("My Farmware"); - expect(wrapper.text()).toContain("version: 0.0.0"); - expect(wrapper.text()).toContain("Does things."); + ["My Farmware", "version: 0.0.0", "Does things."].map(string => + expect(wrapper.text()).toContain(string)); expect(wrapper.find("label").last().text()).toContain("Config 1"); expect(wrapper.find("input").props().value).toEqual("4"); }); diff --git a/webpack/farmware/__tests__/farmware_test.tsx b/webpack/farmware/__tests__/farmware_test.tsx index 0e50ace86..18f13445d 100644 --- a/webpack/farmware/__tests__/farmware_test.tsx +++ b/webpack/farmware/__tests__/farmware_test.tsx @@ -25,9 +25,11 @@ describe("", () => { images: [] }; const wrapper = mount(); - expect(wrapper.text()).toContain("Take Photo"); - expect(wrapper.text()).toContain("Farmware"); - expect(wrapper.text()).toContain("Camera Calibration"); - expect(wrapper.text()).toContain("Weed Detector"); + ["Take Photo", + "Farmware", + "Camera Calibration", + "Weed Detector" + ].map(string => + expect(wrapper.text()).toContain(string)); }); }); diff --git a/webpack/farmware/camera_calibration/__tests__/camera_calibration_test.tsx b/webpack/farmware/camera_calibration/__tests__/camera_calibration_test.tsx index 53e51fa54..d86ae8186 100644 --- a/webpack/farmware/camera_calibration/__tests__/camera_calibration_test.tsx +++ b/webpack/farmware/camera_calibration/__tests__/camera_calibration_test.tsx @@ -21,12 +21,14 @@ describe("", () => { V_HI: 9 }; const wrapper = mount(); - expect(wrapper.text()).toContain("Camera Calibration"); - expect(wrapper.text()).toContain("Color Range"); - expect(wrapper.text()).toContain("HUE017947"); - expect(wrapper.text()).toContain("SATURATION025558"); - expect(wrapper.text()).toContain("VALUE025569"); - expect(wrapper.text()).toContain("Processing Parameters"); - expect(wrapper.text()).toContain("Scan image"); + ["Camera Calibration", + "Color Range", + "HUE017947", + "SATURATION025558", + "VALUE025569", + "Processing Parameters", + "Scan image" + ].map(string => + expect(wrapper.text()).toContain(string)); }); }); diff --git a/webpack/farmware/weed_detector/__tests__/weed_detector_test.tsx b/webpack/farmware/weed_detector/__tests__/weed_detector_test.tsx index 40d814715..5a7c242ae 100644 --- a/webpack/farmware/weed_detector/__tests__/weed_detector_test.tsx +++ b/webpack/farmware/weed_detector/__tests__/weed_detector_test.tsx @@ -19,12 +19,14 @@ describe("", () => { images: [] }; const wrapper = mount(); - expect(wrapper.text()).toContain("Weed Detector"); - expect(wrapper.text()).toContain("Color Range"); - expect(wrapper.text()).toContain("HUE01793090"); - expect(wrapper.text()).toContain("SATURATION025550255"); - expect(wrapper.text()).toContain("VALUE025550255"); - expect(wrapper.text()).toContain("Processing Parameters"); - expect(wrapper.text()).toContain("Scan image"); + ["Weed Detector", + "Color Range", + "HUE01793090", + "SATURATION025550255", + "VALUE025550255", + "Processing Parameters", + "Scan image" + ].map(string => + expect(wrapper.text()).toContain(string)); }); }); diff --git a/webpack/password_reset/__tests__/on_init_test.ts b/webpack/password_reset/__tests__/on_init_test.ts new file mode 100644 index 000000000..5dfdac3f1 --- /dev/null +++ b/webpack/password_reset/__tests__/on_init_test.ts @@ -0,0 +1,15 @@ +jest.mock("react-dom", () => ({ render: jest.fn() })); + +import { onInit } from "../on_init"; +import { render } from "react-dom"; + +describe("onInit()", () => { + it("Attaches to a DOM element", async (done) => { + await onInit({}, jest.fn()); + expect({}).toBeTruthy(); + expect(render).toHaveBeenCalled(); + const [calls] = (render as jest.Mock<{}>).mock.calls; + expect(calls[0].type.name).toBe("PasswordReset"); + done(); + }); +}); diff --git a/webpack/password_reset/index.tsx b/webpack/password_reset/index.tsx index e4403d141..c34206f40 100644 --- a/webpack/password_reset/index.tsx +++ b/webpack/password_reset/index.tsx @@ -1,23 +1,6 @@ -import * as React from "react"; import "../css/_index.scss"; import { detectLanguage } from "../i18n"; +import * as I18n from "i18next"; +import { onInit } from "./on_init"; -detectLanguage().then(async (config) => { - const i18next = await import("i18next"); - const { render } = await import("react-dom"); - const { PasswordReset } = await import("./password_reset"); - i18next.init(config, (err, t) => { - const node = document.createElement("DIV"); - node.id = "root"; - document.body.appendChild(node); - - const reactElem = React.createElement(PasswordReset, {}); - const domElem = document.getElementById("root"); - - if (domElem) { - render(reactElem, domElem); - } else { - throw new Error(t("Add a div with id `root` to the page first.")); - } - }); -}); +detectLanguage().then(async config => I18n.init(config, onInit)); diff --git a/webpack/password_reset/on_init.ts b/webpack/password_reset/on_init.ts new file mode 100644 index 000000000..01f80f2c3 --- /dev/null +++ b/webpack/password_reset/on_init.ts @@ -0,0 +1,18 @@ +import * as I18n from "i18next"; + +export const MISSING_DIV = "Add a div with id `root` to the page first."; + +export const onInit: I18n.Callback = async (err, t) => { + const React = await import("react"); + const { render } = await import("react-dom"); + const { PasswordReset } = await import("./password_reset"); + const { bail } = await import("../util"); + const node = document.createElement("DIV"); + node.id = "root"; + document.body.appendChild(node); + + const reactElem = React.createElement(PasswordReset, {}); + const domElem = document.getElementById("root"); + + return (domElem) ? render(reactElem, domElem) : bail(MISSING_DIV); +}; diff --git a/webpack/refresh_token.ts b/webpack/refresh_token.ts index 55845ded7..4ee93db03 100644 --- a/webpack/refresh_token.ts +++ b/webpack/refresh_token.ts @@ -3,7 +3,6 @@ import { API } from "./api/index"; import { AuthState } from "./auth/interfaces"; import { HttpData } from "./util"; import { setToken } from "./auth/actions"; -import { Session } from "./session"; type Resp = HttpData; @@ -15,8 +14,11 @@ const ok = (x: Resp) => { /** Grab a new token from the API (won't extend token's exp. date). * Redirect to home page on failure. */ -export let maybeRefreshToken = (old: AuthState): Promise => { - API.setBaseUrl(old.token.unencoded.iss); - setToken(old); // Precaution: The Axios interceptors might not be set yet. - return axios.get(API.current.tokensPath).then(ok, Session.clear); -}; +export let maybeRefreshToken + = (old: AuthState): Promise => { + API.setBaseUrl(old.token.unencoded.iss); + setToken(old); // Precaution: The Axios interceptors might not be set yet. + return axios + .get(API.current.tokensPath) + .then(ok, () => Promise.resolve(undefined)); + }; diff --git a/webpack/regimens/__tests__/actions_test.ts b/webpack/regimens/__tests__/actions_test.ts new file mode 100644 index 000000000..2344aec61 --- /dev/null +++ b/webpack/regimens/__tests__/actions_test.ts @@ -0,0 +1,90 @@ +import { editRegimen, saveRegimen, deleteRegimen, selectRegimen } from "../actions"; +import { fakeRegimen } from "../../__test_support__/fake_state/resources"; +import { Actions } from "../../constants"; +import { fakeState } from "../../__test_support__/fake_state"; +import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; + +describe("editRegimen()", () => { + it("doesn't call edit", () => { + const dispatch = jest.fn(); + editRegimen(undefined, {})(dispatch); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("calls edit", () => { + const dispatch = jest.fn(); + const regimen = fakeRegimen(); + regimen.uuid = "regimens"; + editRegimen(regimen, {})(dispatch); + expect(dispatch).toHaveBeenCalledWith({ + payload: { + update: {}, + uuid: "regimens" + }, + type: Actions.EDIT_RESOURCE + }); + }); +}); + +describe("saveRegimen()", () => { + it("calls save", () => { + const dispatch = jest.fn(); + const state = fakeState(); + state.resources.index = buildResourceIndex([fakeRegimen()]).index; + const getState = () => state; + saveRegimen(state.resources.index.all[0])(dispatch, getState); + expect(dispatch).toHaveBeenCalledWith({ + payload: { + body: { color: "red", name: "Foo", regimen_items: [] }, + kind: "regimens", + uuid: state.resources.index.all[0] + }, + type: Actions.SAVE_RESOURCE_START + }); + }); +}); + +describe("deleteRegimen()", () => { + it("doesn't delete regimen", () => { + const dispatch = jest.fn(); + const state = fakeState(); + state.resources.index = buildResourceIndex([fakeRegimen()]).index; + const getState = () => state; + deleteRegimen(state.resources.index.all[0])(dispatch, getState); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("calls destroy", () => { + const dispatch = jest.fn(); + const state = fakeState(); + state.resources.index = buildResourceIndex([fakeRegimen()]).index; + const getState = () => state; + // tslint:disable-next-line:no-any + (global as any).confirm = () => true; + deleteRegimen(state.resources.index.all[0])(dispatch, getState); + expect(dispatch).toHaveBeenCalledWith({ + payload: { + body: { color: "red", name: "Foo", regimen_items: [] }, + kind: "regimens", + uuid: state.resources.index.all[0] + }, + type: Actions.DESTROY_RESOURCE_OK + }); + }); +}); + +describe("selectRegimen()", () => { + it("selects regimen", () => { + const regimen = fakeRegimen(); + regimen.uuid = "regimens"; + const action = selectRegimen(regimen); + expect(action).toEqual({ + payload: { + body: { color: "red", name: "Foo", regimen_items: [] }, + kind: "regimens", + uuid: "regimens" + }, + type: Actions.SELECT_REGIMEN + }); + }); +}); diff --git a/webpack/regimens/__tests__/index_test.tsx b/webpack/regimens/__tests__/index_test.tsx new file mode 100644 index 000000000..d15aa1945 --- /dev/null +++ b/webpack/regimens/__tests__/index_test.tsx @@ -0,0 +1,32 @@ +jest.mock("react-redux", () => ({ + connect: jest.fn() +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { Regimens } from "../index"; +import { Props } from "../interfaces"; +import { bot } from "../../__test_support__/fake_state/bot"; +import { auth } from "../../__test_support__/fake_state/token"; +import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; + +describe("", () => { + it("renders", () => { + const fakeProps: Props = { + dispatch: jest.fn(), + sequences: [], + resources: buildResourceIndex([]).index, + auth, + current: undefined, + regimens: [], + selectedSequence: undefined, + dailyOffsetMs: 1000, + weeks: [], + bot, + calendar: [] + }; + const wrapper = mount(); + ["Regimens", "Regimen Editor", "Scheduler"].map(string => + expect(wrapper.text()).toContain(string)); + }); +}); diff --git a/webpack/regimens/__tests__/reducer_test.ts b/webpack/regimens/__tests__/reducer_test.ts index 98efc4c6e..433e873bb 100644 --- a/webpack/regimens/__tests__/reducer_test.ts +++ b/webpack/regimens/__tests__/reducer_test.ts @@ -84,3 +84,42 @@ describe("INIT_RESOURCE", () => { expect(nextState.currentRegimen).toBe(action.payload.uuid); }); }); + +describe("SELECT_REGIMEN", () => { + it("sets currentRegimen", () => { + const state = defensiveClone(STATE); + state.currentRegimen = undefined; + const action = { + type: Actions.SELECT_REGIMEN, + payload: { uuid: "regimens.4.56", kind: "regimens" } + }; + const nextState = regimensReducer(STATE, action); + expect(nextState.currentRegimen).toBe(action.payload.uuid); + }); +}); + +describe("SET_SEQUENCE", () => { + it("sets selectedSequenceUUID", () => { + const state = defensiveClone(STATE); + state.selectedSequenceUUID = undefined; + const action = { + type: Actions.SET_SEQUENCE, + payload: "sequence" + }; + const nextState = regimensReducer(STATE, action); + expect(nextState.selectedSequenceUUID).toBe(action.payload); + }); +}); + +describe("SET_TIME_OFFSET", () => { + it("sets dailyOffsetMs", () => { + const state = defensiveClone(STATE); + state.dailyOffsetMs = NaN; + const action = { + type: Actions.SET_TIME_OFFSET, + payload: 100 + }; + const nextState = regimensReducer(STATE, action); + expect(nextState.dailyOffsetMs).toBe(action.payload); + }); +}); diff --git a/webpack/regimens/__tests__/serializers_test.ts b/webpack/regimens/__tests__/serializers_test.ts new file mode 100644 index 000000000..46e3adc5a --- /dev/null +++ b/webpack/regimens/__tests__/serializers_test.ts @@ -0,0 +1,17 @@ +import { regimenSerializer } from "../serializers"; +import { fakeRegimen } from "../../__test_support__/fake_state/resources"; + +describe("regimenSerializer()", () => { + it("returns formatted regimen", () => { + const regimen = fakeRegimen().body; + regimen.name = "Fake Regimen"; + regimen.color = "blue"; + regimen.regimen_items = []; + const ret = regimenSerializer(regimen); + expect(ret).toEqual({ + color: "blue", + name: "Fake Regimen", + regimen_items: [] + }); + }); +}); diff --git a/webpack/regimens/__tests__/state_to_props_test.ts b/webpack/regimens/__tests__/state_to_props_test.ts new file mode 100644 index 000000000..5867ee353 --- /dev/null +++ b/webpack/regimens/__tests__/state_to_props_test.ts @@ -0,0 +1,57 @@ +import { mapStateToProps } from "../state_to_props"; +import { fakeState } from "../../__test_support__/fake_state"; +import { TaggedResource } from "../../resources/tagged_resources"; +import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; + +describe("mapStateToProps()", () => { + it("returns props: no regimen selected", () => { + const props = mapStateToProps(fakeState()); + expect(props.current).toEqual(undefined); + expect(props.calendar).toEqual([]); + }); + + it("returns props: active regimen", () => { + const state = fakeState(); + const fakeResources: TaggedResource[] = [ + { + "specialStatus": undefined, + "kind": "regimens", + "body": { + "id": 1, + "name": "Test Regimen", + "color": "gray", + "regimen_items": [ + { + "id": 1, + "regimen_id": 1, + "sequence_id": 1, + "time_offset": 1000 + } + ] + }, + "uuid": "N/A" + }, + { + "kind": "sequences", + "specialStatus": undefined, + "body": { + "id": 1, + "name": "Test Sequence", + "color": "gray", + "body": [{ kind: "wait", args: { milliseconds: 100 } }], + "args": { + "version": 4 + }, + "kind": "sequence" + }, + "uuid": "N/A" + } + ]; + state.resources.index = buildResourceIndex(fakeResources).index; + const regimenUuid = state.resources.index.all[0]; + state.resources.consumers.regimens.currentRegimen = regimenUuid; + const props = mapStateToProps(state); + props.current ? expect(props.current.uuid).toEqual(regimenUuid) : fail; + expect(props.calendar[0].items[0].item.time_offset).toEqual(1000); + }); +}); diff --git a/webpack/regimens/bulk_scheduler/__tests__/actions_test.ts b/webpack/regimens/bulk_scheduler/__tests__/actions_test.ts index 9ab5ac897..38d8d3862 100644 --- a/webpack/regimens/bulk_scheduler/__tests__/actions_test.ts +++ b/webpack/regimens/bulk_scheduler/__tests__/actions_test.ts @@ -1,16 +1,155 @@ const mockErr = jest.fn(); jest.mock("i18next", () => ({ t: (i: string) => i })); -jest.mock("farmbot-toastr", () => ({ error: mockErr })); +jest.mock("farmbot-toastr", () => ({ error: mockErr, warning: mockErr })); -import { commitBulkEditor } from "../actions"; +import { commitBulkEditor, setTimeOffset, toggleDay, setSequence } from "../actions"; import { fakeState } from "../../../__test_support__/fake_state"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import { TaggedResource } from "../../../resources/tagged_resources"; +import { Actions } from "../../../constants"; +import { Everything } from "../../../interfaces"; +import { ToggleDayParams } from "../interfaces"; describe("commitBulkEditor()", () => { - it("does nothing if no regimen is selected", () => { - const getState = () => fakeState(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + function newFakeState() { + const state = fakeState(); + const fakeResources: TaggedResource[] = [ + { + "specialStatus": undefined, + "kind": "regimens", + "body": { + "id": 1, + "name": "Test Regimen", + "color": "gray", + "regimen_items": [ + { + "id": 1, + "regimen_id": 1, + "sequence_id": 1, + "time_offset": 1000 + } + ] + }, + "uuid": "N/A" + }, + { + "kind": "sequences", + "specialStatus": undefined, + "body": { + "id": 1, + "name": "Test Sequence", + "color": "gray", + "body": [{ kind: "wait", args: { milliseconds: 100 } }], + "args": { + "version": 4 + }, + "kind": "sequence" + }, + "uuid": "N/A" + } + ]; + state.resources.index = buildResourceIndex(fakeResources).index; + const regimenUuid = state.resources.index.all[0]; + const sequenceUuid = state.resources.index.all[1]; + + state.resources.consumers.regimens.currentRegimen = regimenUuid; + state.resources.consumers.regimens.selectedSequenceUUID = sequenceUuid; + state.resources.consumers.regimens.dailyOffsetMs = 2000; + state.resources.consumers.regimens.weeks = [{ + days: + { + day1: true, + day2: false, + day3: false, + day4: false, + day5: false, + day6: false, + day7: false + } + }]; + return state; + } + + function returnsError(state: Everything, message: string) { + const getState = () => state; const dispatch = jest.fn(); commitBulkEditor()(dispatch, getState); - expect(dispatch.mock.calls.length).toEqual(0); - expect(mockErr.mock.calls.length).toEqual(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockErr).toBeCalledWith(message); + } + + it("does nothing if no regimen is selected", () => { + const state = newFakeState(); + state.resources.consumers.regimens.currentRegimen = undefined; + returnsError(state, "Select a regimen first or create one."); + }); + + it("does nothing if no sequence is selected", () => { + const state = newFakeState(); + state.resources.consumers.regimens.selectedSequenceUUID = undefined; + returnsError(state, "Select a sequence from the dropdown first."); + }); + + it("does nothing if no days are selected", () => { + const state = newFakeState(); + state.resources.consumers.regimens.weeks[0].days.day1 = false; + returnsError(state, "No day(s) selected."); + }); + + it("does nothing if no weeks", () => { + const state = newFakeState(); + state.resources.consumers.regimens.weeks = []; + returnsError(state, "No day(s) selected."); + }); + + it("adds items", () => { + const state = newFakeState(); + const getState = () => state; + const dispatch = jest.fn(); + commitBulkEditor()(dispatch, getState); + const argsList = dispatch.mock.calls[0][0]; + expect(argsList.type).toEqual(Actions.OVERWRITE_RESOURCE); + expect(argsList.payload.update.regimen_items[1]) + .toEqual({ sequence_id: 1, time_offset: 2000 }); + expect(mockErr).not.toHaveBeenCalled(); + }); +}); + +describe("setTimeOffset()", () => { + it("returns action", () => { + const action = setTimeOffset(100); + expect(action).toEqual({ payload: 100, type: Actions.SET_TIME_OFFSET }); + }); + + it("throws error for NaN", () => { + expect(() => setTimeOffset(NaN)) + .toThrowError("Bad time input on regimen page: null"); + expect(mockErr).toBeCalledWith( + "Time is not properly formatted.", "Bad Input"); + }); +}); + +describe("toggleDay()", () => { + it("returns action", () => { + const params: ToggleDayParams = { week: 0, day: 0 }; + const action = toggleDay(params); + expect(action).toEqual({ + payload: { day: 0, week: 0 }, + type: Actions.TOGGLE_DAY + }); + }); +}); + +describe("setSequence()", () => { + it("returns action", () => { + const action = setSequence("sequences"); + expect(action).toEqual({ + payload: "sequences", + type: Actions.SET_SEQUENCE + }); }); }); diff --git a/webpack/regimens/bulk_scheduler/__tests__/add_button_test.tsx b/webpack/regimens/bulk_scheduler/__tests__/add_button_test.tsx new file mode 100644 index 000000000..770c70a90 --- /dev/null +++ b/webpack/regimens/bulk_scheduler/__tests__/add_button_test.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { mount } from "enzyme"; +import { AddButtonProps } from "../interfaces"; +import { AddButton } from "../add_button"; + +describe("", () => { + it("renders an add button when active", () => { + const props: AddButtonProps = { active: true, click: jest.fn() }; + const wrapper = mount(); + const button = wrapper.find("button"); + ["green", "add"].map(klass => { + expect(button.hasClass(klass)).toBeTruthy(); + }); + expect(wrapper.find("i").hasClass("fa-plus")).toBeTruthy(); + button.simulate("click"); + expect(props.click).toHaveBeenCalled(); + }); + + it("renders a
when inactive", () => { + const props: AddButtonProps = { active: false, click: jest.fn() }; + const wrapper = mount(); + expect(wrapper.html()).toEqual("
"); + }); +}); diff --git a/webpack/regimens/bulk_scheduler/__tests__/index_test.tsx b/webpack/regimens/bulk_scheduler/__tests__/index_test.tsx new file mode 100644 index 000000000..fe6bdf0de --- /dev/null +++ b/webpack/regimens/bulk_scheduler/__tests__/index_test.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { BulkSchedulerWidget } from "../index"; +import { BulkEditorProps } from "../interfaces"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import { Actions } from "../../../constants"; +import { fakeSequence } from "../../../__test_support__/fake_state/resources"; + +describe("", () => { + const weeks = [{ + days: + { + day1: true, + day2: false, + day3: false, + day4: false, + day5: false, + day6: false, + day7: false + } + }]; + + function fakeProps(): BulkEditorProps { + const sequence = fakeSequence(); + sequence.body.name = "Fake Sequence"; + return { + selectedSequence: sequence, + dailyOffsetMs: 3600000, + weeks, + sequences: [fakeSequence(), fakeSequence()], + resources: buildResourceIndex([]).index, + dispatch: jest.fn() + }; + } + + it("renders with sequence selected", () => { + const wrapper = mount(); + const buttons = wrapper.find("button"); + expect(buttons.length).toEqual(6); + ["Scheduler", "Sequence", "Fake Sequence", "Time", + "Days", "Week 1", "1234567"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("renders without sequence selected", () => { + const p = fakeProps(); + p.selectedSequence = undefined; + const wrapper = mount(); + ["Sequence", "None", "Time"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("changes time", () => { + const p = fakeProps(); + p.dispatch = jest.fn(); + const wrapper = shallow(); + const timeInput = wrapper.find("BlurableInput").first(); + expect(timeInput.props().value).toEqual("01:00"); + timeInput.simulate("commit", { currentTarget: { value: "02:00" } }); + expect(p.dispatch).toHaveBeenCalledWith({ + payload: 7200000, + type: Actions.SET_TIME_OFFSET + }); + }); + + it("changes sequence", () => { + const p = fakeProps(); + p.dispatch = jest.fn(); + const wrapper = shallow(); + const sequenceInput = wrapper.find("FBSelect").first(); + sequenceInput.simulate("change", { value: "sequences" }); + expect(p.dispatch).toHaveBeenCalledWith({ + payload: "sequences", + type: Actions.SET_SEQUENCE + }); + }); + + it("doesn't change sequence", () => { + const p = fakeProps(); + p.dispatch = jest.fn(); + const wrapper = shallow(); + const sequenceInput = wrapper.find("FBSelect").first(); + const change = () => sequenceInput.simulate("change", { value: 4 }); + expect(change).toThrowError("WARNING: Not a sequence UUID."); + expect(p.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/webpack/regimens/bulk_scheduler/__tests__/week_grid_test.tsx b/webpack/regimens/bulk_scheduler/__tests__/week_grid_test.tsx new file mode 100644 index 000000000..a4cd360b8 --- /dev/null +++ b/webpack/regimens/bulk_scheduler/__tests__/week_grid_test.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { mount } from "enzyme"; +import { WeekGrid } from "../week_grid"; +import { WeekGridProps } from "../interfaces"; +import { Actions } from "../../../constants"; + +describe("", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const weeks = [{ + days: + { + day1: true, + day2: false, + day3: false, + day4: false, + day5: false, + day6: false, + day7: false + } + }]; + + it("renders", () => { + const props: WeekGridProps = { weeks, dispatch: jest.fn() }; + const wrapper = mount(); + const buttons = wrapper.find("button"); + expect(buttons.length).toEqual(4); + ["Days", "Week 1", "1234567"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + function checkAction(position: number, text: string, type: Actions) { + const props: WeekGridProps = { weeks, dispatch: jest.fn() }; + const wrapper = mount(); + const button = wrapper.find("button").at(position); + expect(button.text().toLowerCase()).toContain(text.toLowerCase()); + button.simulate("click"); + expect(props.dispatch).toHaveBeenCalledWith({ type, payload: undefined }); + } + + it("adds week", () => { + checkAction(0, "Week", Actions.PUSH_WEEK); + }); + + it("removes week", () => { + checkAction(1, "Week", Actions.POP_WEEK); + }); + + it("selects all days", () => { + checkAction(2, "Deselect All", Actions.DESELECT_ALL_DAYS); + }); + + it("deselects all days", () => { + checkAction(3, "Select All", Actions.SELECT_ALL_DAYS); + }); +}); diff --git a/webpack/regimens/bulk_scheduler/actions.ts b/webpack/regimens/bulk_scheduler/actions.ts index d4f01a31e..14b28ecc2 100644 --- a/webpack/regimens/bulk_scheduler/actions.ts +++ b/webpack/regimens/bulk_scheduler/actions.ts @@ -73,9 +73,11 @@ export function commitBulkEditor(): Thunk { // Proceed only if they selected a sequence from the drop down. if (selectedSequenceUUID) { const seq = findSequence(res.index, selectedSequenceUUID).body; - const regimenItems = groupRegimenItemsByWeek(weeks, dailyOffsetMs, seq); + const regimenItems = weeks.length > 0 + ? groupRegimenItemsByWeek(weeks, dailyOffsetMs, seq) + : undefined; // Proceed only if days are selcted in the scheduler. - if (regimenItems.length > 0) { + if (regimenItems && regimenItems.length > 0) { const reg = findRegimen(res.index, currentRegimen); const update = defensiveClone(reg).body; update.regimen_items = update.regimen_items.concat(regimenItems); diff --git a/webpack/regimens/editor/__tests__/active_editor_test.tsx b/webpack/regimens/editor/__tests__/active_editor_test.tsx new file mode 100644 index 000000000..3ec9644fe --- /dev/null +++ b/webpack/regimens/editor/__tests__/active_editor_test.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { mount } from "enzyme"; +import { ActiveEditor } from "../active_editor"; +import { fakeRegimen } from "../../../__test_support__/fake_state/resources"; +import { ActiveEditorProps } from "../interfaces"; +import { Actions } from "../../../constants"; + +describe("", () => { + const props: ActiveEditorProps = { + dispatch: jest.fn(), + regimen: fakeRegimen(), + calendar: [{ + day: "1", + items: [{ + name: "Item 0", + color: "red", + hhmm: "10:00", + sortKey: 0, + day: 1, + dispatch: jest.fn(), + regimen: fakeRegimen(), + item: { + sequence_id: 0, time_offset: 1000 + } + }] + }] + }; + + it("renders", () => { + const wrapper = mount(); + ["Day", "Item 0", "10:00"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("removes regimen item", () => { + const wrapper = mount(); + wrapper.find("i").simulate("click"); + expect(props.dispatch).toHaveBeenCalledWith({ + payload: { + update: { + color: "red", name: "Foo", regimen_items: [] + }, + uuid: "regimens.1.17" + }, + type: Actions.OVERWRITE_RESOURCE + }); + }); +}); diff --git a/webpack/regimens/editor/__tests__/index_test.tsx b/webpack/regimens/editor/__tests__/index_test.tsx new file mode 100644 index 000000000..70c4b4689 --- /dev/null +++ b/webpack/regimens/editor/__tests__/index_test.tsx @@ -0,0 +1,84 @@ +import { fakeState } from "../../../__test_support__/fake_state"; +const mockState = fakeState; +const mockDestroy = jest.fn(); +const mockSave = jest.fn(); +jest.mock("../../../api/crud", () => ({ + getState: mockState, + destroy: mockDestroy, + save: mockSave +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { RegimenEditorWidget } from "../index"; +import { fakeRegimen } from "../../../__test_support__/fake_state/resources"; +import { RegimenEditorWidgetProps } from "../interfaces"; +import { auth } from "../../../__test_support__/fake_state/token"; +import { bot } from "../../../__test_support__/fake_state/bot"; + +describe("", () => { + beforeEach(function () { + jest.clearAllMocks(); + }); + + function fakeProps(): RegimenEditorWidgetProps { + return { + dispatch: jest.fn(), + auth, + bot, + current: fakeRegimen(), + calendar: [{ + day: "1", + items: [{ + name: "Item 0", + color: "red", + hhmm: "10:00", + sortKey: 0, + day: 1, + dispatch: jest.fn(), + regimen: fakeRegimen(), + item: { + sequence_id: 0, time_offset: 1000 + } + }] + }] + }; + } + + it("active editor", () => { + const wrapper = mount(); + ["Regimen Editor", "Delete", "Item 0", "10:00"].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("empty editor", () => { + const props = fakeProps(); + props.current = undefined; + const wrapper = mount(); + ["Regimen Editor", "No Regimen selected."].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("error: not logged in", () => { + const props = fakeProps(); + props.auth = undefined; + const wrapper = () => mount(); + expect(wrapper).toThrowError("Must log in first"); + }); + + it("deletes regimen", () => { + const wrapper = mount(); + const deleteButton = wrapper.find("button").at(2); + expect(deleteButton.text()).toContain("Delete"); + deleteButton.simulate("click"); + expect(mockDestroy).toHaveBeenCalledWith("regimens.6.22"); + }); + + it("saves regimen", () => { + const wrapper = mount(); + const saveeButton = wrapper.find("button").at(0); + expect(saveeButton.text()).toContain("Save"); + saveeButton.simulate("click"); + expect(mockSave).toHaveBeenCalledWith("regimens.8.24"); + }); +}); diff --git a/webpack/regimens/editor/__tests__/regimen_name_input.tsx b/webpack/regimens/editor/__tests__/regimen_name_input.tsx new file mode 100644 index 000000000..094543a71 --- /dev/null +++ b/webpack/regimens/editor/__tests__/regimen_name_input.tsx @@ -0,0 +1,22 @@ +jest.mock("../../actions", () => ({ editRegimen: jest.fn() })); + +import { write } from "../regimen_name_input"; +import { fakeRegimen } from "../../../__test_support__/fake_state/resources"; +import { editRegimen } from "../../actions"; + +describe("write()", () => { + it("crashes without a regimen", () => { + const input = { regimen: undefined, dispatch: jest.fn() }; + expect(() => write(input)).toThrowError(); + }); + + it("calls dispatch", () => { + const input = { regimen: fakeRegimen(), dispatch: jest.fn() }; + const callback = write(input); + expect(callback).toBeInstanceOf(Function); + const value = "FOO"; + callback({ currentTarget: { value } } as any); + expect(input.dispatch).toHaveBeenCalled(); + expect(editRegimen).toHaveBeenCalled(); + }); +}); diff --git a/webpack/regimens/editor/regimen_name_input.tsx b/webpack/regimens/editor/regimen_name_input.tsx index 43ed20779..144ef3e2f 100644 --- a/webpack/regimens/editor/regimen_name_input.tsx +++ b/webpack/regimens/editor/regimen_name_input.tsx @@ -5,7 +5,7 @@ import { ColorPicker } from "../../ui"; import { Row, Col } from "../../ui/index"; import { editRegimen } from "../actions"; -function write({ dispatch, regimen }: RegimenProps): +export function write({ dispatch, regimen }: RegimenProps): React.EventHandler> { if (regimen) { return (event: React.FormEvent) => { diff --git a/webpack/regimens/list/__tests__/index_test.tsx b/webpack/regimens/list/__tests__/index_test.tsx new file mode 100644 index 000000000..5d000647c --- /dev/null +++ b/webpack/regimens/list/__tests__/index_test.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { RegimensList } from "../index"; +import { RegimensListProps } from "../../interfaces"; +import { fakeRegimen } from "../../../__test_support__/fake_state/resources"; + +describe("", () => { + function fakeProps(): RegimensListProps { + const regimen = fakeRegimen(); + regimen.body.name = "Fake Regimen"; + return { + dispatch: jest.fn(), + regimens: [regimen, regimen], + regimen: undefined + }; + } + it("renders", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("Regimens"); + expect(wrapper.text()).toContain("Fake Regimen Fake Regimen"); + }); + + it("sets search term", () => { + const wrapper = shallow(); + wrapper.find("input").simulate("change", + { currentTarget: { value: "term" } }); + expect(wrapper.state().searchTerm).toEqual("term"); + }); +}); diff --git a/webpack/sequences/__tests__/sequences_list_test.tsx b/webpack/sequences/__tests__/sequences_list_test.tsx index 039dcbeb4..8195a90a7 100644 --- a/webpack/sequences/__tests__/sequences_list_test.tsx +++ b/webpack/sequences/__tests__/sequences_list_test.tsx @@ -17,7 +17,7 @@ describe("", () => { sequences={[fakeSequence1, fakeSequence2]} />); expect(wrapper.find("input").first().props().placeholder) .toContain("Search Sequences"); - expect(wrapper.text()).toContain("Sequence 1"); - expect(wrapper.text()).toContain("Sequence 2"); + ["Sequence 1", "Sequence 2"].map(string => + expect(wrapper.text()).toContain(string)); }); }); diff --git a/webpack/sync/actions.ts b/webpack/sync/actions.ts index 68ab209ed..9f2c93636 100644 --- a/webpack/sync/actions.ts +++ b/webpack/sync/actions.ts @@ -32,7 +32,7 @@ export function fetchSyncData(dispatch: Function) { type, payload: { name, data: r.data } }), fail); - const fail = () => warning("Please try refreshing the page.", + const fail = () => warning("Please try refreshing the page or logging in again.", "Error downloading data"); fetch("users", API.current.usersPath); @@ -47,16 +47,3 @@ export function fetchSyncData(dispatch: Function) { fetch("sequences", API.current.sequencesPath); fetch("tools", API.current.toolsPath); } - -export function fetchSyncDataOk(payload: {}) { - return { - type: "FETCH_SYNC_OK", payload - }; -} - -export function fetchSyncDataNo(err: Error) { - return { - type: "FETCH_SYNC_NO", - payload: {} - }; -} diff --git a/webpack/tools/__tests__/index_test.tsx b/webpack/tools/__tests__/index_test.tsx new file mode 100644 index 000000000..9824df59e --- /dev/null +++ b/webpack/tools/__tests__/index_test.tsx @@ -0,0 +1,39 @@ +jest.mock("react-redux", () => ({ + connect: jest.fn() +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { Tools } from "../index"; +import { Props } from "../interfaces"; +import { fakeToolSlot, fakeTool } from "../../__test_support__/fake_state/resources"; + +describe("", () => { + function fakeProps(): Props { + return { + toolSlots: [], + tools: [fakeTool()], + getToolSlots: () => [fakeToolSlot()], + getToolOptions: () => [], + getChosenToolOption: () => { return { label: "None", value: "" }; }, + getToolByToolSlotUUID: () => fakeTool(), + changeToolSlot: jest.fn(), + isActive: () => true, + dispatch: jest.fn(), + botPosition: { x: undefined, y: undefined, z: undefined } + }; + } + + it("renders", () => { + const wrapper = mount(); + const txt = wrapper.text(); + const strings = [ + "ToolBay 1", + "SlotXYZ", + "Tool1101010Foo", + "Tools", + "Tool NameStatus", + "Fooactive"]; + strings.map(string => expect(txt).toContain(string)); + }); +}); diff --git a/webpack/tools/components/tool_list.tsx b/webpack/tools/components/tool_list.tsx index a15845d68..0d8f16d68 100644 --- a/webpack/tools/components/tool_list.tsx +++ b/webpack/tools/components/tool_list.tsx @@ -28,7 +28,7 @@ export class ToolList extends React.Component { {tools.map((tool: TaggedTool) => { - return + return {tool.body.name || "Name not found"} diff --git a/webpack/tools/components/toolbay_list.tsx b/webpack/tools/components/toolbay_list.tsx index 796f66434..416f19398 100644 --- a/webpack/tools/components/toolbay_list.tsx +++ b/webpack/tools/components/toolbay_list.tsx @@ -24,7 +24,7 @@ export class ToolBayList extends React.Component { {getToolSlots().map((slot: TaggedToolSlotPointer, index: number) => { const tool = getToolByToolSlotUUID(slot.uuid); const name = (tool && tool.body.name) || "None"; - return + return diff --git a/webpack/util.ts b/webpack/util.ts index ceeb8fd03..003f81ebe 100644 --- a/webpack/util.ts +++ b/webpack/util.ts @@ -461,5 +461,5 @@ export function withTimeout(ms: number, promise: Promise) { }); // Returns a race between our timeout and the passed in promise - return Promise.race([promise, timeout]); + return Promise.race([promise, timeout]) as Promise; } diff --git a/webpack/verification_support.ts b/webpack/verification_support.ts index a534cbbc6..1a39dd216 100644 --- a/webpack/verification_support.ts +++ b/webpack/verification_support.ts @@ -1,9 +1,20 @@ import { getParam, HttpData } from "./util"; -import axios from "axios"; +import axios, { AxiosResponse } from "axios"; import { API } from "./api/api"; import { Session } from "./session"; import { AuthState } from "./auth/interfaces"; +/** Keep track of this in rollbar to prevent global registration failures. */ +export const ALREADY_VERIFIED = + `

+ You are already verified. We will now forward you to the main application. +

+

+ If you are still unable to access the app, try logging in again or + asking for help on the FarmBot Forum. +

`; +const ALREADY_VERIFIED_MSG = "TRIED TO RE-VERIFY"; + export const FAILURE_PAGE = `

We were unable to verify your account. @@ -17,7 +28,15 @@ export const FAILURE_MSG = "USER VERIFICATION FAILED!"; /** Function called when the Frontend verifies its registration token. * IF YOU BREAK THIS FUNCTION, YOU BREAK *ALL* NEW USER REGISTRATIONS. */ -export const verify = async () => { try { attempt(); } catch (e) { fail(); } }; +export const verify = async () => { + try { + console.log("TODO: Make sure this thing actually uses `await`." + + " function won't work without await"); + await attempt(); + } catch (e) { + fail(e); + } +}; export async function attempt() { API.setBaseUrl(API.fetchBrowserLocation()); @@ -28,7 +47,23 @@ export async function attempt() { window.location.href = API.current.baseUrl + "/app/controls"; } -export function fail() { - document.write(FAILURE_PAGE); - throw new Error(FAILURE_MSG); +interface AxiosError extends Error { + response?: AxiosResponse | undefined; // Need to be extra cautious here. } + +export function fail(err: AxiosError | undefined) { + switch (err && err.response && err.response.status) { + case 409: + alreadyVerified(); + break; + default: + document.write(FAILURE_PAGE); + throw new Error(FAILURE_MSG); + } +} + +const alreadyVerified = () => { + window.location.href = "/app/controls"; + document.write(ALREADY_VERIFIED); + throw new Error(ALREADY_VERIFIED_MSG); +}; diff --git a/yarn.lock b/yarn.lock index 9fcaf1ec1..08c5b16c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1972,9 +1972,9 @@ farmbot-toastr@^1.0.0, farmbot-toastr@^1.0.3: farmbot-toastr "^1.0.0" typescript "^2.3.4" -farmbot@5.0.1-rc12: - version "5.0.1-rc12" - resolved "https://registry.yarnpkg.com/farmbot/-/farmbot-5.0.1-rc12.tgz#3746018e2d42657ece67ff2af23b507d604ceb0d" +farmbot@5.0.1-rc13: + version "5.0.1-rc13" + resolved "https://registry.yarnpkg.com/farmbot/-/farmbot-5.0.1-rc13.tgz#473366b179eb967d9f1265952334b6195de7b1ef" dependencies: mqtt "^1.7.4" typescript "^2.4.2"