itunes_store_transporter 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,83 @@
1
+ require "itunes/store/transporter/errors"
2
+
3
+ module ITunes
4
+ module Store
5
+ class Transporter
6
+ class OutputParser
7
+
8
+ ##
9
+ # This class extracts error and warning messages output by +iTMSTransporter+. For each message
10
+ # is creates an instance of ITunes::Store::Transporter::TransporterMessage
11
+ #
12
+
13
+ attr :errors
14
+ attr :warnings
15
+
16
+ ERROR_LINE = />\s+ERROR:\s+(.+)/
17
+ WARNING_LINE = />\s+WARN:\s+(.+)/
18
+
19
+ # Generic messages we want to ignore.
20
+ SKIP_ERRORS = [ /\boperation was not successful/i,
21
+ /\bunable to verify the package/i,
22
+ /\bwill NOT be verified/,
23
+ /^an error has occurred/i,
24
+ /^an error occurred while/i,
25
+ /^unknown operation/i,
26
+ /\bunable to authenticate the package/i ]
27
+
28
+ ##
29
+ # === Arguments
30
+ #
31
+ # [output (Array)] +iTMSTransporter+ output
32
+ #
33
+ def initialize(output)
34
+ @errors = []
35
+ @warnings = []
36
+ parse_output(output) if Array === output
37
+ end
38
+
39
+ private
40
+ def parse_output(output)
41
+ output.each do |line|
42
+ if line =~ ERROR_LINE
43
+ error = $1
44
+ next if SKIP_ERRORS.any? { |skip| error =~ skip }
45
+ errors << create_message(error)
46
+ elsif line =~ WARNING_LINE
47
+ warnings << create_message($1)
48
+ end
49
+ end
50
+
51
+ # Unique messages only. The block form of uniq() not available on Ruby < 1.9.2
52
+ [errors, warnings].each do |e|
53
+ e.replace(e.inject({}) do |uniq, x|
54
+ uniq[x.message] = x
55
+ uniq
56
+ end.values)
57
+ end
58
+ end
59
+
60
+ def create_message(line)
61
+ case line
62
+ when /^(?:ERROR|WARNING)\s+ITMS-(\d+):\s+(.+)/
63
+ code = $1
64
+ message = $2
65
+ when /(.+)\s+\((\d+)\)$/,
66
+ # Is this correct?
67
+ /(.+)\s+errorCode\s+=\s+\((\d+)\)$/
68
+ message = $1
69
+ code = $2
70
+ else
71
+ message = line
72
+ code = nil
73
+ end
74
+
75
+ message.gsub! /^"/, ""
76
+ message.gsub! /"(?: at .+)?$/, ""
77
+
78
+ TransporterMessage.new(message, code ? code.to_i : nil)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,94 @@
1
+ require "childprocess"
2
+
3
+ module ITunes
4
+ module Store
5
+ class Transporter
6
+
7
+ class Shell # :nodoc:
8
+ attr :path
9
+
10
+ EXE_NAME = "iTMSTransporter"
11
+ WINDOWS_EXE = "#{EXE_NAME}.CMD"
12
+ DEFAULT_UNIX_PATH = "/usr/local/itms/bin/#{EXE_NAME}"
13
+
14
+ class << self
15
+ def windows?
16
+ # We just need to know where iTMSTransporter lives, though cygwin
17
+ # can crow when it receives a Windows path.
18
+ ChildProcess.windows? || ChildProcess.os == :cygwin
19
+ end
20
+
21
+ def default_path
22
+ if windows?
23
+ # The Transporter installer prefers x86
24
+ # But... I think ruby normalizes this to just PROGRAMFILES
25
+ root = ENV["PROGRAMFILES(x86)"] || ENV["PROGRAMFILES"] # Need C:\ in case?
26
+ File.join(root, "itms", WINDOWS_EXE)
27
+ else
28
+ DEFAULT_UNIX_PATH
29
+ end
30
+ end
31
+ end
32
+
33
+ def initialize(path = nil)
34
+ @path = path || self.class.default_path
35
+ end
36
+
37
+ def exec(argv, &block)
38
+ begin
39
+ process = ChildProcess.build(path, *argv)
40
+
41
+ stdout = IO.pipe
42
+ stderr = IO.pipe
43
+
44
+ stdout[1].sync = true
45
+ process.io.stdout = stdout[1]
46
+
47
+ stderr[1].sync = true
48
+ process.io.stderr = stderr[1]
49
+
50
+ process.start
51
+
52
+ stdout[1].close
53
+ stderr[1].close
54
+
55
+ poll(stdout[0], stderr[0], &block)
56
+ rescue ChildProcess::Error, SystemCallError => e
57
+ raise ITunes::Store::Transporter::TransporterError, e.message
58
+ ensure
59
+ process.wait if process.alive?
60
+ [ stdout, stderr ].flatten.each { |io| io.close if !io.closed? }
61
+ end
62
+
63
+ process.exit_code
64
+ end
65
+
66
+ private
67
+ def poll(stdout, stderr)
68
+ read = [ stdout, stderr ]
69
+
70
+ loop do
71
+ if ready = select(read, nil, nil, 1)
72
+ ready.each do |set|
73
+ next unless set.any?
74
+
75
+ set.each do |io|
76
+ if io.eof?
77
+ read.delete(io)
78
+ next
79
+ end
80
+
81
+ name = io == stdout ? :stdout : :stderr
82
+ yield(io.gets, name)
83
+ end
84
+
85
+ end
86
+ end
87
+ break unless read.any?
88
+ end
89
+ end
90
+
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,7 @@
1
+ module ITunes
2
+ module Store
3
+ class Transporter
4
+ VERSION = "0.0.1"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,511 @@
1
+ require "spec_helper"
2
+ require "stringio"
3
+
4
+ shared_examples_for "a transporter option" do |option, expected|
5
+ it "creates the correct command line argument" do
6
+ ITunes::Store::Transporter::Shell.any_instance.stub(:exec) { |*arg| arg.first.should include(*expected); 0 }
7
+ subject.run(options.merge(option))
8
+ end
9
+ end
10
+
11
+ shared_examples_for "a vendor_id option" do
12
+ it_should_behave_like "a transporter option", { :vendor_id => "vID" }, "-vendor_id", "vID"
13
+ end
14
+
15
+ shared_examples_for "a transporter option that expects a directory" do |option, expected|
16
+ context "when the directory exists" do
17
+ it_should_behave_like "a transporter option", {option => "."}, expected, "."
18
+ end
19
+
20
+ context "when the directory does not exist" do
21
+ it "raises an OptionError" do
22
+ lambda { subject.run(options.merge(option => "__baaaaahd_directory__")) }.should raise_exception(ITunes::Store::Transporter::OptionError, /does not exist/)
23
+ end
24
+ end
25
+ end
26
+
27
+ shared_examples_for "a boolean transporter option" do |option, expected|
28
+ context "when true" do
29
+ it "creates the command line argument" do
30
+ ITunes::Store::Transporter::Shell.any_instance.stub(:exec) { |*arg| arg.first.should include(*expected); 0 }
31
+ subject.run(options.merge(option => true))
32
+ end
33
+ end
34
+
35
+ context "when false" do
36
+ it "does not create the command line argument" do
37
+ ITunes::Store::Transporter::Shell.any_instance.stub(:exec) { |*arg| arg.first.should_not include(*expected); 0 }
38
+ subject.run(options.merge(option => false))
39
+ end
40
+ end
41
+
42
+ context "when not boolean" do
43
+ it "raises an OptionError" do
44
+ lambda { subject.run(options.merge(option => "sshaw")) }.should raise_exception(ITunes::Store::Transporter::OptionError, /does not accept/)
45
+ end
46
+ end
47
+ end
48
+
49
+ shared_examples_for "a required option" do |option|
50
+ it "must have a value" do
51
+ ["", nil].each do |value|
52
+ lambda { subject.run(options.merge(option => value)) }.should raise_exception(ITunes::Store::Transporter::OptionError, /#{option}/)
53
+ end
54
+ end
55
+ end
56
+
57
+ shared_examples_for "a command that accepts a shortname argument" do
58
+ context "when the shortname's invalid" do
59
+ it "raises an OptionError" do
60
+ lambda { subject.run(options.merge(:shortname => "+")) }.should raise_exception(ITunes::Store::Transporter::OptionError, /shortname/)
61
+ end
62
+ end
63
+
64
+ context "when the shortname's valid" do
65
+ it "does not raise an exception" do
66
+ mock_output
67
+ lambda { subject.run(options.merge(:shortname => "Too $hort")) }.should_not raise_exception
68
+ end
69
+ end
70
+ end
71
+
72
+ shared_examples_for "a subclass of Command::Base" do
73
+ it { should be_a_kind_of(ITunes::Store::Transporter::Command::Base) }
74
+
75
+ context "when on Windows" do
76
+ it "automatically sets NoPause to true" do
77
+ ENV["PROGRAMFILES"] = "C:\\"
78
+ shell = ITunes::Store::Transporter::Shell
79
+ shell.any_instance.stub(:exec) { |*arg| arg.first.should include("-WONoPause", "true"); 0 }
80
+ shell.stub(:windows? => true)
81
+ subject.run(options)
82
+ end
83
+ end
84
+
85
+ describe "options" do
86
+ describe ":print_stderr" do
87
+ before :each do
88
+ @realerr = $stderr
89
+ $stderr = StringIO.new
90
+ mock_output(:stderr => ["ERR 1"])
91
+ described_class.new(:print_stderr => print?).run(options)
92
+ end
93
+
94
+ after(:each) { $stderr = @realerr }
95
+
96
+ context "when true" do
97
+ let(:print?) { true }
98
+
99
+ it "prints to stderr" do
100
+ $stderr.string.chomp.should == "ERR 1"
101
+ end
102
+ end
103
+
104
+ context "when false" do
105
+ let(:print?) { false }
106
+
107
+ it "does not print to stderr" do
108
+ $stderr.string.should be_empty
109
+ end
110
+ end
111
+ end
112
+
113
+ # TODO: Needs some DRYing
114
+ describe ":print_stdout" do
115
+ before :each do
116
+ @realout = $stdout
117
+ $stdout = StringIO.new
118
+ mock_output(:stdout => ["OUT 1"])
119
+ described_class.new(:print_stdout => print?).run(options)
120
+ end
121
+
122
+ after(:each) { $stdout = @realout }
123
+
124
+ context "when true" do
125
+ let(:print?) { true }
126
+
127
+ it "prints to stdout" do
128
+ $stdout.string.chomp.should == "OUT 1"
129
+ end
130
+ end
131
+
132
+ context "when false" do
133
+ let(:print?) { false }
134
+
135
+ it "does not print to stdout" do
136
+ $stdout.string.should be_empty
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ context "when successful" do
143
+ it "calls #handle_success" do
144
+ mock_output(:exit => 0)
145
+ subject.should_receive(:handle_success)
146
+ subject.should_not_receive(:handle_error)
147
+ subject.run(options)
148
+ end
149
+ end
150
+
151
+ context "when not successful" do
152
+ it "calls #handler_error" do
153
+ mock_output(:exit => 1)
154
+ subject.should_receive(:handle_error)
155
+ subject.should_not_receive(:handle_success)
156
+ subject.run(options)
157
+ end
158
+
159
+ context "when an error is output to stderr" do
160
+ it "raises an ExecutionError" do
161
+ mock_output(:exit => 1, :stderr => "stderr.errors")
162
+ lambda { subject.run(options) }.should raise_error { |e|
163
+ e.should be_a(ITunes::Store::Transporter::ExecutionError)
164
+
165
+ e.exitstatus.should == 1
166
+ e.errors.should have(2).items
167
+
168
+ # just check one
169
+ e.errors[0].should be_a(ITunes::Store::Transporter::TransporterMessage)
170
+ e.errors[0].code.should == 9000
171
+ e.errors[0].message.should match("Your audio of screwed up!")
172
+ e.errors[1].code.should == 4009
173
+ e.errors[1].message.should match("Chapter timecode is just plain wrong")
174
+ }
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ shared_examples_for "a transporter mode" do
181
+ it_should_behave_like "a subclass of Command::Base"
182
+ it { should be_a_kind_of(ITunes::Store::Transporter::Command::Mode) }
183
+
184
+ it "requires a username" do
185
+ args = options
186
+ args.delete(:username)
187
+ lambda { subject.run(args) }.should raise_error(ITunes::Store::Transporter::OptionError, /username/)
188
+ end
189
+
190
+ it "requires a password" do
191
+ args = options
192
+ args.delete(:password)
193
+ lambda { subject.run(args) }.should raise_error(ITunes::Store::Transporter::OptionError, /password/)
194
+ end
195
+ end
196
+
197
+ shared_examples_for "a command that requires a package argument" do
198
+ it_should_behave_like "a required option", :package
199
+
200
+ context "when a directory" do
201
+ before(:all) do
202
+ @tmpdir = Dir.mktmpdir
203
+ @pkgdir = File.join(@tmpdir, "package.itmsp")
204
+ Dir.mkdir(@pkgdir)
205
+ end
206
+
207
+ after(:all) { FileUtils.rm_rf(@tmpdir) }
208
+
209
+ it "must end in .itmsp" do
210
+ options = create_options(:package => @tmpdir)
211
+ lambda { subject.run(options) }.should raise_error(ITunes::Store::Transporter::OptionError, /must match/i)
212
+
213
+ mock_output(:exit => 0)
214
+ options = create_options(:package => @pkgdir)
215
+ lambda { subject.run(options) }.should_not raise_error
216
+ end
217
+
218
+ it "must exist" do
219
+ options = create_options(:package => File.join(@tmpdir, "badpkg.itmsp"))
220
+ lambda { subject.run(options) }.should raise_error(ITunes::Store::Transporter::OptionError, /does not exist/i)
221
+ end
222
+ end
223
+
224
+ context "when a file" do
225
+ it "raises an OptionError" do
226
+ path = Tempfile.new("").path
227
+ options = create_options(:package => path)
228
+ # TODO: Optout's error message will probably be changed to something more descriptive, change this when that happens
229
+ lambda { subject.run(options) }.should raise_error(ITunes::Store::Transporter::OptionError, /dir/i)
230
+ end
231
+ end
232
+ end
233
+
234
+ describe ITunes::Store::Transporter::Command::Providers do
235
+ it_behaves_like "a transporter mode"
236
+
237
+ subject { described_class.new({}) }
238
+ let(:options) { create_options }
239
+ its(:mode) { should == "provider" }
240
+
241
+ describe "#run" do
242
+ it "returns the shortname and longname for each provider" do
243
+ mock_output(:stdout => "providers.two", :stderr => "stderr.info")
244
+ subject.run(options).should == [ { :longname => "Some Great User", :shortname => "luser" },
245
+ { :longname => "Skye's Taco Eating Service Inc.", :shortname => "conmuchacebolla" } ]
246
+ end
247
+ end
248
+ end
249
+
250
+ describe ITunes::Store::Transporter::Command::Upload do
251
+ it_behaves_like "a transporter mode"
252
+ it_behaves_like "a command that requires a package argument"
253
+ it_behaves_like "a command that accepts a shortname argument"
254
+
255
+ subject { described_class.new({}) }
256
+ let(:options) { create_options(:package => create_package, :transport => "Aspera") }
257
+ after(:each) { FileUtils.rm_rf(options[:package]) }
258
+
259
+ describe "#run" do
260
+ context "when successful" do
261
+ it "returns true" do
262
+ mock_output(:stdout => "stdout.success")
263
+ subject.run(options).should be_true
264
+ end
265
+ end
266
+ end
267
+
268
+ describe "options" do
269
+ describe ":rate" do
270
+ it "must be an integer" do
271
+ lambda { subject.run(options.merge(:rate => "123")) }.should raise_exception(ITunes::Store::Transporter::OptionError, /rate/)
272
+ end
273
+
274
+ it_should_behave_like "a transporter option", {:rate => 123}, "-k", "123"
275
+ end
276
+
277
+ describe ":transport" do
278
+ %w|Aspera Signiant DAV|.each do |name|
279
+ context "with #{name}" do
280
+ it_should_behave_like "a transporter option", {:transport => name}, "-t", name
281
+ end
282
+ end
283
+
284
+ it "is case sensitive" do
285
+ lambda { subject.run(options.merge(:transport => "aspera")) }.should raise_exception(ITunes::Store::Transporter::OptionError)
286
+ end
287
+
288
+ it "raises an OptionError if the transport is not supported" do
289
+ lambda { subject.run(options.merge(:transport => "ftp")) }.should raise_exception(ITunes::Store::Transporter::OptionError)
290
+ end
291
+ end
292
+
293
+ describe ":delete" do
294
+ it_should_behave_like "a boolean transporter option", :delete, "-delete"
295
+ end
296
+
297
+ describe ":log_history" do
298
+ it_should_behave_like "a transporter option that expects a directory", :log_history, "-loghistory"
299
+ end
300
+
301
+ describe ":success" do
302
+ it_should_behave_like "a transporter option that expects a directory", :success, "-success"
303
+ end
304
+
305
+ describe ":failure" do
306
+ it_should_behave_like "a transporter option that expects a directory", :failure, "-failure"
307
+ end
308
+ end
309
+ end
310
+
311
+ describe ITunes::Store::Transporter::Command::Lookup do
312
+ it_behaves_like "a transporter mode"
313
+ it_behaves_like "a command that accepts a shortname argument"
314
+
315
+ subject { described_class.new({}) }
316
+
317
+ let(:options) { create_options(:vendor_id => "X") }
318
+ its(:mode) { should == "lookupMetadata" }
319
+
320
+ # Fake the directory iTMSTransporter creates for the metadata
321
+ before(:each) do
322
+ @tmpdir = Dir.mktmpdir
323
+ Dir.stub(:mktmpdir => @tmpdir)
324
+
325
+ id = options[:vendor_id] || options[:apple_id]
326
+ @package = File.join(@tmpdir, "#{id}.itmsp")
327
+ Dir.mkdir(@package)
328
+
329
+ @metadata = "<x>Metadata</x>"
330
+ File.open(File.join(@package, "metadata.xml"), "w") { |io| io.write(@metadata) }
331
+
332
+ mock_output
333
+ end
334
+
335
+ after(:each) { FileUtils.rm_rf(@tmpdir) }
336
+
337
+ describe "#run" do
338
+ context "when successful" do
339
+ it "returns the metadata and deletes the temp directory used to output the metadata" do
340
+ subject.run(options).should == @metadata
341
+ File.exists?(@tmpdir).should be_false
342
+ end
343
+
344
+ context "when the metadata file was not created" do
345
+ before { FileUtils.rm_rf(@tmpdir) }
346
+
347
+ it "raises a TransporterError" do
348
+ lambda { subject.run(options) }.should raise_exception(ITunes::Store::Transporter::TransporterError, /no metadata file/i)
349
+ end
350
+ end
351
+ end
352
+ end
353
+
354
+ # One of these two should be requied, but they should be mutually exclusive
355
+ describe "options" do
356
+ describe ":vendor_id" do
357
+ let(:options) { create_options({:vendor_id => "vID"}) }
358
+ it_should_behave_like "a vendor_id option"
359
+ end
360
+
361
+ describe ":apple_id" do
362
+ let(:options) { create_options({:apple_id => "aID"}) }
363
+ it_should_behave_like "a transporter option", { :apple_id => "aID" }, "-apple_id", "aID"
364
+ end
365
+ end
366
+ end
367
+
368
+ describe ITunes::Store::Transporter::Command::Schema do
369
+ it_behaves_like "a transporter mode"
370
+ it_behaves_like "a command that accepts a shortname argument"
371
+
372
+ subject { described_class.new({}) }
373
+ let(:options) { create_options(:type => "strict", :version => "film5") }
374
+ its(:mode) { should == "generateSchema" }
375
+
376
+ describe "#run" do
377
+ context "when successful" do
378
+ it "returns the requested schema" do
379
+ mock_output(:stdout => [ "<x>Film Schema</x>" ], :stderr => "stderr.info")
380
+ subject.run(options).should == "<x>Film Schema</x>"
381
+ end
382
+ end
383
+ end
384
+
385
+ describe "options" do
386
+ describe ":version" do
387
+ it_should_behave_like "a transporter option", {:version => "versionX"}, "-schema", "versionX"
388
+ end
389
+
390
+ # Like Upload's :trasport, case sen., limited to a set
391
+ describe ":type" do
392
+ %w|transitional strict|.each do |type|
393
+ context "with #{type}" do
394
+ it_should_behave_like "a transporter option", {:type => type}, "-schemaType", type
395
+ end
396
+ end
397
+ end
398
+ end
399
+ end
400
+
401
+ describe ITunes::Store::Transporter::Command::Status do
402
+ it_behaves_like "a transporter mode"
403
+
404
+ subject { described_class.new({}) }
405
+ let(:options) { create_options(:vendor_id => 123123) }
406
+ its(:mode) { should == "status" }
407
+
408
+ describe "#run" do
409
+ context "when successful" do
410
+ it "returns the status information for the package" do
411
+ mock_output(:stdout => "status.vendor_id_123123", :stderr => "stderr.info")
412
+ subject.run(options).should == {
413
+ :vendor_identifier => "123123",
414
+ :apple_identifier => "123123",
415
+ :itunesconnect_status => "Not ready for sale",
416
+ :upload_created => "2000-01-01 00:00:00",
417
+ :upload_state => "Uploaded",
418
+ :upload_state_id => "1",
419
+ :content_state => "Irie",
420
+ :content_state_id => "2"
421
+ }
422
+ end
423
+ end
424
+ end
425
+
426
+ describe "options" do
427
+ describe ":vendor_id" do
428
+ it_should_behave_like "a vendor_id option"
429
+ end
430
+ end
431
+ end
432
+
433
+ describe ITunes::Store::Transporter::Command::Verify do
434
+ it_behaves_like "a transporter mode"
435
+ it_behaves_like "a command that requires a package argument"
436
+ it_behaves_like "a command that accepts a shortname argument"
437
+
438
+ subject { described_class.new({}) }
439
+ its(:mode) { should == "verify" }
440
+ let(:options) { create_options(:package => create_package) }
441
+
442
+ describe "#run" do
443
+ context "when successful" do #successful means exit(0)
444
+ context "without any errors" do
445
+ it "returns true" do
446
+ mock_output(:stdout => "stdout.success", :stderr => "stderr.info")
447
+ subject.run(options).should be_true
448
+ end
449
+ end
450
+
451
+ # If no packages were verfied it exits with 0 but emits an error message
452
+ context "with errors" do
453
+ it "raises an ExecutionError" do
454
+ mock_output(:exit => 0, :stderr => "stderr.errors");
455
+ lambda { subject.run(options) }.should raise_exception(ITunes::Store::Transporter::ExecutionError)
456
+ end
457
+ end
458
+ end
459
+ end
460
+
461
+ describe "options" do
462
+ describe ":verify_assets" do
463
+ it_should_behave_like "a boolean transporter option", :verify_assets, "-disableAssetVerification"
464
+ end
465
+ end
466
+ end
467
+
468
+ describe ITunes::Store::Transporter::Command::Version do
469
+ subject { described_class.new({}) }
470
+
471
+ def output_version(v)
472
+ ["iTMSTransporter version #{v}\n"]
473
+ end
474
+
475
+ describe "#run" do
476
+ context "when the version is major" do
477
+ it "returns the version" do
478
+ mock_output(:stdout => output_version("1"))
479
+ subject.run.should == "1"
480
+ end
481
+ end
482
+
483
+ context "when the version is major.minor" do
484
+ it "returns the version" do
485
+ mock_output(:stdout => output_version("1.10"))
486
+ subject.run.should == "1.10"
487
+ end
488
+ end
489
+
490
+ context "when the version is major.minor.release" do
491
+ it "returns the version" do
492
+ mock_output(:stdout => output_version("1.10.100"))
493
+ subject.run.should == "1.10.100"
494
+ end
495
+ end
496
+
497
+ context "when the version is major.minor.release.build format" do
498
+ it "returns the version" do
499
+ mock_output(:stdout => output_version("1.10.100.1234"))
500
+ subject.run.should == "1.10.100.1234"
501
+ end
502
+ end
503
+
504
+ context "when the version it's somthing else" do
505
+ it "returns 'Unknown'" do
506
+ mock_output(:stdout => ["bad version here"])
507
+ subject.run.should == "Unknown"
508
+ end
509
+ end
510
+ end
511
+ end