bin-migrate.t 8.6 KB

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