itunes-connect 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,30 @@
1
+ require "yaml"
2
+
3
+ class ItunesConnect::RcFile # :nodoc:
4
+
5
+ DEFAULT_RCFILE_PATH = File.expand_path("~/.itunesrc")
6
+
7
+ def self.default
8
+ self.new(DEFAULT_RCFILE_PATH)
9
+ end
10
+
11
+ def initialize(path=DEFAULT_RCFILE_PATH)
12
+ if File.exist?(path)
13
+ @rc = YAML.load_file(path)
14
+ else
15
+ @rc = { }
16
+ end
17
+ end
18
+
19
+ def username
20
+ @rc[:username]
21
+ end
22
+
23
+ def password
24
+ @rc[:password]
25
+ end
26
+
27
+ def database
28
+ @rc[:database]
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ require "ostruct"
2
+
3
+ # This class transforms the raw input given in the constructor into a
4
+ # series of objects representing each row. You can either get the
5
+ # entire set of data by accessing the +data+ attribute, or by calling
6
+ # the +each+ method and handing it a block.
7
+ class ItunesConnect::Report
8
+ include Enumerable
9
+
10
+ # The report as a Hash, where the keys are country codes and the
11
+ # values are Hashes with the keys, <tt>:date</tt>, <tt>:upgrade</tt>,
12
+ # <tt>:install</tt>.
13
+ attr_reader :data
14
+
15
+ # Give me an +IO+-like object (one that responds to the +each+
16
+ # method) and I'll parse that sucker for you.
17
+ def initialize(input)
18
+ @data = Hash.new { |h,k| h[k] = { }}
19
+ input.each do |line|
20
+ line.chomp!
21
+ next if line =~ /^(Provider|$)/
22
+ tokens = line.split(/\s+/)
23
+ country = tokens[11]
24
+ count = tokens[6].to_i
25
+ @data[country][:date] = Date.parse(tokens[8])
26
+ case tokens[5].to_i
27
+ when 7
28
+ @data[country][:upgrade] = count
29
+ when 1
30
+ @data[country][:install] = count
31
+ end
32
+ end
33
+ end
34
+
35
+ # Yields each parsed data row to the given block. Each item yielded
36
+ # has the following attributes:
37
+ # * country
38
+ # * date
39
+ # * install_count
40
+ # * upgrade_count
41
+ def each # :yields: record
42
+ @data.each do |country, value|
43
+ if block_given?
44
+ yield OpenStruct.new(:country => country,
45
+ :date => value[:date],
46
+ :install_count => value[:install] || 0,
47
+ :upgrade_count => value[:upgrade] || 0)
48
+ end
49
+ end
50
+ end
51
+
52
+ # The total number of rows in the report
53
+ def size
54
+ @data.size
55
+ end
56
+ end
@@ -0,0 +1,129 @@
1
+ require "sqlite3"
2
+ require "ostruct"
3
+
4
+ module ItunesConnect
5
+ # Represents a database stored on disk.
6
+ class Store
7
+ # Creates a new instance. If no database file exists at the given
8
+ # path a new one is created and the correct tables and indexes are
9
+ # added.
10
+ def initialize(file, verbose=false)
11
+ @db = SQLite3::Database.new(file)
12
+ if @db.table_info("reports").empty?
13
+ @db.execute("CREATE TABLE reports (id INTEGER PRIMARY KEY, " +
14
+ "report_date DATE, country TEXT, install_count INTEGER, " +
15
+ "update_count INTEGER)")
16
+ @db.execute("CREATE UNIQUE INDEX u_reports_idx ON reports " +
17
+ "(report_date, country)")
18
+ end
19
+ @verbose = verbose
20
+ end
21
+
22
+ def verbose? # :nodoc:
23
+ !!@verbose
24
+ end
25
+
26
+ # Add a record to this instance
27
+ def add(date, country, install_count, update_count)
28
+ @db.execute("INSERT INTO reports (report_date, country, " +
29
+ "install_count, update_count) VALUES (?, ?, ?, ?)",
30
+ date, country, install_count, update_count)
31
+ true
32
+ rescue SQLite3::SQLException => e
33
+ if e.message =~ /columns .* are not unique/
34
+ $stdout.puts "Skipping existing row for #{country} on #{date}" if verbose?
35
+ false
36
+ else
37
+ raise e
38
+ end
39
+ end
40
+
41
+ VALID_COUNT_OPTIONS = [:from, :to, :country]
42
+ # Get counts optionally constrained by dates and/or country codes.
43
+ # Available options are:
44
+ # <tt>:from</tt>:: The from date, defaults to the beginning
45
+ # <tt>:to</tt>:: The end date, defaults to now
46
+ # <tt>:country</tt>:: The country code, defaults to <tt>nil</tt>
47
+ # which means no country code restriction
48
+ def counts(opts={ })
49
+ unless (leftovers = opts.keys - VALID_COUNT_OPTIONS).empty?
50
+ raise "Invalid keys: #{leftovers.join(', ')}"
51
+ end
52
+
53
+ params = []
54
+ clauses = []
55
+ sql = "SELECT * FROM reports"
56
+
57
+ if opts[:from]
58
+ clauses << "report_date >= ?"
59
+ params << opts[:from]
60
+ end
61
+
62
+ if opts[:to]
63
+ clauses << "report_date <= ?"
64
+ params << opts[:to]
65
+ end
66
+
67
+ if opts[:country]
68
+ clauses << "country = ?"
69
+ params << opts[:country]
70
+ end
71
+
72
+ sql << " WHERE " unless clauses.empty?
73
+ sql << clauses.join(" AND ") unless params.empty?
74
+ sql << " ORDER BY report_date DESC"
75
+
76
+ @db.execute(sql, *params).map do |row|
77
+ OpenStruct.new({
78
+ :report_date => Date.parse(row[1]),
79
+ :country => row[2],
80
+ :install_count => row[3].to_i,
81
+ :update_count => row[4].to_i
82
+ })
83
+ end
84
+ end
85
+
86
+ # Get summed counts by country, optionally constrained by dates
87
+ # and/or country codes. Available options are:
88
+ # <tt>:from</tt>:: The from date, defaults to the beginning
89
+ # <tt>:to</tt>:: The end date, defaults to now
90
+ # <tt>:country</tt>:: The country code, defaults to <tt>nil</tt>
91
+ # which means no country code restriction
92
+ def country_counts(opts={ })
93
+ unless (leftovers = opts.keys - VALID_COUNT_OPTIONS).empty?
94
+ raise "Invalid keys: #{leftovers.join(', ')}"
95
+ end
96
+
97
+ params = []
98
+ clauses = []
99
+ sql = "SELECT country, SUM(install_count), SUM(update_count) FROM reports"
100
+
101
+ if opts[:from]
102
+ clauses << "report_date >= ?"
103
+ params << opts[:from]
104
+ end
105
+
106
+ if opts[:to]
107
+ clauses << "report_date <= ?"
108
+ params << opts[:to]
109
+ end
110
+
111
+ if opts[:country]
112
+ clauses << "country = ?"
113
+ params << opts[:country]
114
+ end
115
+
116
+ sql << " WHERE " unless clauses.empty?
117
+ sql << clauses.join(" AND ") unless params.empty?
118
+ sql << " GROUP BY country ORDER BY country"
119
+
120
+ @db.execute(sql, *params).map do |row|
121
+ OpenStruct.new({
122
+ :country => row[0],
123
+ :install_count => row[1].to_i,
124
+ :update_count => row[2].to_i
125
+ })
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,140 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe ItunesConnect::Commands::Download do
4
+ before(:each) do
5
+ @cmd = ItunesConnect::Commands::Download.new(mock(:null_object => true),
6
+ mock(:username => nil,
7
+ :password => nil,
8
+ :database => nil))
9
+ @defaults = {
10
+ :username => 'dudeman',
11
+ :password => 'sekret',
12
+ :date => nil,
13
+ :out => nil,
14
+ :db => nil,
15
+ :verbose? => false,
16
+ :debug? => false,
17
+ :report => 'Daily'
18
+ }
19
+ end
20
+
21
+ describe 'with valid execution arguments' do
22
+ before(:each) do
23
+ @connection = mock(ItunesConnect::Connection)
24
+ ItunesConnect::Connection.should_receive(:new).
25
+ with('dudeman', 'sekret', false, false).
26
+ and_return(@connection)
27
+ end
28
+
29
+ it 'should call get_report correctly with no args' do
30
+ @connection.should_receive(:get_report).with(Date.today - 1, $stdout, 'Daily')
31
+ opts = stub(@defaults)
32
+ @cmd.execute!(opts)
33
+ end
34
+
35
+ it 'should call get_report with date argument when given' do
36
+ today = Date.today - 15
37
+ @connection.should_receive(:get_report).with(today, $stdout, 'Daily')
38
+ opts = stub(@defaults.merge(:date => today))
39
+ @cmd.execute!(opts)
40
+ end
41
+
42
+ it 'should call get_report with File object when path is given' do
43
+ @connection.should_receive(:get_report).with(Date.today - 1,
44
+ an_instance_of(File),
45
+ 'Daily')
46
+ opts = stub(@defaults.merge(:out => '/tmp/foobar'))
47
+ @cmd.execute!(opts)
48
+ end
49
+
50
+ it 'should use the given report type' do
51
+ @connection.should_receive(:get_report).with(Date.today - 1, $stdout, 'Weekly')
52
+ opts = stub(@defaults.merge({ :report => 'Weekly' }))
53
+ @cmd.execute!(opts)
54
+ end
55
+
56
+ describe 'and the :db option is specified' do
57
+ it 'should import the results into the DB' do
58
+ t = Date.parse('8/31/2009')
59
+ @connection.should_receive(:get_report) do |date, io, report|
60
+ io << read_fixture('fixtures/report.txt')
61
+ io.flush
62
+ end
63
+
64
+ store = mock(ItunesConnect::Store)
65
+ store.should_receive(:add).with(t, 'GB', 0, 1)
66
+ store.should_receive(:add).with(t, 'AR', 0, 1)
67
+ store.should_receive(:add).with(t, 'US', 1, 3)
68
+ ItunesConnect::Store.should_receive(:new).
69
+ with('/tmp/foobar.db', false).
70
+ and_return(store)
71
+
72
+ opts = stub(@defaults.merge(:db => '/tmp/foobar.db',
73
+ :date => '2009/08/31'))
74
+ @cmd.execute!(opts)
75
+ end
76
+ end
77
+ end
78
+
79
+ describe 'checking execution arguments' do
80
+ it 'should get grumpy when no username or password is given' do
81
+ lambda { @cmd.execute! }.should raise_error(ArgumentError)
82
+ lambda { @cmd.execute!(stub(@defaults.merge(:password => nil))) }.
83
+ should raise_error(ArgumentError)
84
+
85
+ lambda { @cmd.execute!(stub(@defaults.merge(:username => nil))) }.
86
+ should raise_error(ArgumentError)
87
+ end
88
+
89
+ it 'should reject getting both :out and :db options' do
90
+ lambda do
91
+ opts = stub(@defaults.merge(:db => '/tmp/foobar.db',
92
+ :out => '/tmp/foobar.txt'))
93
+ @cmd.execute!(opts)
94
+ end.should raise_error(ArgumentError)
95
+ end
96
+
97
+ it 'should reject invalid report types' do
98
+ lambda do
99
+ opts = stub(@defaults.merge(:report => 'Glowing'))
100
+ @cmd.execute!(opts)
101
+ end.should raise_error(ArgumentError)
102
+ end
103
+
104
+ it 'should reject requests to store monthly reports in the database' do
105
+ lambda do
106
+ opts = stub(@defaults.merge(:report => 'Monthly', :db => '/tmp/foo.db'))
107
+ @cmd.execute!(opts)
108
+ end.should raise_error(ArgumentError)
109
+ end
110
+ end
111
+
112
+ describe 'setting up command-line options' do
113
+ it 'should add appropriate options to given Clip' do
114
+ clip = mock('Clip')
115
+ clip.should_receive(:opt).
116
+ with('u', 'username',
117
+ :desc => 'iTunes Connect username')
118
+ clip.should_receive(:opt).
119
+ with('p', 'password',
120
+ :desc => 'iTunes Connect password')
121
+ clip.should_receive(:opt).
122
+ with('d', 'date',
123
+ :desc => 'Daily report date (MM/DD/YYYY format)',
124
+ :default => (Date.today - 1).strftime('%m/%d/%Y'))
125
+ clip.should_receive(:opt).
126
+ with('o', 'out',
127
+ :desc => 'Dump report to file, - is stdout')
128
+ clip.should_receive(:opt).
129
+ with('b', 'db',
130
+ :desc => 'Dump report to sqlite DB at the given path')
131
+ clip.should_receive(:opt).
132
+ with('r', 'report',
133
+ :desc => 'Report type. One of "Daily", "Weekly", "Monthly"',
134
+ :default => 'Daily')
135
+
136
+ ItunesConnect::Commands::Download.new(clip)
137
+ end
138
+ end
139
+
140
+ end
@@ -0,0 +1,64 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+ require "clip"
3
+
4
+ describe ItunesConnect::Commands::Help do
5
+ before(:each) do
6
+ @cmd = ItunesConnect::Commands::Help.new(mock(:null_object => true))
7
+ end
8
+
9
+ describe 'with valid execution arguments' do
10
+ before(:each) do
11
+ @io = StringIO.new
12
+ end
13
+
14
+ it 'should dump the "download" command usage to :out' do
15
+ @cmd.execute!(nil, %w(download), @io)
16
+ clip = ItunesConnect::Commands.default_clip
17
+ clip.banner = "Command options for 'download':"
18
+ ItunesConnect::Commands::Download.new(clip)
19
+ @io.string.should == clip.help
20
+ end
21
+
22
+ it 'should dump the "import" command usage to :out' do
23
+ @cmd.execute!(nil, %w(import), @io)
24
+ clip = ItunesConnect::Commands.default_clip
25
+ clip.banner = "Command options for 'import':"
26
+ ItunesConnect::Commands::Import.new(clip)
27
+ @io.string.should == clip.help
28
+ end
29
+
30
+ it 'should dump the "report" command usage to :out' do
31
+ @cmd.execute!(nil, %w(report), @io)
32
+ clip = ItunesConnect::Commands.default_clip
33
+ clip.banner = "Command options for 'report':"
34
+ ItunesConnect::Commands::Report.new(clip)
35
+ @io.string.should == clip.help
36
+ end
37
+ end
38
+
39
+ describe 'when no command name is given' do
40
+ it 'should raise an ArgumentError' do
41
+ io = StringIO.new
42
+ @cmd.execute!(nil, [], io)
43
+ io.string.should == <<-EOF
44
+ Available commands:
45
+
46
+ download Retrieves reports from the iTunes Connect site
47
+ import Imports report data into a database file
48
+ report Generates reports from a local database
49
+ help Describe a particular command
50
+ EOF
51
+
52
+ end
53
+ end
54
+
55
+ describe 'when usage is requested for an unrecognized command' do
56
+
57
+ it 'should raise an ArgumentError' do
58
+ lambda { @cmd.execute!({ :out => @io }, %w(bazooka)) }.
59
+ should raise_error(ArgumentError)
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,66 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+ require "tempfile"
3
+
4
+ describe ItunesConnect::Commands::Import do
5
+
6
+ before(:each) do
7
+ @cmd = ItunesConnect::Commands::Import.new(mock(:null_object => true),
8
+ mock(:username => nil,
9
+ :password => nil,
10
+ :database => nil))
11
+ end
12
+
13
+ describe 'with valid execution arguments' do
14
+ before(:each) do
15
+ @store = mock(ItunesConnect::Store)
16
+ ItunesConnect::Store.should_receive(:new).
17
+ with("/tmp/store.db", false).
18
+ and_return(@store)
19
+ end
20
+
21
+ it 'should add a record to the store for each row of data' do
22
+ t = Date.parse('8/31/2009')
23
+ @store.should_receive(:add).with(t, 'GB', 0, 1)
24
+ @store.should_receive(:add).with(t, 'AR', 0, 1)
25
+ @store.should_receive(:add).with(t, 'US', 1, 3)
26
+ report_file = File.join(File.dirname(__FILE__), '..', 'fixtures', 'report.txt')
27
+ @cmd.execute!(stub(:db => '/tmp/store.db',
28
+ :file => report_file,
29
+ :verbose? => false))
30
+ end
31
+ end
32
+
33
+ describe 'execution argument validation' do
34
+ it 'should reject missing all options' do
35
+ lambda { @cmd.execute! }.should raise_error(ArgumentError)
36
+ end
37
+
38
+ it 'should reject missing :file option' do
39
+ lambda do
40
+ @cmd.execute!(stub(:db => '/tmp/store.db', :file => nil))
41
+ end.should raise_error(ArgumentError)
42
+ end
43
+
44
+ it 'should reject missing :db option' do
45
+ lambda do
46
+ @cmd.execute!(stub(:db => nil, :file => '/tmp/report.txt', :verbose? => false))
47
+ end.should raise_error(ArgumentError)
48
+ end
49
+ end
50
+
51
+ describe 'setting up command-line parsing' do
52
+
53
+ it 'should add appropriate options to a given Clip' do
54
+ clip = mock('Clip')
55
+ clip.should_receive(:opt).
56
+ with('b', 'db',
57
+ :desc => 'Dump report to sqlite DB at the given path')
58
+ clip.should_receive(:req).
59
+ with('f', 'file',
60
+ :desc => 'The file to import, - means standard in')
61
+
62
+ ItunesConnect::Commands::Import.new(clip)
63
+ end
64
+ end
65
+
66
+ end