money_tracking 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|