bin-migrate.t 8.8 KB

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