money_tracking 0.99.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.rspec +2 -0
- data/.travis.yml +8 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +61 -0
- data/Rakefile +2 -0
- data/bin/money +6 -0
- data/lib/money_tracking.rb +6 -0
- data/lib/money_tracking/cli.rb +88 -0
- data/lib/money_tracking/cli/commands.rb +7 -0
- data/lib/money_tracking/cli/create_command.rb +21 -0
- data/lib/money_tracking/cli/delete_command.rb +15 -0
- data/lib/money_tracking/cli/list_command.rb +16 -0
- data/lib/money_tracking/cli/runner.rb +54 -0
- data/lib/money_tracking/cli/update_command.rb +33 -0
- data/lib/money_tracking/cli/views.rb +15 -0
- data/lib/money_tracking/cli/views/empty.rb +11 -0
- data/lib/money_tracking/cli/views/expense_created.rb +11 -0
- data/lib/money_tracking/cli/views/expense_deleted.rb +15 -0
- data/lib/money_tracking/cli/views/expense_item.rb +34 -0
- data/lib/money_tracking/cli/views/expense_list.rb +11 -0
- data/lib/money_tracking/cli/views/expense_not_found.rb +15 -0
- data/lib/money_tracking/cli/views/expense_not_updated.rb +19 -0
- data/lib/money_tracking/cli/views/expense_updated.rb +15 -0
- data/lib/money_tracking/data_store/file_store.rb +81 -0
- data/lib/money_tracking/data_store/protocol.rb +20 -0
- data/lib/money_tracking/domain.rb +12 -0
- data/lib/money_tracking/domain/expense.rb +92 -0
- data/lib/money_tracking/domain/expense_factory.rb +15 -0
- data/lib/money_tracking/domain/expense_finder.rb +13 -0
- data/lib/money_tracking/version.rb +3 -0
- data/money_tracking.gemspec +25 -0
- data/spec/aruba_helper.rb +31 -0
- data/spec/integration/cli_spec.rb +129 -0
- data/spec/money_tracking/cli/create_command_spec.rb +38 -0
- data/spec/money_tracking/cli/delete_command_spec.rb +44 -0
- data/spec/money_tracking/cli/list_command_spec.rb +34 -0
- data/spec/money_tracking/cli/update_command_spec.rb +92 -0
- data/spec/money_tracking/cli/views/expense_item_spec.rb +60 -0
- data/spec/money_tracking/cli/views/expense_list_spec.rb +40 -0
- data/spec/money_tracking/data_store/acts_as_a_data_store.rb +144 -0
- data/spec/money_tracking/data_store/file_store_spec.rb +16 -0
- data/spec/money_tracking/domain/expense_factory_spec.rb +58 -0
- data/spec/money_tracking/domain/expense_finder_spec.rb +59 -0
- data/spec/money_tracking/domain/expense_spec.rb +125 -0
- data/spec/spec_helper.rb +32 -0
- metadata +148 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
RSpec.describe CreateCommand do
|
4
|
+
subject { described_class.new(expense_factory, "79.5", "euro", ["food", "other"]) }
|
5
|
+
|
6
|
+
let(:expense_factory) { instance_double(Domain::ExpenseFactory) }
|
7
|
+
let(:expense) { instance_double(Domain::Expense, build_view: nil) }
|
8
|
+
let(:view) { instance_double(Views::ExpenseCreated) }
|
9
|
+
|
10
|
+
before do
|
11
|
+
allow(expense_factory)
|
12
|
+
.to receive(:create).with(
|
13
|
+
amount: 79.5,
|
14
|
+
currency: "euro",
|
15
|
+
tags: ["food", "other"],
|
16
|
+
).and_return(expense)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "creates an expense object" do
|
20
|
+
expect(expense_factory)
|
21
|
+
.to receive(:create).with(
|
22
|
+
amount: 79.5,
|
23
|
+
currency: "euro",
|
24
|
+
tags: ["food", "other"],
|
25
|
+
).and_return(expense)
|
26
|
+
subject.call
|
27
|
+
end
|
28
|
+
|
29
|
+
it "renders ExpenseCreated view" do
|
30
|
+
allow(expense)
|
31
|
+
.to receive(:build_view)
|
32
|
+
.with(Views::ExpenseCreated)
|
33
|
+
.and_return(view)
|
34
|
+
expect(subject.call).to eq(view)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
RSpec.describe DeleteCommand do
|
4
|
+
subject { described_class.new(expense_finder, expense_id) }
|
5
|
+
|
6
|
+
let(:expense_finder) { instance_double(Domain::ExpenseFinder) }
|
7
|
+
let(:expense_id) { "ja6ptd3d" }
|
8
|
+
let(:expense) { instance_double(Domain::Expense) }
|
9
|
+
let(:not_found) { instance_double(Domain::ExpenseNotFound) }
|
10
|
+
let(:view) { instance_double(Views::ExpenseDeleted) }
|
11
|
+
let(:not_found_view) { instance_double(Views::ExpenseNotFound) }
|
12
|
+
|
13
|
+
before do
|
14
|
+
allow(expense).to receive(:build_view).with(Views::ExpenseDeleted).and_return(view)
|
15
|
+
allow(expense).to receive(:delete).and_return(expense)
|
16
|
+
|
17
|
+
allow(not_found).to receive(:build_view).with(Views::ExpenseDeleted)
|
18
|
+
.and_return(not_found_view)
|
19
|
+
allow(not_found).to receive(:delete).and_return(not_found)
|
20
|
+
end
|
21
|
+
|
22
|
+
context "when no such expense found" do
|
23
|
+
before { allow(expense_finder).to receive(:read).with(expense_id).and_return(not_found) }
|
24
|
+
|
25
|
+
it "returns ExpenseNotFound view" do
|
26
|
+
expect(subject.call).to be(not_found_view)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context "when expense was found" do
|
31
|
+
before { allow(expense_finder).to receive(:read).with(expense_id).and_return(expense) }
|
32
|
+
|
33
|
+
it "returns ExpenseDeleted view" do
|
34
|
+
expect(subject.call).to be(view)
|
35
|
+
end
|
36
|
+
|
37
|
+
it "deletes an expense" do
|
38
|
+
expect(expense).to receive(:delete)
|
39
|
+
subject.call
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
RSpec.describe ListCommand do
|
4
|
+
subject { described_class.new(expense_finder) }
|
5
|
+
|
6
|
+
let(:view) { double("View") }
|
7
|
+
let(:expense_finder) { instance_double(Domain::ExpenseFinder) }
|
8
|
+
let(:expenses) { [expense_a, expense_b] }
|
9
|
+
|
10
|
+
let(:expense_a) { instance_double(Domain::Expense) }
|
11
|
+
let(:expense_b) { instance_double(Domain::Expense) }
|
12
|
+
|
13
|
+
context "when there are no expenses" do
|
14
|
+
before { allow(expense_finder).to receive(:list).and_return([]) }
|
15
|
+
|
16
|
+
it "returns empty view" do
|
17
|
+
expect(subject.call).to be_a(Views::Empty)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
context "when there are some expenses" do
|
22
|
+
before { allow(expense_finder).to receive(:list).and_return(expenses) }
|
23
|
+
|
24
|
+
it "creates proper expense list view and returns it" do
|
25
|
+
allow(Views::ExpenseList)
|
26
|
+
.to receive(:new)
|
27
|
+
.with(expenses, Views::ExpenseItem)
|
28
|
+
.and_return(view)
|
29
|
+
expect(subject.call).to be(view)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
RSpec.describe UpdateCommand do
|
4
|
+
subject { described_class.new(
|
5
|
+
expense_finder,
|
6
|
+
id,
|
7
|
+
amount,
|
8
|
+
currency,
|
9
|
+
add_tags,
|
10
|
+
rm_tags,
|
11
|
+
) }
|
12
|
+
|
13
|
+
let(:expense_finder) { instance_double(Domain::ExpenseFinder) }
|
14
|
+
let(:not_found) { instance_double(Domain::ExpenseNotFound) }
|
15
|
+
let(:not_found_view) { instance_double(Views::ExpenseNotFound) }
|
16
|
+
|
17
|
+
let(:view) { instance_double(Views::ExpenseUpdated) }
|
18
|
+
let(:not_updated_view) { instance_double(Views::ExpenseNotUpdated) }
|
19
|
+
|
20
|
+
let(:id) { "8ti0osfb" }
|
21
|
+
let(:amount) { nil }
|
22
|
+
let(:currency) { nil }
|
23
|
+
let(:add_tags) { nil }
|
24
|
+
let(:rm_tags) { nil }
|
25
|
+
|
26
|
+
before { allow(not_found).to receive(:update).and_return(not_found) }
|
27
|
+
|
28
|
+
context "when expense is not found" do
|
29
|
+
before { allow(expense_finder).to receive(:read).with(id).and_return(not_found) }
|
30
|
+
|
31
|
+
it "renders not found view" do
|
32
|
+
allow(not_found)
|
33
|
+
.to receive(:build_view)
|
34
|
+
.and_return(not_found_view)
|
35
|
+
expect(subject.call).to be(not_found_view)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context "when expense is found" do
|
40
|
+
let(:expense) { instance_double(Domain::Expense) }
|
41
|
+
|
42
|
+
before do
|
43
|
+
allow(expense_finder).to receive(:read).with(id).and_return(expense)
|
44
|
+
allow(expense).to receive(:update).and_return(expense)
|
45
|
+
|
46
|
+
allow(expense)
|
47
|
+
.to receive(:build_view)
|
48
|
+
.with(Views::ExpenseUpdated)
|
49
|
+
.and_return(view)
|
50
|
+
|
51
|
+
allow(expense)
|
52
|
+
.to receive(:build_view)
|
53
|
+
.with(Views::ExpenseNotUpdated)
|
54
|
+
.and_return(not_updated_view)
|
55
|
+
end
|
56
|
+
|
57
|
+
context "when there are no other options provided" do
|
58
|
+
it "does nothing" do
|
59
|
+
subject.call
|
60
|
+
end
|
61
|
+
|
62
|
+
it "renders ExpenseNotUpdated view" do
|
63
|
+
expect(subject.call).to be(not_updated_view)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
context "when there was some options provided" do
|
68
|
+
let(:amount) { 94.5 }
|
69
|
+
let(:currency) { "dollar" }
|
70
|
+
let(:add_tags) { ["food", "drinks"] }
|
71
|
+
let(:rm_tags) { ["dinner"] }
|
72
|
+
|
73
|
+
it "delegates to model's #update" do
|
74
|
+
expect(expense)
|
75
|
+
.to receive(:update)
|
76
|
+
.with(
|
77
|
+
amount: amount,
|
78
|
+
currency: currency,
|
79
|
+
add_tags: add_tags,
|
80
|
+
rm_tags: rm_tags,
|
81
|
+
).and_return(expense)
|
82
|
+
subject.call
|
83
|
+
end
|
84
|
+
|
85
|
+
it "returns ExpenseUpdated view" do
|
86
|
+
expect(subject.call).to be(view)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
module Views
|
4
|
+
RSpec.describe ExpenseItem do
|
5
|
+
subject(:view) {
|
6
|
+
described_class.new(
|
7
|
+
id: id,
|
8
|
+
created_at: created_at,
|
9
|
+
amount: amount,
|
10
|
+
currency: currency,
|
11
|
+
tags: tags,
|
12
|
+
)
|
13
|
+
}
|
14
|
+
|
15
|
+
let(:id) { "go5775ft" }
|
16
|
+
let(:created_at) { "14-04-2015 17:03:25" }
|
17
|
+
let(:amount) { 47.39 }
|
18
|
+
let(:currency) { "dollar" }
|
19
|
+
let(:tags) { ["other"] }
|
20
|
+
|
21
|
+
it "returns proper string representation" do
|
22
|
+
expect(view.to_s)
|
23
|
+
.to eq("go5775ft - 14-04-2015 17:03:25: 47.39 dollar [other]")
|
24
|
+
end
|
25
|
+
|
26
|
+
context "when number has more than 2 decimals after floating point" do
|
27
|
+
let(:amount) { 33.9876 }
|
28
|
+
it "rounds everything after 2 first decimals" do
|
29
|
+
expect(view.to_s)
|
30
|
+
.to eq("go5775ft - 14-04-2015 17:03:25: 33.99 dollar [other]")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "when number has less than 2 decimals after floating point" do
|
35
|
+
let(:amount) { 33.7 }
|
36
|
+
it "pads with zeros up to 2 decimals" do
|
37
|
+
expect(view.to_s)
|
38
|
+
.to eq("go5775ft - 14-04-2015 17:03:25: 33.70 dollar [other]")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context "when there are no tags" do
|
43
|
+
let(:tags) { [] }
|
44
|
+
it "does not render them" do
|
45
|
+
expect(view.to_s)
|
46
|
+
.to eq("go5775ft - 14-04-2015 17:03:25: 47.39 dollar")
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context "when there are more tags" do
|
51
|
+
let(:tags) { ["other", "food", "pricey"] }
|
52
|
+
it "does render them in sorted order" do
|
53
|
+
expect(view.to_s)
|
54
|
+
.to eq("go5775ft - 14-04-2015 17:03:25: 47.39 dollar [food other pricey]")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module MoneyTracking
|
2
|
+
module Cli
|
3
|
+
module Views
|
4
|
+
RSpec.describe ExpenseList do
|
5
|
+
subject(:view) {
|
6
|
+
described_class.new(
|
7
|
+
expenses,
|
8
|
+
item_factory,
|
9
|
+
)
|
10
|
+
}
|
11
|
+
|
12
|
+
let(:expenses) { [expense_a, expense_b, expense_c] }
|
13
|
+
let(:item_factory) { class_double(ExpenseItem) }
|
14
|
+
|
15
|
+
let(:expense_a) { instance_double(Domain::Expense) }
|
16
|
+
let(:expense_b) { instance_double(Domain::Expense) }
|
17
|
+
let(:expense_c) { instance_double(Domain::Expense) }
|
18
|
+
|
19
|
+
let(:item_a) { double("Item expense a", to_s: "an item a") }
|
20
|
+
let(:item_b) { double("Item expense b", to_s: "an item b") }
|
21
|
+
let(:item_c) { double("Item expense c", to_s: "an item c") }
|
22
|
+
|
23
|
+
before do
|
24
|
+
allow(expense_a).to receive(:build_view).with(item_factory).and_return(item_a)
|
25
|
+
allow(expense_b).to receive(:build_view).with(item_factory).and_return(item_b)
|
26
|
+
allow(expense_c).to receive(:build_view).with(item_factory).and_return(item_c)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "renders proper list" do
|
30
|
+
expect(view.to_s.split("\n"))
|
31
|
+
.to eq([
|
32
|
+
"an item a",
|
33
|
+
"an item b",
|
34
|
+
"an item c",
|
35
|
+
])
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
require "set"
|
2
|
+
|
3
|
+
RSpec.shared_context "acts as a DataStore" do |options|
|
4
|
+
store_reset = options.fetch(:store_reset, -> {})
|
5
|
+
|
6
|
+
before { store_reset.call }
|
7
|
+
after { store_reset.call }
|
8
|
+
|
9
|
+
shared_examples "returns nil" do
|
10
|
+
it "returns nil" do
|
11
|
+
is_expected.to eq(nil)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
shared_examples "returns empty record set" do
|
16
|
+
it "returns empty record set" do
|
17
|
+
is_expected.to be_empty
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "#create" do
|
22
|
+
subject { store.create(fields) }
|
23
|
+
let(:fields) { { hello: "world", test: 35, some: [1, 2, "3"] } }
|
24
|
+
|
25
|
+
it "returns unique id of created record" do
|
26
|
+
is_expected.to match(/^[\d\w]{8}$/)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "#read" do
|
31
|
+
subject { store.read(id) }
|
32
|
+
let(:id) { "some id" }
|
33
|
+
|
34
|
+
context "when there was some records created" do
|
35
|
+
let(:fields_a) { { hello: "world", test: 99 } }
|
36
|
+
let(:fields_b) { { crazy: "stuff", amount: 89.5, tags: ["hello", "world"] } }
|
37
|
+
let(:fields_c) { { lonely: "field" } }
|
38
|
+
|
39
|
+
let!(:id_a) { store.create(fields_a) }
|
40
|
+
let!(:id_b) { store.create(fields_b) }
|
41
|
+
let!(:id_c) { store.create(fields_c) }
|
42
|
+
|
43
|
+
let(:id) { id_b }
|
44
|
+
|
45
|
+
it "returns fields of the record with specified id" do
|
46
|
+
is_expected.to eq(fields_b.merge(id: id_b))
|
47
|
+
end
|
48
|
+
|
49
|
+
context "but record with specified id doesn't exist" do
|
50
|
+
let(:id) { "other id" }
|
51
|
+
|
52
|
+
include_examples "returns nil"
|
53
|
+
end
|
54
|
+
|
55
|
+
context "and was deleted afterwards" do
|
56
|
+
before { store.delete(id) }
|
57
|
+
|
58
|
+
include_examples "returns nil"
|
59
|
+
end
|
60
|
+
|
61
|
+
context "and was updated with new fields" do
|
62
|
+
before { store.update(id, new_fields) }
|
63
|
+
|
64
|
+
let(:new_fields) { fields_b.merge(amount: 44, hello: "world") }
|
65
|
+
|
66
|
+
it "returns updated fields of the record with specified id" do
|
67
|
+
is_expected.to eq(new_fields.merge(id: id_b))
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context "when record wasn't created" do
|
73
|
+
include_examples "returns nil"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "#list" do
|
78
|
+
subject { store.list.to_set }
|
79
|
+
|
80
|
+
context "when there was no record created" do
|
81
|
+
include_examples "returns empty record set"
|
82
|
+
end
|
83
|
+
|
84
|
+
context "when there was some records created" do
|
85
|
+
let(:fields_a) { { hello: "world", test: 99 } }
|
86
|
+
let(:fields_b) { { crazy: "stuff", amount: 89.5, tags: ["hello", "world"] } }
|
87
|
+
let(:fields_c) { { lonely: "field" } }
|
88
|
+
let(:fields_d) { { user: 79, name: "john" } }
|
89
|
+
|
90
|
+
let!(:id_a) { store.create(fields_a) }
|
91
|
+
let!(:id_b) { store.create(fields_b) }
|
92
|
+
let!(:id_c) { store.create(fields_c) }
|
93
|
+
let!(:id_d) { store.create(fields_d) }
|
94
|
+
|
95
|
+
let(:final_a) { fields_a.merge(id: id_a) }
|
96
|
+
let(:final_b) { fields_b.merge(id: id_b) }
|
97
|
+
let(:final_c) { fields_c.merge(id: id_c) }
|
98
|
+
let(:final_d) { fields_d.merge(id: id_d) }
|
99
|
+
|
100
|
+
it "returns all these records" do
|
101
|
+
is_expected.to eq([final_a, final_b, final_c, final_d].to_set)
|
102
|
+
end
|
103
|
+
|
104
|
+
context "and some records were updated" do
|
105
|
+
before do
|
106
|
+
store.update(id_a, new_fields_a)
|
107
|
+
store.update(id_c, new_fields_c)
|
108
|
+
end
|
109
|
+
|
110
|
+
let(:new_fields_a) { fields_a.merge(lazy: "programmer", pragmatic: "programmer") }
|
111
|
+
let(:new_fields_c) { fields_c.merge(amount: 44, hello: "world") }
|
112
|
+
|
113
|
+
let(:new_final_a) { new_fields_a.merge(id: id_a) }
|
114
|
+
let(:new_final_c) { new_fields_c.merge(id: id_c) }
|
115
|
+
|
116
|
+
it "returns all these records with updated fields" do
|
117
|
+
is_expected.to eq([new_final_a, final_b, new_final_c, final_d].to_set)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
context "and some records were deleted" do
|
122
|
+
before do
|
123
|
+
store.delete(id_b)
|
124
|
+
store.delete(id_c)
|
125
|
+
end
|
126
|
+
|
127
|
+
it "returns all the records that are left" do
|
128
|
+
is_expected.to eq([final_a, final_d].to_set)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
context "and all records were deleted" do
|
133
|
+
before do
|
134
|
+
store.delete(id_a)
|
135
|
+
store.delete(id_b)
|
136
|
+
store.delete(id_c)
|
137
|
+
store.delete(id_d)
|
138
|
+
end
|
139
|
+
|
140
|
+
include_examples "returns empty record set"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|