bin-migrate.t 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. #!/usr/bin/env perl
  2. # bin/migrate.pl carries a FlatFile site's posts into the SQLite model. The
  3. # thing worth asserting is not that it copies rows, it is that the site answers
  4. # the same questions afterwards -- so this builds a corpus through the FlatFile
  5. # model, migrates, and compares the two models query for query.
  6. use 5.040;
  7. use re '/aasx';
  8. use warnings FATAL => 'all';
  9. use FindBin;
  10. use FindBin::libs;
  11. use Test::More;
  12. use Test::MockModule qw{strict};
  13. use IPC::Run3();
  14. use Path::Tiny();
  15. our ( $REPO, $ROOT, $OLDCWD );
  16. BEGIN {
  17. require Cwd;
  18. require File::Copy;
  19. require File::Path;
  20. require File::Temp;
  21. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  22. $OLDCWD = Cwd::getcwd();
  23. $ROOT = File::Temp::tempdir( 'tcms-migrate-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  24. $ENV{HOME} = $ROOT;
  25. File::Path::make_path("$ROOT/$_") for qw{config schema data/files logs};
  26. File::Copy::copy( "$REPO/schema/$_", "$ROOT/schema/$_" ) or die $! for qw{flatfile.schema sqlite.schema};
  27. File::Copy::copy( "$REPO/config/default.cfg", "$ROOT/config/default.cfg" ) or die $!;
  28. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  29. }
  30. END {
  31. chdir($OLDCWD) if $OLDCWD;
  32. }
  33. require_ok('Trog::Data::FlatFile') or BAIL_OUT("Can't find the model to migrate from");
  34. require_ok('Trog::Data::SQLite') or BAIL_OUT("Can't find the model to migrate to");
  35. require Trog::Config;
  36. require Trog::SQLite::TagIndex;
  37. my $auth = Test::MockModule->new('Trog::Auth');
  38. $auth->redefine( username2display => sub { return "Display $_[0]" } );
  39. $auth->redefine( username2classname => sub { return "class-$_[0]" } );
  40. my $config = Trog::Config::get();
  41. my $flat = Trog::Data::FlatFile->new($config);
  42. my $sqlite = Trog::Data::SQLite->new($config);
  43. # The script is the thing under test, so run it the way an operator would.
  44. sub migrate {
  45. my (@args) = @_;
  46. my $out = q{};
  47. IPC::Run3::run3( [ $^X, "-I$REPO/lib", "$REPO/bin/migrate.pl", @args ], \undef, \$out, \$out );
  48. return ( $?, $out );
  49. }
  50. my $t = 1700000000;
  51. sub post {
  52. my (%o) = @_;
  53. return {
  54. title => 'Untitled',
  55. data => 'nothing in particular',
  56. user => 'bob',
  57. author => 'bob',
  58. visibility => 'public',
  59. created => $t++,
  60. version => 0,
  61. form => 'blog.tx',
  62. %o,
  63. tags => [ @{ $o{tags} // ['blog'] }, $o{visibility} // 'public' ],
  64. # What add() would have filled in. Without them the flat file model's
  65. # index build warns about a post it cannot route.
  66. local_href => $o{local_href} // "/posts/$o{id}",
  67. callback => 'Trog::Routes::HTML::posts',
  68. method => 'GET',
  69. };
  70. }
  71. subtest 'nothing to migrate' => sub {
  72. my ( $rc, $out ) = migrate();
  73. isnt( $rc, 0, 'an empty datastore is an error rather than a silent success' );
  74. like( $out, qr/No[ ]posts[ ]found/, 'and says so' );
  75. unlike( $out, qr/[ ]at[ ]\S+[ ]line[ ]\d+/, 'without a stack trace, since it is not a crash' );
  76. };
  77. # A corpus with the awkward bits: several versions of one post, a private one,
  78. # a post whose body is an array, and one carrying a tag nothing else has.
  79. my @corpus = (
  80. post( id => 'p-one', title => 'The First', data => 'quick brown fox', tags => [qw{blog topbar}] ),
  81. post( id => 'p-one', title => 'The First v2', data => 'quick brown fox, revised', tags => [qw{blog topbar}], version => 1 ),
  82. post( id => 'p-one', title => 'The First v3', data => 'quick brown fox, again', tags => [qw{blog topbar}], version => 2 ),
  83. post( id => 'p-secret', title => 'Hidden', data => 'not for you', visibility => 'private' ),
  84. post( id => 'p-deck', title => 'A Deck', data => [ 'slide one', 'slide two' ], form => 'presentation.tx' ),
  85. post( id => 'p-ann', title => 'By Ann', data => 'ann wrote this', user => 'ann', author => 'ann', tags => [qw{blog rare}] ),
  86. );
  87. $flat->write( [$_] ) foreach @corpus;
  88. # FlatFile memoizes both the tag list and the posts for a given set of tags at
  89. # load time, and writing a post invalidates neither -- production gets away with
  90. # it because a save re-execs the worker. Anything reading back what it just
  91. # wrote in one process has to do it by hand.
  92. sub reset_flatfile_cache {
  93. no warnings qw{once};
  94. @Trog::Data::FlatFile::tags = Trog::SQLite::TagIndex::tags();
  95. %Trog::Data::FlatFile::posts_by_tag = ();
  96. return;
  97. }
  98. reset_flatfile_cache();
  99. subtest 'a dry run writes nothing' => sub {
  100. my ( $rc, $out ) = migrate('--dry-run');
  101. is( $rc, 0, 'it succeeds' ) or diag($out);
  102. like( $out, qr/Would[ ]carry[ ]over[ ]6[ ]post[ ]version\(s\)/, 'and counts every version, not every post' );
  103. like( $out, qr/Nothing[ ]was[ ]written/, 'and says it wrote nothing' );
  104. is( $sqlite->count(), 0, 'because it wrote nothing' );
  105. };
  106. subtest 'the migration itself' => sub {
  107. my ( $rc, $out ) = migrate();
  108. is( $rc, 0, 'it succeeds' ) or diag($out);
  109. like( $out, qr/Carried[ ]over[ ]6[ ]post[ ]version\(s\),[ ]skipped[ ]0/, 'carrying over every version' );
  110. like( $out, qr/data\/files[ ]holds[ ]6[ ]post[ ]version\(s\);[ ]data\/posts\.sqlite[ ]now[ ]holds[ ]6/, 'and says what actually landed' );
  111. is( $sqlite->count(), 4, 'four posts' );
  112. my $present = $sqlite->versions_present();
  113. is_deeply( [ sort keys %{ $present->{'p-one'} } ], [ 0, 1, 2 ], 'with the whole version history of the one that has one' );
  114. };
  115. subtest 'the two models now answer the same questions' => sub {
  116. my @queries = (
  117. [ 'everything an admin sees' => { limit => 0, acls => ['admin'] } ],
  118. [ 'what a visitor sees' => { limit => 0, acls => ['public'] } ],
  119. [ 'by tag' => { limit => 0, acls => ['admin'], tags => ['topbar'] } ],
  120. [ 'by a rare tag' => { limit => 0, acls => ['admin'], tags => ['rare'] } ],
  121. [ 'by author' => { limit => 0, acls => ['admin'], author => 'ann' } ],
  122. [ 'by id' => { limit => 0, acls => [qw{admin}], id => 'p-one' } ],
  123. [ 'an older version' => { limit => 0, acls => [qw{admin}], id => 'p-one', version => 1 } ],
  124. [ 'by form' => { limit => 0, acls => ['admin'], form => 'presentation.tx' } ],
  125. );
  126. foreach my $case (@queries) {
  127. my ( $name, $query ) = @$case;
  128. my $want = [ sort map { "$_->{id}\@$_->{version}" } $flat->get(%$query) ];
  129. my $got = [ sort map { "$_->{id}\@$_->{version}" } $sqlite->get(%$query) ];
  130. is_deeply( $got, $want, $name ) or diag("flatfile: @$want\nsqlite: @$got");
  131. }
  132. # The rolled up fields have to survive the trip, not just the rows.
  133. my ($current) = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-one' );
  134. is( $current->{version}, 2, 'the newest version is the current one' );
  135. is( $current->{version_max}, 2, 'version_max came across' );
  136. is( $current->{title}, 'The First v3', 'with the newest content' );
  137. is( $current->{created}, $corpus[0]{created}, 'created is still the post birthday' );
  138. is( $current->{modified}, $corpus[2]{created}, 'and modified the last edit' );
  139. # Search is built during the migration, not left to be rebuilt later.
  140. my @found = map { $_->{id} } $sqlite->get( limit => 0, acls => ['admin'], like => 'own fox' );
  141. is_deeply( [ sort @found ], ['p-one'], 'and the search index was built on the way in' );
  142. };
  143. subtest 'running it again' => sub {
  144. my ( $rc, $out ) = migrate();
  145. is( $rc, 0, 'succeeds' ) or diag($out);
  146. like( $out, qr/Carried[ ]over[ ]0[ ]post[ ]version\(s\),[ ]skipped[ ]6/, 'and carries nothing over twice' );
  147. is( $sqlite->count(), 4, 'leaving the post count alone' );
  148. };
  149. subtest 'picking up what was written in between' => sub {
  150. # The reason re-running has to work: run once against a live site, once more
  151. # after a final quiet period.
  152. $flat->write( [ post( id => 'p-late', title => 'Written Later', data => 'after the first pass' ) ] );
  153. reset_flatfile_cache();
  154. my ( $rc, $out ) = migrate();
  155. is( $rc, 0, 'succeeds' ) or diag($out);
  156. like( $out, qr/Carried[ ]over[ ]1[ ]post[ ]version\(s\),[ ]skipped[ ]6/, 'carrying over only the new one' );
  157. my ($late) = $sqlite->get( limit => 0, acls => [qw{admin}], id => 'p-late' );
  158. is( $late->{title}, 'Written Later', 'which is there afterwards' );
  159. };
  160. subtest 'the flat files are left alone' => sub {
  161. my @files = Path::Tiny::path('data/files')->children;
  162. is( scalar(@files), 5, 'every post still has its file' );
  163. ok( -f 'data/posts.db', "and the flat file model's own index is untouched" );
  164. # Which is what makes going back possible.
  165. my @still = $flat->get( limit => 0, acls => ['admin'] );
  166. is( scalar(@still), 5, 'and the old model still reads them' );
  167. };
  168. done_testing();