itunes-connect 0.9.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.
@@ -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