Merge conflicts

pull/506/head
Rick Carlino 2017-10-18 07:13:28 -05:00
commit b977825e0c
67 changed files with 1472 additions and 226 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -10,6 +10,7 @@ window.globalConfig = <%= raw($FRONTEND_SHARED_DATA) %> // SEE COMMENTS!;
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i" rel="stylesheet">
<%= javascript_include_tag *webpack_asset_paths("commons") %>
<% if false %>
<script async src="http://<%= ENV["API_HOST"] %>:8080/target/target-script-min.js#anonymous">
</script>

View File

@ -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

View File

@ -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.

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
});
}

View File

@ -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"
}
};

View File

@ -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();
});
});

View File

@ -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();
}
});
});
});

View File

@ -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()", () => {

View File

@ -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);
});
});

View File

@ -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 } };

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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("<Peripherals />", () => {
beforeEach(function () {
jest.clearAllMocks();
});
function fakeProps(): PeripheralsProps {
return {
bot,
peripherals: [fakePeripheral()],
dispatch: jest.fn(),
resources: buildResourceIndex([]),
disabled: false
};
}
it("renders", () => {
const wrapper = mount(<Peripherals {...fakeProps() } />);
["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(<Peripherals {...fakeProps() } />);
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(<Peripherals {...p } />);
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(<Peripherals {...p } />);
const save = wrapper.find("button").at(1);
expect(save.text()).toContain("Save");
save.simulate("click");
expect(p.dispatch).toHaveBeenCalled();
});
});

View File

@ -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
});
})

View File

@ -15,14 +15,8 @@ describe("<FarmbotOsSettings/>", () => {
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));
});
});

View File

@ -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("<HardwareSettings />", () => {
beforeEach(() => {
@ -23,9 +24,8 @@ describe("<HardwareSettings />", () => {
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("<HardwareSettings />", () => {
}
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");
});
});

View File

@ -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("<AddFarmEvent />", () => {
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(<AddFarmEvent {...fakeProps() } />);
wrapper.setState({ uuid: "farm_events" });
["Add Farm Event", "Sequence or Regimen", "fake"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("redirects", () => {
const wrapper = mount(<AddFarmEvent {...fakeProps() } />);
expect(wrapper.text()).toContain("Loading");
});
});

View File

@ -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("<EditFarmEvent />", () => {
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(<EditFarmEvent {...fakeProps() } />);
["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(<EditFarmEvent {...p } />);
expect(wrapper.text()).toContain("Loading");
});
});

View File

@ -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();
});
});

View File

@ -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));
});
});

View File

@ -21,10 +21,11 @@ describe("<DragHelperLayer/>", () => {
it("shows drag helpers", () => {
const p = fakeProps();
const wrapper = shallow(<DragHelperLayer {...p } />);
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", () => {

View File

@ -35,13 +35,15 @@ describe("<PlantLayer/>", () => {
const wrapper = shallow(<PlantLayer {...p } />);
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", () => {

View File

@ -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("<PlantInventoryItem />", () => {
it("renders", () => {
@ -24,7 +25,7 @@ describe("<PlantInventoryItem />", () => {
icon: "",
plantUUID: "points.1.17"
},
type: "TOGGLE_HOVERED_PLANT"
type: Actions.TOGGLE_HOVERED_PLANT
});
});
@ -40,7 +41,7 @@ describe("<PlantInventoryItem />", () => {
icon: "",
plantUUID: undefined
},
type: "TOGGLE_HOVERED_PLANT"
type: Actions.TOGGLE_HOVERED_PLANT
});
});
});

View File

@ -13,8 +13,12 @@ describe("<PlantInventory />", () => {
<Plants
plants={[fakePlant()]}
dispatch={jest.fn()} />);
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...");
});

View File

@ -38,9 +38,8 @@ describe("<SelectPlants />", () => {
const p = fakeProps();
p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />);
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", () => {

View File

@ -49,9 +49,8 @@ describe("<FarmwareForms/>", () => {
const wrapper = mount(<FarmwareForms
farmwares={fakeFarmwares()}
user_env={{}} />);
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");
});

View File

@ -25,9 +25,11 @@ describe("<FarmwarePage />", () => {
images: []
};
const wrapper = mount(<FarmwarePage {...props} />);
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));
});
});

View File

@ -21,12 +21,14 @@ describe("<CameraCalibration/>", () => {
V_HI: 9
};
const wrapper = mount(<CameraCalibration {...props} />);
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));
});
});

View File

@ -19,12 +19,14 @@ describe("<WeedDetector />", () => {
images: []
};
const wrapper = mount(<WeedDetector {...props} />);
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));
});
});

View File

@ -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();
});
});

View File

@ -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));

View File

@ -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);
};

View File

@ -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<AuthState>;
@ -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<AuthState> => {
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<AuthState | undefined> => {
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));
};

View File

@ -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
});
});
});

View File

@ -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("<Regimens />", () => {
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 {...fakeProps } />);
["Regimens", "Regimen Editor", "Scheduler"].map(string =>
expect(wrapper.text()).toContain(string));
});
});

View File

@ -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);
});
});

View File

@ -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: []
});
});
});

View File

@ -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);
});
});

View File

@ -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
});
});
});

View File

@ -0,0 +1,24 @@
import * as React from "react";
import { mount } from "enzyme";
import { AddButtonProps } from "../interfaces";
import { AddButton } from "../add_button";
describe("<AddButton />", () => {
it("renders an add button when active", () => {
const props: AddButtonProps = { active: true, click: jest.fn() };
const wrapper = mount(<AddButton {...props} />);
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 <div> when inactive", () => {
const props: AddButtonProps = { active: false, click: jest.fn() };
const wrapper = mount(<AddButton {...props} />);
expect(wrapper.html()).toEqual("<div></div>");
});
});

View File

@ -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("<BulkSchedulerWidget />", () => {
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(<BulkSchedulerWidget {...fakeProps() } />);
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(<BulkSchedulerWidget {...p } />);
["Sequence", "None", "Time"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("changes time", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = shallow(<BulkSchedulerWidget {...p } />);
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(<BulkSchedulerWidget {...p } />);
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(<BulkSchedulerWidget {...p } />);
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();
});
});

View File

@ -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("<WeekGrid />", () => {
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(<WeekGrid {...props} />);
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(<WeekGrid {...props} />);
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);
});
});

View File

@ -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);

View File

@ -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("<ActiveEditor />", () => {
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(<ActiveEditor {...props} />);
["Day", "Item 0", "10:00"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("removes regimen item", () => {
const wrapper = mount(<ActiveEditor {...props} />);
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
});
});
});

View File

@ -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("<RegimenEditorWidget />", () => {
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(<RegimenEditorWidget {...fakeProps() } />);
["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(<RegimenEditorWidget {...props} />);
["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(<RegimenEditorWidget {...props} />);
expect(wrapper).toThrowError("Must log in first");
});
it("deletes regimen", () => {
const wrapper = mount(<RegimenEditorWidget {...fakeProps() } />);
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(<RegimenEditorWidget {...fakeProps() } />);
const saveeButton = wrapper.find("button").at(0);
expect(saveeButton.text()).toContain("Save");
saveeButton.simulate("click");
expect(mockSave).toHaveBeenCalledWith("regimens.8.24");
});
});

View File

@ -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();
});
});

View File

@ -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<React.FormEvent<{}>> {
if (regimen) {
return (event: React.FormEvent<HTMLInputElement>) => {

View File

@ -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("<RegimensList />", () => {
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(<RegimensList {...fakeProps() } />);
expect(wrapper.text()).toContain("Regimens");
expect(wrapper.text()).toContain("Fake Regimen Fake Regimen");
});
it("sets search term", () => {
const wrapper = shallow(<RegimensList {...fakeProps() } />);
wrapper.find("input").simulate("change",
{ currentTarget: { value: "term" } });
expect(wrapper.state().searchTerm).toEqual("term");
});
});

View File

@ -17,7 +17,7 @@ describe("<SequencesList />", () => {
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));
});
});

View File

@ -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<User>("users", API.current.usersPath);
@ -47,16 +47,3 @@ export function fetchSyncData(dispatch: Function) {
fetch<Sequence[]>("sequences", API.current.sequencesPath);
fetch<Tool[]>("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: {}
};
}

View File

@ -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("<Tools />", () => {
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(<Tools {...fakeProps() } />);
const txt = wrapper.text();
const strings = [
"ToolBay 1",
"SlotXYZ",
"Tool1101010Foo",
"Tools",
"Tool NameStatus",
"Fooactive"];
strings.map(string => expect(txt).toContain(string));
});
});

View File

@ -28,7 +28,7 @@ export class ToolList extends React.Component<ToolListProps, {}> {
</Col>
</Row>
{tools.map((tool: TaggedTool) => {
return <Row key={tool.body.id}>
return <Row key={tool.uuid}>
<Col xs={8}>
{tool.body.name || "Name not found"}
</Col>

View File

@ -24,7 +24,7 @@ export class ToolBayList extends React.Component<ToolBayListProps, {}> {
{getToolSlots().map((slot: TaggedToolSlotPointer, index: number) => {
const tool = getToolByToolSlotUUID(slot.uuid);
const name = (tool && tool.body.name) || "None";
return <Row key={slot.body.id}>
return <Row key={slot.uuid}>
<Col xs={2}>
<label>{index + 1}</label>
</Col>

View File

@ -461,5 +461,5 @@ export function withTimeout<T>(ms: number, promise: Promise<T>) {
});
// Returns a race between our timeout and the passed in promise
return Promise.race([promise, timeout]);
return Promise.race([promise, timeout]) as Promise<T>;
}

View File

@ -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 =
`<p>
You are already verified. We will now forward you to the main application.
</p>
<p>
If you are still unable to access the app, try logging in again or
<a href="http://forum.farmbot.org/"> asking for help on the FarmBot Forum.</a>
</p>`;
const ALREADY_VERIFIED_MSG = "TRIED TO RE-VERIFY";
export const FAILURE_PAGE =
`<p>
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);
};

View File

@ -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"