about summary refs log tree commit homepage
path: root/t
diff options
context:
space:
mode:
Diffstat (limited to 't')
-rw-r--r--t/address.t5
-rw-r--r--t/altid.t20
-rw-r--r--t/altid_v2.t55
-rw-r--r--t/content_id.t35
-rw-r--r--t/convert-compact.t104
-rw-r--r--t/git.t24
-rw-r--r--t/import.t32
-rw-r--r--t/init.t45
-rw-r--r--t/mid.t22
-rw-r--r--t/msgmap.t4
-rw-r--r--t/nntp.t6
-rw-r--r--t/nntpd.t37
-rw-r--r--t/over.t63
-rw-r--r--t/perf-nntpd.t135
-rw-r--r--t/perf-threading.t32
-rw-r--r--t/plack.t18
-rw-r--r--t/psgi_search.t6
-rw-r--r--t/psgi_v2.t245
-rw-r--r--t/search-thr-index.t14
-rw-r--r--t/search.t162
-rw-r--r--t/thread-all.t38
-rw-r--r--t/time.t28
-rw-r--r--t/v1-add-remove-add.t45
-rw-r--r--t/v2-add-remove-add.t42
-rw-r--r--t/v2mda.t59
-rw-r--r--t/v2mirror.t176
-rw-r--r--t/v2reindex.t97
-rw-r--r--t/v2writable.t280
-rw-r--r--t/view.t1
-rw-r--r--t/watch_maildir.t3
-rw-r--r--t/watch_maildir_v2.t125
31 files changed, 1818 insertions, 140 deletions
diff --git a/t/address.t b/t/address.t
index e35e4f8b..eced5c46 100644
--- a/t/address.t
+++ b/t/address.t
@@ -9,8 +9,9 @@ is_deeply([qw(e@example.com e@example.org)],
         [PublicInbox::Address::emails('User <e@example.com>, e@example.org')],
         'address extraction works as expected');
 
-is_deeply([PublicInbox::Address::emails('"ex@example.com" <ex@example.com>')],
-        [qw(ex@example.com)]);
+is_deeply(['user@example.com'],
+        [PublicInbox::Address::emails('<user@example.com (Comment)>')],
+        'comment after domain accepted before >');
 
 my @names = PublicInbox::Address::names(
         'User <e@e>, e@e, "John A. Doe" <j@d>, <x@x>');
diff --git a/t/altid.t b/t/altid.t
index 7759bd6b..d4f6152e 100644
--- a/t/altid.t
+++ b/t/altid.t
@@ -20,7 +20,9 @@ my $altid = [ "serial:gmane:file=$alt_file" ];
 
 {
         my $mm = PublicInbox::Msgmap->new_file($alt_file, 1);
-        $mm->mid_set(1234, 'a@example.com');
+        is($mm->mid_set(1234, 'a@example.com'), 1, 'mid_set once OK');
+        ok(0 == $mm->mid_set(1234, 'a@example.com'), 'mid_set not idempotent');
+        ok(0 == $mm->mid_set(1, 'a@example.com'), 'mid_set fails with dup MID');
 }
 
 {
@@ -48,14 +50,20 @@ my $altid = [ "serial:gmane:file=$alt_file" ];
 
 {
         my $ro = PublicInbox::Search->new($git_dir, $altid);
-        my $res = $ro->query("gmane:1234");
-        is($res->{total}, 1, 'got one match');
-        is($res->{msgs}->[0]->mid, 'a@example.com');
+        my $msgs = $ro->query("gmane:1234");
+        is_deeply([map { $_->mid } @$msgs], ['a@example.com'], 'got one match');
 
-        $res = $ro->query("gmane:666");
-        is($res->{total}, 0, 'body did NOT match');
+        $msgs = $ro->query("gmane:666");
+        is_deeply([], $msgs, 'body did NOT match');
 };
 
+{
+        my $mm = PublicInbox::Msgmap->new_file($alt_file, 1);
+        my ($min, $max) = $mm->minmax;
+        my $num = $mm->mid_insert('b@example.com');
+        ok($num > $max, 'auto-increment goes beyond mid_set');
+}
+
 done_testing();
 
 1;
diff --git a/t/altid_v2.t b/t/altid_v2.t
new file mode 100644
index 00000000..87f1452b
--- /dev/null
+++ b/t/altid_v2.t
@@ -0,0 +1,55 @@
+# Copyright (C) 2016-2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for altid_v2.t" if $@;
+}
+
+use_ok 'PublicInbox::V2Writable';
+use_ok 'PublicInbox::Inbox';
+my $tmpdir = tempdir('pi-altidv2-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $mainrepo = "$tmpdir/inbox";
+my $full = "$tmpdir/inbox/another-nntp.sqlite3";
+my $altid = [ 'serial:gmane:file=another-nntp.sqlite3' ];
+
+{
+        ok(mkdir($mainrepo), 'created repo for msgmap');
+        my $mm = PublicInbox::Msgmap->new_file($full, 1);
+        is($mm->mid_set(1234, 'a@example.com'), 1, 'mid_set once OK');
+        ok(0 == $mm->mid_set(1234, 'a@example.com'), 'mid_set not idempotent');
+        ok(0 == $mm->mid_set(1, 'a@example.com'), 'mid_set fails with dup MID');
+}
+
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => 'test-v2writable',
+        version => 2,
+        -primary_address => 'test@example.com',
+        altid => $altid,
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $v2w = PublicInbox::V2Writable->new($ibx, 1);
+$v2w->add(Email::MIME->create(
+                header => [
+                        From => 'a@example.com',
+                        To => 'b@example.com',
+                        'Content-Type' => 'text/plain',
+                        Subject => 'boo!',
+                        'Message-ID' => '<a@example.com>',
+                ],
+                body => "hello world gmane:666\n",
+        ));
+$v2w->done;
+
+my $msgs = $ibx->search->reopen->query("gmane:1234");
+is_deeply([map { $_->mid } @$msgs], ['a@example.com'], 'got one match');
+$msgs = $ibx->search->query("gmane:666");
+is_deeply([], $msgs, 'body did NOT match');
+
+done_testing();
+
+1;
diff --git a/t/content_id.t b/t/content_id.t
new file mode 100644
index 00000000..01ce65e5
--- /dev/null
+++ b/t/content_id.t
@@ -0,0 +1,35 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::ContentId qw(content_id);
+use Email::MIME;
+
+my $mime = Email::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'b@example.com',
+                'Content-Type' => 'text/plain',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a@example.com>',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+
+my $orig = content_id($mime);
+my $reload = content_id(Email::MIME->new($mime->as_string));
+is($orig, $reload, 'content_id matches after serialization');
+
+foreach my $h (qw(From To Cc)) {
+        my $n = '"Quoted N\'Ame" <foo@EXAMPLE.com>';
+        $mime->header_str_set($h, "$n");
+        my $q = content_id($mime);
+        is($n, $mime->header($h), "content_id does not mutate $h:");
+        $mime->header_str_set($h, 'Quoted N\'Ame <foo@example.com>');
+        my $nq = content_id($mime);
+        is($nq, $q, "quotes ignored in $h:");
+}
+
+done_testing();
diff --git a/t/convert-compact.t b/t/convert-compact.t
new file mode 100644
index 00000000..ced45415
--- /dev/null
+++ b/t/convert-compact.t
@@ -0,0 +1,104 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use PublicInbox::MIME;
+my @mods = qw(DBD::SQLite Search::Xapian);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for convert-compact.t" if $@;
+}
+use_ok 'PublicInbox::V2Writable';
+use PublicInbox::Import;
+my $tmpdir = tempdir('convert-compact-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => "$tmpdir/v1",
+        name => 'test-v1',
+        -primary_address => 'test@example.com',
+};
+
+ok(PublicInbox::Import::run_die([qw(git init --bare -q), $ibx->{mainrepo}]),
+        'initialized v1 repo');
+ok(umask(077), 'set restrictive umask');
+ok(PublicInbox::Import::run_die([qw(git) , "--git-dir=$ibx->{mainrepo}",
+        qw(config core.sharedRepository 0644)]), 'set sharedRepository');
+$ibx = PublicInbox::Inbox->new($ibx);
+my $im = PublicInbox::Import->new($ibx->git, undef, undef, $ibx);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a-mid@b>',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+ok($im->add($mime), 'added one message');
+ok($im->remove($mime), 'remove message');
+ok($im->add($mime), 'added message again');
+$im->done;
+PublicInbox::SearchIdx->new($ibx, 1)->index_sync;
+
+is(((stat("$ibx->{mainrepo}/public-inbox"))[2]) & 07777, 0755,
+        'sharedRepository respected for v1');
+is(((stat("$ibx->{mainrepo}/public-inbox/msgmap.sqlite3"))[2]) & 07777, 0644,
+        'sharedRepository respected for v1 msgmap');
+my @xdir = glob("$ibx->{mainrepo}/public-inbox/xap*/*");
+foreach (@xdir) {
+        my @st = stat($_);
+        is($st[2] & 07777, -f _ ? 0644 : 0755,
+                'sharedRepository respected on file after convert');
+}
+
+local $ENV{PATH} = "blib/script:$ENV{PATH}";
+open my $err, '>>', "$tmpdir/err.log" or die "open: err.log $!\n";
+open my $out, '>>', "$tmpdir/out.log" or die "open: out.log $!\n";
+my $rdr = { 1 => fileno($out), 2 => fileno($err) };
+
+my $cmd = [ 'public-inbox-compact', $ibx->{mainrepo} ];
+ok(PublicInbox::Import::run_die($cmd, undef, $rdr), 'v1 compact works');
+
+@xdir = glob("$ibx->{mainrepo}/public-inbox/xap*");
+is(scalar(@xdir), 1, 'got one xapian directory after compact');
+is(((stat($xdir[0]))[2]) & 07777, 0755,
+        'sharedRepository respected on v1 compact');
+
+$cmd = [ 'public-inbox-convert', $ibx->{mainrepo}, "$tmpdir/v2" ];
+ok(PublicInbox::Import::run_die($cmd, undef, $rdr), 'convert works');
+@xdir = glob("$tmpdir/v2/xap*/*");
+foreach (@xdir) {
+        my @st = stat($_);
+        is($st[2] & 07777, -f _ ? 0644 : 0755,
+                'sharedRepository respected after convert');
+}
+
+$cmd = [ 'public-inbox-compact', "$tmpdir/v2" ];
+my $env = { NPROC => 2 };
+ok(PublicInbox::Import::run_die($cmd, $env, $rdr), 'v2 compact works');
+$ibx->{mainrepo} = "$tmpdir/v2";
+$ibx->{version} = 2;
+
+@xdir = glob("$tmpdir/v2/xap*/*");
+foreach (@xdir) {
+        my @st = stat($_);
+        is($st[2] & 07777, -f _ ? 0644 : 0755,
+                'sharedRepository respected after v2 compact');
+}
+is(((stat("$tmpdir/v2/msgmap.sqlite3"))[2]) & 07777, 0644,
+        'sharedRepository respected for v2 msgmap');
+
+@xdir = (glob("$tmpdir/v2/git/*.git/objects/*/*"),
+         glob("$tmpdir/v2/git/*.git/objects/pack/*"));
+foreach (@xdir) {
+        my @st = stat($_);
+        is($st[2] & 07777, -f _ ? 0444 : 0755,
+                'sharedRepository respected after v2 compact');
+}
+my $msgs = $ibx->recent({limit => 1000});
+is($msgs->[0]->{mid}, 'a-mid@b', 'message exists in history');
+is(scalar @$msgs, 1, 'only one message in history');
+
+done_testing();
diff --git a/t/git.t b/t/git.t
index 5efc18ab..7f96293f 100644
--- a/t/git.t
+++ b/t/git.t
@@ -6,6 +6,7 @@ use Test::More;
 use File::Temp qw/tempdir/;
 my $dir = tempdir('pi-git-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 use Cwd qw/getcwd/;
+use PublicInbox::Spawn qw(popen_rd);
 
 use_ok 'PublicInbox::Git';
 {
@@ -137,6 +138,29 @@ if (1) {
         is($all, join('', @ref), 'qx returned array when wanted');
         my $nl = scalar @ref;
         ok($nl > 1, "qx returned array length of $nl");
+
+        $gcf->qx(qw(repack -adbq));
+        ok($gcf->packed_bytes > 0, 'packed size is positive');
+}
+
+if ('alternates reloaded') {
+        my $alt = tempdir('pi-git-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+        my @cmd = ('git', "--git-dir=$alt", qw(hash-object -w --stdin));
+        is(system(qw(git init -q --bare), $alt), 0, 'create alt directory');
+        open my $fh, '<', "$alt/config" or die "open failed: $!\n";
+        my $rd = popen_rd(\@cmd, {}, { 0 => fileno($fh) } );
+        close $fh or die "close failed: $!";
+        chomp(my $remote = <$rd>);
+        my $gcf = PublicInbox::Git->new($dir);
+        is($gcf->cat_file($remote), undef, "remote file not found");
+        open $fh, '>>', "$dir/objects/info/alternates" or
+                        die "open failed: $!\n";
+        print $fh "$alt/objects" or die "print failed: $!\n";
+        close $fh or die "close failed: $!";
+        my $found = $gcf->cat_file($remote);
+        open $fh, '<', "$alt/config" or die "open failed: $!\n";
+        my $config = eval { local $/; <$fh> };
+        is($$found, $config, 'alternates reloaded');
 }
 
 done_testing();
diff --git a/t/import.t b/t/import.t
index fb6238e7..eee47447 100644
--- a/t/import.t
+++ b/t/import.t
@@ -6,7 +6,10 @@ use Test::More;
 use PublicInbox::MIME;
 use PublicInbox::Git;
 use PublicInbox::Import;
-use File::Temp qw/tempdir/;
+use PublicInbox::Spawn qw(spawn);
+use IO::File;
+use Fcntl qw(:DEFAULT);
+use File::Temp qw/tempdir tempfile/;
 my $dir = tempdir('pi-import-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 
 is(system(qw(git init -q --bare), $dir), 0, 'git init successful');
@@ -20,10 +23,33 @@ my $mime = PublicInbox::MIME->create(
                 'Content-Type' => 'text/plain',
                 Subject => 'this is a subject',
                 'Message-ID' => '<a@example.com>',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
         ],
         body => "hello world\n",
 );
+
+$im->{want_object_info} = 1 if 'v2';
 like($im->add($mime), qr/\A:\d+\z/, 'added one message');
+
+if ('v2') {
+        my $info = $im->{last_object};
+        like($info->[0], qr/\A[a-f0-9]{40}\z/, 'got last object_id');
+        is($mime->as_string, ${$info->[2]}, 'string matches');
+        is($info->[1], length(${$info->[2]}), 'length matches');
+        my @cmd = ('git', "--git-dir=$git->{git_dir}", qw(hash-object --stdin));
+        my $in = tempfile();
+        print $in $mime->as_string or die "write failed: $!";
+        $in->flush or die "flush failed: $!";
+        $in->seek(0, SEEK_SET);
+        my $out = tempfile();
+        my $pid = spawn(\@cmd, {}, { 0 => fileno($in), 1 => fileno($out)});
+        is(waitpid($pid, 0), $pid, 'waitpid succeeds on hash-object');
+        is($?, 0, 'hash-object');
+        $out->seek(0, SEEK_SET);
+        chomp(my $hashed_obj = <$out>);
+        is($hashed_obj, $info->[0], "last object_id matches exp");
+}
+
 $im->done;
 my @revs = $git->qx(qw(rev-list HEAD));
 is(scalar @revs, 1, 'one revision created');
@@ -64,6 +90,8 @@ isnt($msg->header('Subject'), $mime->header('Subject'), 'subject mismatch');
 $mime->header_set('Message-Id', '<failcheck@example.com>');
 is($im->add($mime, sub { undef }), undef, 'check callback fails');
 is($im->remove($mime), undef, 'message not added, so not removed');
-
+is(undef, $im->checkpoint, 'checkpoint works before ->done');
 $im->done;
+is(undef, $im->checkpoint, 'checkpoint works after ->done');
+$im->checkpoint;
 done_testing();
diff --git a/t/init.t b/t/init.t
index 864f1ab5..59f54813 100644
--- a/t/init.t
+++ b/t/init.t
@@ -7,6 +7,23 @@ use PublicInbox::Config;
 use File::Temp qw/tempdir/;
 my $tmpdir = tempdir('pi-init-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 use constant pi_init => 'blib/script/public-inbox-init';
+use PublicInbox::Import;
+use File::Basename;
+open my $null, '>>', '/dev/null';
+my $rdr = { 2 => fileno($null) };
+sub quiet_fail {
+        my ($cmd, $msg) = @_;
+        # run_die doesn't take absolute paths:
+        my $path = $ENV{PATH};
+        if (index($cmd->[0], '/') >= 0) {
+                my ($dir, $base) = ($cmd->[0] =~ m!\A(.+)/([^/]+)\z!);
+                $path = "$dir:$path";
+                $cmd->[0] = $base;
+        }
+        local $ENV{PATH} = $path;
+        eval { PublicInbox::Import::run_die($cmd, undef, $rdr) };
+        isnt($@, '', $msg);
+}
 
 {
         local $ENV{PI_DIR} = "$tmpdir/.public-inbox/";
@@ -23,6 +40,34 @@ use constant pi_init => 'blib/script/public-inbox-init';
                    qw(http://example.com/clist clist@example.com));
         is(system(@cmd), 0, 'public-inbox-init clist OK');
         is((stat($cfgfile))[2] & 07777, 0666, "permissions preserved");
+
+        @cmd = (pi_init, 'clist', '-V2', "$tmpdir/clist",
+                   qw(http://example.com/clist clist@example.com));
+        quiet_fail(\@cmd, 'attempting to init V2 from V1 fails');
+}
+
+SKIP: {
+        foreach my $mod (qw(DBD::SQLite Search::Xapian::WritableDatabase)) {
+                eval "require $mod";
+                skip "$mod missing for v2", 2 if $@;
+        }
+        local $ENV{PI_DIR} = "$tmpdir/.public-inbox/";
+        my $cfgfile = "$ENV{PI_DIR}/config";
+        my @cmd = (pi_init, '-V2', 'v2list', "$tmpdir/v2list",
+                   qw(http://example.com/v2list v2list@example.com));
+        is(system(@cmd), 0, 'public-inbox-init -V2 OK');
+        ok(-d "$tmpdir/v2list", 'v2list directory exists');
+        ok(-f "$tmpdir/v2list/msgmap.sqlite3", 'msgmap exists');
+        ok(-d "$tmpdir/v2list/all.git", 'catch-all.git directory exists');
+        @cmd = (pi_init, 'v2list', "$tmpdir/v2list",
+                   qw(http://example.com/v2list v2list@example.com));
+        is(system(@cmd), 0, 'public-inbox-init is idempotent');
+        ok(! -d "$tmpdir/public-inbox" && !-d "$tmpdir/objects",
+                'idempotent invocation w/o -V2 does not make inbox v1');
+
+        @cmd = (pi_init, 'v2list', "-V1", "$tmpdir/v2list",
+                   qw(http://example.com/v2list v2list@example.com));
+        quiet_fail(\@cmd, 'initializing V2 as V1 fails');
 }
 
 done_testing();
diff --git a/t/mid.t b/t/mid.t
index 0bf33318..223be798 100644
--- a/t/mid.t
+++ b/t/mid.t
@@ -1,11 +1,31 @@
 # Copyright (C) 2016-2018 all contributors <meta@public-inbox.org>
 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
 use Test::More;
-use PublicInbox::MID qw(mid_escape);
+use PublicInbox::MID qw(mid_escape mids references);
 
 is(mid_escape('foo!@(bar)'), 'foo!@(bar)');
 is(mid_escape('foo%!@(bar)'), 'foo%25!@(bar)');
 is(mid_escape('foo%!@(bar)'), 'foo%25!@(bar)');
 
+{
+        use Email::MIME;
+        my $mime = Email::MIME->create;
+        $mime->header_set('Message-Id', '<mid-1@a>');
+        is_deeply(['mid-1@a'], mids($mime->header_obj), 'mids in common case');
+        $mime->header_set('Message-Id', '<mid-1@a>', '<mid-2@b>');
+        is_deeply(['mid-1@a', 'mid-2@b'], mids($mime->header_obj), '2 mids');
+        $mime->header_set('Message-Id', '<mid-1@a>', '<mid-1@a>');
+        is_deeply(['mid-1@a'], mids($mime->header_obj), 'dup mids');
+        $mime->header_set('Message-Id', '<mid-1@a> comment');
+        is_deeply(['mid-1@a'], mids($mime->header_obj), 'comment ignored');
+        $mime->header_set('Message-Id', 'bare-mid');
+        is_deeply(['bare-mid'], mids($mime->header_obj), 'bare mid OK');
+
+        $mime->header_set('References', '<hello> <world>');
+        $mime->header_set('In-Reply-To', '<weld>');
+        is_deeply(['hello', 'world', 'weld'], references($mime->header_obj),
+                'references combines with In-Reply-To');
+}
+
 done_testing();
 1;
diff --git a/t/msgmap.t b/t/msgmap.t
index bc22137d..dce98f46 100644
--- a/t/msgmap.t
+++ b/t/msgmap.t
@@ -65,4 +65,8 @@ my $orig = $d->mid_insert('spam@1');
 $d->mid_delete('spam@1');
 is($d->mid_insert('spam@2'), 1 + $orig, "last number not recycled");
 
+my $tmp = $d->tmp_clone;
+is_deeply([$d->minmax], [$tmp->minmax], 'Cloned temporary DB matches');
+ok($tmp->mid_delete('spam@2'), 'temporary DB is writable');
+
 done_testing();
diff --git a/t/nntp.t b/t/nntp.t
index 03c7f083..57fef48b 100644
--- a/t/nntp.t
+++ b/t/nntp.t
@@ -109,7 +109,9 @@ use_ok 'PublicInbox::Inbox';
         is($ng->base_url, $u, 'URL expanded');
         my $mid = 'a@b';
         my $mime = Email::MIME->new("Message-ID: <$mid>\r\n\r\n");
-        PublicInbox::NNTP::set_nntp_headers($mime->header_obj, $ng, 1, $mid);
+        my $hdr = $mime->header_obj;
+        my $mock_self = { nntpd => { grouplist => [] } };
+        PublicInbox::NNTP::set_nntp_headers($mock_self, $hdr, $ng, 1, $mid);
         is_deeply([ $mime->header('Message-ID') ], [ "<$mid>" ],
                 'Message-ID unchanged');
         is_deeply([ $mime->header('Archived-At') ], [ "<${u}a\@b/>" ],
@@ -124,7 +126,7 @@ use_ok 'PublicInbox::Inbox';
                 'Xref: set');
 
         $ng->{-base_url} = 'http://mirror.example.com/m/';
-        PublicInbox::NNTP::set_nntp_headers($mime->header_obj, $ng, 2, $mid);
+        PublicInbox::NNTP::set_nntp_headers($mock_self, $hdr, $ng, 2, $mid);
         is_deeply([ $mime->header('Message-ID') ], [ "<$mid>" ],
                 'Message-ID unchanged');
         is_deeply([ $mime->header('Archived-At') ],
diff --git a/t/nntpd.t b/t/nntpd.t
index 20191cb6..3698f98b 100644
--- a/t/nntpd.t
+++ b/t/nntpd.t
@@ -21,14 +21,18 @@ my $tmpdir = tempdir('pi-nntpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
 my $home = "$tmpdir/pi-home";
 my $err = "$tmpdir/stderr.log";
 my $out = "$tmpdir/stdout.log";
-my $maindir = "$tmpdir/main.git";
+my $mainrepo = "$tmpdir/main.git";
 my $group = 'test-nntpd';
 my $addr = $group . '@example.com';
 my $nntpd = 'blib/script/public-inbox-nntpd';
 my $init = 'blib/script/public-inbox-init';
 use_ok 'PublicInbox::Import';
+use_ok 'PublicInbox::Inbox';
 use_ok 'PublicInbox::Git';
+use_ok 'PublicInbox::V2Writable';
 
+# XXX FIXME: make it easier to test both versions
+my $version = int($ENV{PI_VERSION} || 1);
 my %opts = (
         LocalAddr => '127.0.0.1',
         ReuseAddr => 1,
@@ -40,14 +44,34 @@ my $sock = IO::Socket::INET->new(%opts);
 my $pid;
 my $len;
 END { kill 'TERM', $pid if defined $pid };
+
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => $group,
+        version => $version,
+        -primary_address => $addr,
+};
+$ibx = PublicInbox::Inbox->new($ibx);
 {
         local $ENV{HOME} = $home;
-        system($init, $group, $maindir, 'http://example.com/', $addr);
+        my @cmd = ($init, $group, $mainrepo, 'http://example.com/', $addr);
+        push @cmd, "-V$version";
+        is(system(@cmd), 0, 'init OK');
         is(system(qw(git config), "--file=$home/.public-inbox/config",
                         "publicinbox.$group.newsgroup", $group),
                 0, 'enabled newsgroup');
         my $len;
 
+        my $im;
+        if ($version == 2) {
+                $im = PublicInbox::V2Writable->new($ibx);
+        } elsif ($version == 1) {
+                my $git = PublicInbox::Git->new($mainrepo);
+                $im = PublicInbox::Import->new($git, 'test', $addr);
+        } else {
+                die "unsupported version: $version";
+        }
+
         # ensure successful message delivery
         {
                 my $mime = Email::MIME->new(<<EOF);
@@ -66,12 +90,12 @@ EOF
                 $list_id =~ s/@/./;
                 $mime->header_set('List-Id', "<$list_id>");
                 $len = length($mime->as_string);
-                my $git = PublicInbox::Git->new($maindir);
-                my $im = PublicInbox::Import->new($git, 'test', $addr);
                 $im->add($mime);
                 $im->done;
-                my $s = PublicInbox::SearchIdx->new($maindir, 1);
-                $s->index_sync;
+                if ($version == 1) {
+                        my $s = PublicInbox::SearchIdx->new($mainrepo, 1);
+                        $s->index_sync;
+                }
         }
 
         ok($sock, 'sock created');
@@ -99,6 +123,7 @@ EOF
         my $list = $n->list;
         is_deeply($list, { $group => [ qw(1 1 n) ] }, 'LIST works');
         is_deeply([$n->group($group)], [ qw(0 1 1), $group ], 'GROUP works');
+        is_deeply($n->listgroup($group), [1], 'listgroup OK');
 
         %opts = (
                 PeerAddr => $host_port,
diff --git a/t/over.t b/t/over.t
new file mode 100644
index 00000000..c0d9d5e5
--- /dev/null
+++ b/t/over.t
@@ -0,0 +1,63 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use Compress::Zlib qw(compress);
+foreach my $mod (qw(DBD::SQLite)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for over.t" if $@;
+}
+
+use_ok 'PublicInbox::OverIdx';
+my $tmpdir = tempdir('pi-over-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $over = PublicInbox::OverIdx->new("$tmpdir/over.sqlite3");
+$over->connect;
+my $x = $over->next_tid;
+is(int($x), $x, 'integer tid');
+my $y = $over->next_tid;
+is($y, $x+1, 'tid increases');
+
+$x = $over->sid('hello-world');
+is(int($x), $x, 'integer sid');
+$y = $over->sid('hello-WORLD');
+is($y, $x+1, 'sid ncreases');
+is($over->sid('hello-world'), $x, 'idempotent');
+$over->disconnect;
+
+$over = PublicInbox::OverIdx->new("$tmpdir/over.sqlite3");
+$over->connect;
+is($over->sid('hello-world'), $x, 'idempotent across reopen');
+$over->each_by_mid('never', sub { fail('should not be called') });
+
+$x = $over->create_ghost('never');
+is(int($x), $x, 'integer tid for ghost');
+$y = $over->create_ghost('NEVAR');
+is($y, $x + 1, 'integer tid for ghost increases');
+
+my $ddd = compress('');
+foreach my $s ('', undef) {
+        $over->add_over([0, 0, 98, [ 'a' ], [], $s, $ddd]);
+        $over->add_over([0, 0, 99, [ 'b' ], [], $s, $ddd]);
+        my $msgs = [ map { $_->{num} } @{$over->get_thread('a')} ];
+        is_deeply([98], $msgs,
+                'messages not linked by empty subject');
+}
+
+$over->add_over([0, 0, 98, [ 'a' ], [], 's', $ddd]);
+$over->add_over([0, 0, 99, [ 'b' ], [], 's', $ddd]);
+foreach my $mid (qw(a b)) {
+        my $msgs = [ map { $_->{num} } @{$over->get_thread('a')} ];
+        is_deeply([98, 99], $msgs, 'linked messages by subject');
+}
+$over->add_over([0, 0, 98, [ 'a' ], [], 's', $ddd]);
+$over->add_over([0, 0, 99, [ 'b' ], ['a'], 'diff', $ddd]);
+foreach my $mid (qw(a b)) {
+        my $msgs = [ map { $_->{num} } @{$over->get_thread($mid)} ];
+        is_deeply([98, 99], $msgs, "linked messages by Message-ID: <$mid>");
+}
+
+$over->rollback_lazy;
+
+done_testing();
diff --git a/t/perf-nntpd.t b/t/perf-nntpd.t
new file mode 100644
index 00000000..e5021532
--- /dev/null
+++ b/t/perf-nntpd.t
@@ -0,0 +1,135 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use Benchmark qw(:all :hireswallclock);
+use PublicInbox::Inbox;
+use File::Temp qw/tempdir/;
+use POSIX qw(dup2);
+use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
+use Net::NNTP;
+my $pi_dir = $ENV{GIANT_PI_DIR};
+plan skip_all => "GIANT_PI_DIR not defined for $0" unless $pi_dir;
+eval { require PublicInbox::Search };
+my ($host_port, $group, %opts, $s, $pid);
+END {
+        if ($s) {
+                $s->print("QUIT\r\n");
+                $s->getline;
+                $s = undef;
+        }
+        kill 'TERM', $pid if defined $pid;
+};
+
+if (($ENV{NNTP_TEST_URL} || '') =~ m!\Anntp://([^/]+)/([^/]+)\z!) {
+        ($host_port, $group) = ($1, $2);
+        $host_port .= ":119" unless index($host_port, ':') > 0;
+} else {
+        $group = 'inbox.test.perf.nntpd';
+        my $ibx = { mainrepo => $pi_dir, newsgroup => $group };
+        $ibx = PublicInbox::Inbox->new($ibx);
+        my $nntpd = 'blib/script/public-inbox-nntpd';
+        my $tmpdir = tempdir('perf-nntpd-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+
+        my $pi_config = "$tmpdir/config";
+        {
+                open my $fh, '>', $pi_config or die "open($pi_config): $!";
+                print $fh <<"" or die "print $pi_config: $!";
+[publicinbox "test"]
+        newsgroup = $group
+        mainrepo = $pi_dir
+        address = test\@example.com
+
+                close $fh or die "close($pi_config): $!";
+        }
+
+        %opts = (
+                LocalAddr => '127.0.0.1',
+                ReuseAddr => 1,
+                Proto => 'tcp',
+                Listen => 1024,
+        );
+        my $sock = IO::Socket::INET->new(%opts);
+
+        ok($sock, 'sock created');
+        $! = 0;
+        $pid = fork;
+        if ($pid == 0) {
+                # pretend to be systemd
+                my $fl = fcntl($sock, F_GETFD, 0);
+                dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+                dup2(1, 2) or die "dup2 failed: $!\n";
+                fcntl($sock, F_SETFD, $fl &= ~FD_CLOEXEC);
+                $ENV{LISTEN_PID} = $$;
+                $ENV{LISTEN_FDS} = 1;
+                $ENV{PI_CONFIG} = $pi_config;
+                exec $nntpd, '-W0';
+                die "FAIL: $!\n";
+        }
+        ok(defined $pid, 'forked nntpd process successfully');
+        $host_port = $sock->sockhost . ':' . $sock->sockport;
+}
+%opts = (
+        PeerAddr => $host_port,
+        Proto => 'tcp',
+        Timeout => 1,
+);
+$s = IO::Socket::INET->new(%opts);
+$s->autoflush(1);
+my $buf = $s->getline;
+is($buf, "201 server ready - post via email\r\n", 'got greeting');
+
+my $t = timeit(10, sub {
+        ok($s->print("GROUP $group\r\n"), 'changed group');
+        $buf = $s->getline;
+});
+diag 'GROUP took: ' . timestr($t);
+
+my ($tot, $min, $max) = ($buf =~ /\A211 (\d+) (\d+) (\d+) /);
+ok($tot && $min && $max, 'got GROUP response');
+my $nr = $max - $min;
+my $nmax = 50000;
+my $nmin = $max - $nmax;
+$nmin = $min if $nmin < $min;
+my $res;
+my $spec = "$nmin-$max";
+my $n;
+
+sub read_until_dot ($) {
+        my $n = 0;
+        do {
+                $buf = $s->getline;
+                ++$n
+        } until $buf eq ".\r\n";
+        $n;
+}
+
+$t = timeit(1, sub {
+        $s->print("XOVER $spec\r\n");
+        $n = read_until_dot($s);
+});
+diag 'xover took: ' . timestr($t) . " for $n";
+
+$t = timeit(1, sub {
+        $s->print("HDR From $spec\r\n");
+        $n = read_until_dot($s);
+
+});
+diag "XHDR From ". timestr($t) . " for $n";
+
+my $date = $ENV{NEWNEWS_DATE};
+unless ($date) {
+        my (undef, undef, undef, $d, $m, $y) = gmtime(time - 30 * 86400);
+        $date = sprintf('%04u%02u%02u', $y + 1900, $m, $d);
+        diag "NEWNEWS_DATE undefined, using $date";
+}
+$t = timeit(1, sub {
+        $s->print("NEWNEWS * $date 000000 GMT\r\n");
+        $n = read_until_dot($s);
+});
+diag 'newnews took: ' . timestr($t) . " for $n";
+
+done_testing();
+
+1;
diff --git a/t/perf-threading.t b/t/perf-threading.t
new file mode 100644
index 00000000..15779c93
--- /dev/null
+++ b/t/perf-threading.t
@@ -0,0 +1,32 @@
+# Copyright (C) 2016-2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+#
+# real-world testing of search threading
+use strict;
+use warnings;
+use Test::More;
+use Benchmark qw(:all);
+use PublicInbox::Inbox;
+my $pi_dir = $ENV{GIANT_PI_DIR};
+plan skip_all => "GIANT_PI_DIR not defined for $0" unless $pi_dir;
+my $ibx = PublicInbox::Inbox->new({ mainrepo => $pi_dir });
+eval { require PublicInbox::Search };
+my $srch = $ibx->search;
+plan skip_all => "$pi_dir not configured for search $0 $@" unless $srch;
+
+require PublicInbox::View;
+
+my $msgs;
+my $elapsed = timeit(1, sub {
+        $msgs = $srch->{over_ro}->recent({limit => 200000});
+});
+my $n = scalar(@$msgs);
+ok($n, 'got some messages');
+diag "enquire: ".timestr($elapsed)." for $n";
+
+$elapsed = timeit(1, sub {
+        PublicInbox::View::thread_results({-inbox => $ibx}, $msgs);
+});
+diag "thread_results ".timestr($elapsed);
+
+done_testing();
diff --git a/t/plack.t b/t/plack.t
index 26b03660..7eb7d7f2 100644
--- a/t/plack.t
+++ b/t/plack.t
@@ -18,6 +18,7 @@ foreach my $mod (@mods) {
 }
 use_ok 'PublicInbox::Import';
 use_ok 'PublicInbox::Git';
+my @ls;
 
 foreach my $mod (@mods) { use_ok $mod; }
 {
@@ -55,6 +56,8 @@ EOF
                 $im->done;
                 my $rev = `git --git-dir="$maindir" rev-list HEAD`;
                 like($rev, qr/\A[a-f0-9]{40}/, "good revision committed");
+                @ls = `git --git-dir="$maindir" ls-tree -r --name-only HEAD`;
+                chomp @ls;
         }
         my $app = eval {
                 local $ENV{PI_CONFIG} = $pi_config;
@@ -198,6 +201,21 @@ EOF
                              "$sfx redirected to /mbox.gz");
                 });
         }
+        test_psgi($app, sub {
+                my ($cb) = @_;
+                # for a while, we used to support /$INBOX/$X40/
+                # when we "compressed" long Message-IDs to SHA-1
+                # Now we're stuck supporting them forever :<
+                foreach my $path (@ls) {
+                        $path =~ tr!/!!d;
+                        my $from = "http://example.com/test/$path/";
+                        my $res = $cb->(GET($from));
+                        is(301, $res->code, 'is permanent redirect');
+                        like($res->header('Location'),
+                                qr!/test/blah\@example\.com/!,
+                                'redirect from x40 MIDs works');
+                }
+        });
 }
 
 done_testing();
diff --git a/t/psgi_search.t b/t/psgi_search.t
index cf5a7e91..2f033016 100644
--- a/t/psgi_search.t
+++ b/t/psgi_search.t
@@ -30,8 +30,7 @@ EOF
 
 my $num = 0;
 # nb. using internal API, fragile!
-my $xdb = $rw->_xdb_acquire;
-$xdb->begin_transaction;
+$rw->begin_txn_lazy;
 
 foreach (reverse split(/\n\n/, $data)) {
         $_ .= "\n";
@@ -42,8 +41,7 @@ foreach (reverse split(/\n\n/, $data)) {
         ok($doc_id, 'message added: '. $mid);
 }
 
-$xdb->commit_transaction;
-$rw = undef;
+$rw->commit_txn_lazy;
 
 my $cfgpfx = "publicinbox.test";
 my $config = PublicInbox::Config->new({
diff --git a/t/psgi_v2.t b/t/psgi_v2.t
new file mode 100644
index 00000000..65448dc4
--- /dev/null
+++ b/t/psgi_v2.t
@@ -0,0 +1,245 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use File::Temp qw/tempdir/;
+use PublicInbox::MIME;
+use PublicInbox::Config;
+use PublicInbox::WWW;
+use PublicInbox::MID qw(mids);
+my @mods = qw(DBD::SQLite Search::Xapian HTTP::Request::Common Plack::Test
+                URI::Escape Plack::Builder);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for psgi_v2_dupes.t" if $@;
+}
+use_ok($_) for @mods;
+use_ok 'PublicInbox::V2Writable';
+my $mainrepo = tempdir('pi-v2_dupes-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => 'test-v2writable',
+        version => 2,
+        -primary_address => 'test@example.com',
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $new_mid;
+
+my $im = PublicInbox::V2Writable->new($ibx, 1);
+$im->{parallel} = 0;
+
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a-mid@b>',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+ok($im->add($mime), 'added one message');
+$mime->body_set("hello world!\n");
+
+my @warn;
+local $SIG{__WARN__} = sub { push @warn, @_ };
+$mime->header_set(Date => 'Fri, 02 Oct 1993 00:01:00 +0000');
+ok($im->add($mime), 'added duplicate-but-different message');
+is(scalar(@warn), 1, 'got one warning');
+my $mids = mids($mime->header_obj);
+$new_mid = $mids->[1];
+$im->done;
+
+my $cfgpfx = "publicinbox.v2test";
+my $cfg = {
+        "$cfgpfx.address" => $ibx->{-primary_address},
+        "$cfgpfx.mainrepo" => $mainrepo,
+};
+my $config = PublicInbox::Config->new($cfg);
+my $www = PublicInbox::WWW->new($config);
+my ($res, $raw, @from_);
+test_psgi(sub { $www->call(@_) }, sub {
+        my ($cb) = @_;
+        $res = $cb->(GET('/v2test/a-mid@b/raw'));
+        $raw = $res->content;
+        like($raw, qr/^hello world$/m, 'got first message');
+        like($raw, qr/^hello world!$/m, 'got second message');
+        @from_ = ($raw =~ m/^From /mg);
+        is(scalar(@from_), 2, 'two From_ lines');
+
+        $res = $cb->(GET("/v2test/$new_mid/raw"));
+        $raw = $res->content;
+        like($raw, qr/^hello world!$/m, 'second message with new Message-Id');
+        @from_ = ($raw =~ m/^From /mg);
+        is(scalar(@from_), 1, 'only one From_ line');
+
+        # Atom feed should sort by Date: (if Received is missing)
+        $res = $cb->(GET('/v2test/new.atom'));
+        my @bodies = ($res->content =~ />(hello [^<]+)</mg);
+        is_deeply(\@bodies, [ "hello world!\n", "hello world\n" ],
+                'Atom ordering is chronological');
+
+        # new.html should sort by Date:, too (if Received is missing)
+        $res = $cb->(GET('/v2test/new.html'));
+        @bodies = ($res->content =~ /^(hello [^<]+)$/mg);
+        is_deeply(\@bodies, [ "hello world!\n", "hello world\n" ],
+                'new.html ordering is chronological');
+});
+
+$mime->header_set('Message-Id', 'a-mid@b');
+$mime->body_set("hello ghosts\n");
+ok($im->add($mime), 'added 3rd duplicate-but-different message');
+is(scalar(@warn), 2, 'got another warning');
+like($warn[0], qr/mismatched/, 'warned about mismatched messages');
+is($warn[0], $warn[1], 'both warnings are the same');
+
+$mids = mids($mime->header_obj);
+my $third = $mids->[-1];
+$im->done;
+
+test_psgi(sub { $www->call(@_) }, sub {
+        my ($cb) = @_;
+        $res = $cb->(GET("/v2test/$third/raw"));
+        $raw = $res->content;
+        like($raw, qr/^hello ghosts$/m, 'got third message');
+        @from_ = ($raw =~ m/^From /mg);
+        is(scalar(@from_), 1, 'one From_ line');
+
+        $res = $cb->(GET('/v2test/a-mid@b/raw'));
+        $raw = $res->content;
+        like($raw, qr/^hello world$/m, 'got first message');
+        like($raw, qr/^hello world!$/m, 'got second message');
+        like($raw, qr/^hello ghosts$/m, 'got third message');
+        @from_ = ($raw =~ m/^From /mg);
+        is(scalar(@from_), 3, 'three From_ lines');
+        $config->each_inbox(sub { $_[0]->search->reopen });
+
+        SKIP: {
+                eval { require IO::Uncompress::Gunzip };
+                skip 'IO::Uncompress::Gunzip missing', 4 if $@;
+
+                $res = $cb->(GET('/v2test/a-mid@b/t.mbox.gz'));
+                my $out;
+                my $in = $res->content;
+                my $status = IO::Uncompress::Gunzip::gunzip(\$in => \$out);
+                like($out, qr/^hello world$/m, 'got first in t.mbox.gz');
+                like($out, qr/^hello world!$/m, 'got second in t.mbox.gz');
+                like($out, qr/^hello ghosts$/m, 'got third in t.mbox.gz');
+                @from_ = ($out =~ m/^From /mg);
+                is(scalar(@from_), 3, 'three From_ lines in t.mbox.gz');
+
+                # search interface
+                $res = $cb->(POST('/v2test/?q=m:a-mid@b&x=m'));
+                $in = $res->content;
+                $status = IO::Uncompress::Gunzip::gunzip(\$in => \$out);
+                like($out, qr/^hello world$/m, 'got first in mbox POST');
+                like($out, qr/^hello world!$/m, 'got second in mbox POST');
+                like($out, qr/^hello ghosts$/m, 'got third in mbox POST');
+                @from_ = ($out =~ m/^From /mg);
+                is(scalar(@from_), 3, 'three From_ lines in mbox POST');
+
+                # all.mbox.gz interface
+                $res = $cb->(GET('/v2test/all.mbox.gz'));
+                $in = $res->content;
+                $status = IO::Uncompress::Gunzip::gunzip(\$in => \$out);
+                like($out, qr/^hello world$/m, 'got first in all.mbox');
+                like($out, qr/^hello world!$/m, 'got second in all.mbox');
+                like($out, qr/^hello ghosts$/m, 'got third in all.mbox');
+                @from_ = ($out =~ m/^From /mg);
+                is(scalar(@from_), 3, 'three From_ lines in all.mbox');
+        };
+
+        $res = $cb->(GET('/v2test/?q=m:a-mid@b&x=t'));
+        is($res->code, 200, 'success with threaded search');
+        my $raw = $res->content;
+        ok($raw =~ s/\A.*>Results 1-3 of 3<//s, 'got all results');
+        my @over = ($raw =~ m/\d{4}-\d+-\d+\s+\d+:\d+ (.+)$/gm);
+        is_deeply(\@over, [ '<a', '` <a', '` <a' ], 'threaded messages show up');
+
+        local $SIG{__WARN__} = 'DEFAULT';
+        $res = $cb->(GET('/v2test/a-mid@b/'));
+        $raw = $res->content;
+        like($raw, qr/^hello world$/m, 'got first message');
+        like($raw, qr/^hello world!$/m, 'got second message');
+        like($raw, qr/^hello ghosts$/m, 'got third message');
+        @from_ = ($raw =~ m/>From: /mg);
+        is(scalar(@from_), 3, 'three From: lines');
+        foreach my $mid ('a-mid@b', $new_mid, $third) {
+                like($raw, qr/&lt;\Q$mid\E&gt;/s, "Message-ID $mid shown");
+        }
+        like($raw, qr/\b3\+ messages\b/, 'thread overview shown');
+
+        my $exp = [ qw(<a-mid@b> <reuse@mid>) ];
+        $mime->header_set('Message-Id', @$exp);
+        $mime->header_set('Subject', '4th dupe');
+        local $SIG{__WARN__} = sub {};
+        ok($im->add($mime), 'added one message');
+        $im->done;
+        my @h = $mime->header('Message-ID');
+        is_deeply($exp, \@h, 'reused existing Message-ID');
+
+        $config->each_inbox(sub { $_[0]->search->reopen });
+
+        $res = $cb->(GET('/v2test/new.atom'));
+        my @ids = ($res->content =~ m!<id>urn:uuid:([^<]+)</id>!sg);
+        my %ids;
+        $ids{$_}++ for @ids;
+        is_deeply([qw(1 1 1 1)], [values %ids], 'feed ids unique');
+
+        $res = $cb->(GET('/v2test/reuse@mid/T/'));
+        $raw = $res->content;
+        like($raw, qr/\b4\+ messages\b/, 'thread overview shown with /T/');
+        @over = ($raw =~ m/^\d{4}-\d+-\d+\s+\d+:\d+ (.+)$/gm);
+        is_deeply(\@over, [ '<a', '` <a', '` <a', '` <a' ],
+                'duplicate messages share the same root');
+
+        $res = $cb->(GET('/v2test/reuse@mid/t/'));
+        $raw = $res->content;
+        like($raw, qr/\b4\+ messages\b/, 'thread overview shown with /t/');
+
+        $res = $cb->(GET('/v2test/0/info/refs'));
+        is($res->code, 200, 'got info refs for dumb clones');
+        $res = $cb->(GET('/v2test/info/refs'));
+        is($res->code, 404, 'unpartitioned git URL fails');
+
+        # ensure conflicted attachments can be resolved
+        foreach my $body (qw(old new)) {
+                my $parts = [
+                        PublicInbox::MIME->create(
+                                attributes => { content_type => 'text/plain' },
+                                body => 'blah',
+                        ),
+                        PublicInbox::MIME->create(
+                                attributes => {
+                                        filename => 'attach.txt',
+                                        content_type => 'text/plain',
+                                },
+                                body => $body
+                        )
+                ];
+                $mime = PublicInbox::MIME->create(
+                        parts => $parts,
+                        header_str => [ From => 'root@z',
+                                'Message-ID' => '<a@dup>',
+                                Subject => 'hi']
+                );
+                ok($im->add($mime), "added attachment $body");
+        }
+        $im->done;
+        $config->each_inbox(sub { $_[0]->search->reopen });
+        $res = $cb->(GET('/v2test/a@dup/'));
+        my @links = ($res->content =~ m!"\.\./([^/]+/2-attach\.txt)\"!g);
+        is(scalar(@links), 2, 'both attachment links exist');
+        isnt($links[0], $links[1], 'attachment links are different');
+        {
+                my $old = $cb->(GET('/v2test/' . $links[0]));
+                my $new = $cb->(GET('/v2test/' . $links[1]));
+                is($old->content, 'old', 'got expected old content');
+                is($new->content, 'new', 'got expected new content');
+        }
+});
+
+done_testing();
+
+1;
diff --git a/t/search-thr-index.t b/t/search-thr-index.t
index c3534f6b..2aa97bff 100644
--- a/t/search-thr-index.t
+++ b/t/search-thr-index.t
@@ -4,6 +4,7 @@ use strict;
 use warnings;
 use Test::More;
 use File::Temp qw/tempdir/;
+use PublicInbox::MID qw(mids);
 use Email::MIME;
 eval { require PublicInbox::SearchIdx; };
 plan skip_all => "Xapian missing for search" if $@;
@@ -31,8 +32,7 @@ EOF
 
 my $num = 0;
 # nb. using internal API, fragile!
-my $xdb = $rw->_xdb_acquire;
-$xdb->begin_transaction;
+my $xdb = $rw->begin_txn_lazy;
 my @mids;
 
 foreach (reverse split(/\n\n/, $data)) {
@@ -41,18 +41,20 @@ foreach (reverse split(/\n\n/, $data)) {
         $mime->header_set('From' => 'bw@g');
         $mime->header_set('To' => 'git@vger.kernel.org');
         my $bytes = bytes::length($mime->as_string);
-        my $doc_id = $rw->add_message($mime, $bytes, ++$num, 'ignored');
-        my $mid = $mime->header('Message-Id');
+        my $mid = mids($mime->header_obj)->[0];
+        my $doc_id = $rw->add_message($mime, $bytes, ++$num, 'ignored', $mid);
         push @mids, $mid;
         ok($doc_id, 'message added: '. $mid);
 }
 
 my $prev;
 foreach my $mid (@mids) {
-        my $res = $rw->get_thread($mid);
-        is(3, $res->{total}, "got all messages from $mid");
+        my $msgs = $rw->{over}->get_thread($mid);
+        is(3, scalar(@$msgs), "got all messages from $mid");
 }
 
+$rw->commit_txn_lazy;
+
 done_testing();
 
 1;
diff --git a/t/search.t b/t/search.t
index 6b1aa2a3..48c2511c 100644
--- a/t/search.t
+++ b/t/search.t
@@ -18,36 +18,36 @@ ok($@, "exception raised on non-existent DB");
 my $rw = PublicInbox::SearchIdx->new($git_dir, 1);
 $rw->_xdb_acquire;
 $rw->_xdb_release;
+my $ibx = $rw->{-inbox};
 $rw = undef;
 my $ro = PublicInbox::Search->new($git_dir);
 my $rw_commit = sub {
-        $rw->{xdb}->commit_transaction if $rw && $rw->{xdb};
+        $rw->commit_txn_lazy if $rw;
         $rw = PublicInbox::SearchIdx->new($git_dir, 1);
-        $rw->_xdb_acquire->begin_transaction;
+        $rw->begin_txn_lazy;
 };
 
 {
         # git repository perms
-        is(PublicInbox::SearchIdx->_git_config_perm(undef),
-           &PublicInbox::SearchIdx::PERM_GROUP,
+        is($ibx->_git_config_perm(), &PublicInbox::InboxWritable::PERM_GROUP,
            "undefined permission is group");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0644')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('0644')),
            0022, "644 => umask(0022)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0600')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('0600')),
            0077, "600 => umask(0077)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('0640')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('0640')),
            0027, "640 => umask(0027)");
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('group')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('group')),
            0007, 'group => umask(0007)');
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('everybody')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('everybody')),
            0002, 'everybody => umask(0002)');
-        is(PublicInbox::SearchIdx::_umask_for(
-             PublicInbox::SearchIdx->_git_config_perm('umask')),
+        is(PublicInbox::InboxWritable::_umask_for(
+             PublicInbox::InboxWritable->_git_config_perm('umask')),
            umask, 'umask => existing umask');
 }
 
@@ -82,18 +82,16 @@ my $rw_commit = sub {
 }
 
 sub filter_mids {
-        my ($res) = @_;
-        sort(map { $_->mid } @{$res->{msgs}});
+        my ($msgs) = @_;
+        sort(map { $_->mid } @$msgs);
 }
 
 {
         $rw_commit->();
         $ro->reopen;
-        my $found = $ro->lookup_message('<root@s>');
-        ok($found, "message found");
-        is($root_id, $found->{doc_id}, 'doc_id set correctly');
-        is($found->mid, 'root@s', 'mid set correctly');
-        ok(int($found->thread_id) > 0, 'thread_id is an integer');
+        my $found = $ro->query('m:root@s');
+        is(scalar(@$found), 1, "message found");
+        is($found->[0]->mid, 'root@s', 'mid set correctly');
 
         my ($res, @res);
         my @exp = sort qw(root@s last@s);
@@ -107,12 +105,12 @@ sub filter_mids {
         is_deeply(\@res, \@exp, 'got expected results for s:"" match');
 
         $res = $ro->query('s:"Hello world"', {limit => 1});
-        is(scalar @{$res->{msgs}}, 1, "limit works");
-        my $first = $res->{msgs}->[0];
+        is(scalar @$res, 1, "limit works");
+        my $first = $res->[0];
 
         $res = $ro->query('s:"Hello world"', {offset => 1});
-        is(scalar @{$res->{msgs}}, 1, "offset works");
-        my $second = $res->{msgs}->[0];
+        is(scalar @$res, 1, "offset works");
+        my $second = $res->[0];
 
         isnt($first, $second, "offset returned different result from limit");
 }
@@ -148,7 +146,13 @@ sub filter_mids {
 
         my $ghost_id = $rw->add_message($was_ghost);
         is($ghost_id, int($ghost_id), "ghost_id is an integer: $ghost_id");
-        ok($ghost_id < $reply_id, "ghost vivified from earlier message");
+        my $msgs = $rw->{over}->get_thread('ghost-message@s');
+        is(scalar(@$msgs), 2, 'got both messages in ghost thread');
+        foreach (qw(sid tid)) {
+                is($msgs->[0]->{$_}, $msgs->[1]->{$_}, "{$_} match");
+        }
+        isnt($msgs->[0]->{num}, $msgs->[1]->{num}, "num do not match");
+        ok($_->{num} > 0, 'positive art num') foreach @$msgs
 }
 
 # search thread on ghost
@@ -164,7 +168,14 @@ sub filter_mids {
 
         # body
         $res = $ro->query('goodbye');
-        is($res->{msgs}->[0]->mid, 'last@s', 'got goodbye message body');
+        is($res->[0]->mid, 'last@s', 'got goodbye message body');
+
+        # datestamp
+        $res = $ro->query('dt:20101002000001..20101002000001');
+        @res = filter_mids($res);
+        is_deeply(\@res, ['ghost-message@s'], 'exact Date: match works');
+        $res = $ro->query('dt:20101002000002..20101002000002');
+        is_deeply($res, [], 'exact Date: match down to the second');
 }
 
 # long message-id
@@ -210,7 +221,7 @@ sub filter_mids {
         $rw_commit->();
         $ro->reopen;
         my $t = $ro->get_thread('root@s');
-        is($t->{total}, 4, "got all 4 mesages in thread");
+        is(scalar(@$t), 4, "got all 4 mesages in thread");
         my @exp = sort($long_reply_mid, 'root@s', 'last@s', $long_mid);
         @res = filter_mids($t);
         is_deeply(\@res, \@exp, "get_thread works");
@@ -239,13 +250,13 @@ sub filter_mids {
                 ],
                 body => "theatre\nfade\n"));
         my $res = $rw->query("theatre");
-        is($res->{total}, 2, "got both matches");
-        is($res->{msgs}->[0]->mid, 'nquote@a', "non-quoted scores higher");
-        is($res->{msgs}->[1]->mid, 'quote@a', "quoted result still returned");
+        is(scalar(@$res), 2, "got both matches");
+        is($res->[0]->mid, 'nquote@a', "non-quoted scores higher");
+        is($res->[1]->mid, 'quote@a', "quoted result still returned");
 
         $res = $rw->query("illusions");
-        is($res->{total}, 1, "got a match for quoted text");
-        is($res->{msgs}->[0]->mid, 'quote@a',
+        is(scalar(@$res), 1, "got a match for quoted text");
+        is($res->[0]->mid, 'quote@a',
                 "quoted result returned if nothing else");
 }
 
@@ -264,10 +275,9 @@ sub filter_mids {
                 ],
                 body => "LOOP!\n"));
         ok($doc_id > 0, "doc_id defined with circular reference");
-        my $smsg = $rw->lookup_message('circle@a');
+        my $smsg = $rw->query('m:circle@a', {limit=>1})->[0];
         is($smsg->references, '', "no references created");
-        my $msg = PublicInbox::SearchMsg->load_doc($smsg->{doc});
-        is($s, $msg->subject, 'long subject not rewritten');
+        is($s, $smsg->subject, 'long subject not rewritten');
 }
 
 {
@@ -281,51 +291,51 @@ sub filter_mids {
         my $mime = Email::MIME->new($str);
         my $doc_id = $rw->add_message($mime);
         ok($doc_id > 0, 'message indexed doc_id with UTF-8');
-        my $smsg = $rw->lookup_message('testmessage@example.com');
-        my $msg = PublicInbox::SearchMsg->load_doc($smsg->{doc});
-
+        my $msg = $rw->query('m:testmessage@example.com', {limit => 1})->[0];
         is($mime->header('Subject'), $msg->subject, 'UTF-8 subject preserved');
 }
 
 {
-        my $res = $ro->query('d:19931002..20101002');
-        ok(scalar @{$res->{msgs}} > 0, 'got results within range');
-        $res = $ro->query('d:20101003..');
-        is(scalar @{$res->{msgs}}, 0, 'nothing after 20101003');
-        $res = $ro->query('d:..19931001');
-        is(scalar @{$res->{msgs}}, 0, 'nothing before 19931001');
+        my $msgs = $ro->query('d:19931002..20101002');
+        ok(scalar(@$msgs) > 0, 'got results within range');
+        $msgs = $ro->query('d:20101003..');
+        is(scalar(@$msgs), 0, 'nothing after 20101003');
+        $msgs = $ro->query('d:..19931001');
+        is(scalar(@$msgs), 0, 'nothing before 19931001');
 }
 
 # names and addresses
 {
-        my $res = $ro->query('t:list@example.com');
-        is(scalar @{$res->{msgs}}, 6, 'searched To: successfully');
-        foreach my $smsg (@{$res->{msgs}}) {
+        my $mset = $ro->query('t:list@example.com', {mset => 1});
+        is($mset->size, 6, 'searched To: successfully');
+        foreach my $m ($mset->items) {
+                my $smsg = $ro->lookup_article($m->get_docid);
                 like($smsg->to, qr/\blist\@example\.com\b/, 'to appears');
         }
 
-        $res = $ro->query('tc:list@example.com');
-        is(scalar @{$res->{msgs}}, 6, 'searched To+Cc: successfully');
-        foreach my $smsg (@{$res->{msgs}}) {
+        $mset = $ro->query('tc:list@example.com', {mset => 1});
+        is($mset->size, 6, 'searched To+Cc: successfully');
+        foreach my $m ($mset->items) {
+                my $smsg = $ro->lookup_article($m->get_docid);
                 my $tocc = join("\n", $smsg->to, $smsg->cc);
                 like($tocc, qr/\blist\@example\.com\b/, 'tocc appears');
         }
 
         foreach my $pfx ('tcf:', 'c:') {
-                $res = $ro->query($pfx . 'foo@example.com');
-                is(scalar @{$res->{msgs}}, 1,
-                        "searched $pfx successfully for Cc:");
-                foreach my $smsg (@{$res->{msgs}}) {
+                my $mset = $ro->query($pfx . 'foo@example.com', { mset => 1 });
+                is($mset->items, 1, "searched $pfx successfully for Cc:");
+                foreach my $m ($mset->items) {
+                        my $smsg = $ro->lookup_article($m->get_docid);
                         like($smsg->cc, qr/\bfoo\@example\.com\b/,
                                 'cc appears');
                 }
         }
 
         foreach my $pfx ('', 'tcf:', 'f:') {
-                $res = $ro->query($pfx . 'Laggy');
-                is(scalar @{$res->{msgs}}, 1,
+                my $res = $ro->query($pfx . 'Laggy');
+                is(scalar(@$res), 1,
                         "searched $pfx successfully for From:");
-                foreach my $smsg (@{$res->{msgs}}) {
+                foreach my $smsg (@$res) {
                         like($smsg->from, qr/Laggy Sender/,
                                 "From appears with $pfx");
                 }
@@ -336,23 +346,23 @@ sub filter_mids {
         $rw_commit->();
         $ro->reopen;
         my $res = $ro->query('b:hello');
-        is(scalar @{$res->{msgs}}, 0, 'no match on body search only');
+        is(scalar(@$res), 0, 'no match on body search only');
         $res = $ro->query('bs:smith');
-        is(scalar @{$res->{msgs}}, 0,
+        is(scalar(@$res), 0,
                 'no match on body+subject search for From');
 
         $res = $ro->query('q:theatre');
-        is(scalar @{$res->{msgs}}, 1, 'only one quoted body');
-        like($res->{msgs}->[0]->from, qr/\AQuoter/, 'got quoted body');
+        is(scalar(@$res), 1, 'only one quoted body');
+        like($res->[0]->from, qr/\AQuoter/, 'got quoted body');
 
         $res = $ro->query('nq:theatre');
-        is(scalar @{$res->{msgs}}, 1, 'only one non-quoted body');
-        like($res->{msgs}->[0]->from, qr/\ANon-Quoter/, 'got non-quoted body');
+        is(scalar @$res, 1, 'only one non-quoted body');
+        like($res->[0]->from, qr/\ANon-Quoter/, 'got non-quoted body');
 
         foreach my $pfx (qw(b: bs:)) {
                 $res = $ro->query($pfx . 'theatre');
-                is(scalar @{$res->{msgs}}, 2, "searched both bodies for $pfx");
-                like($res->{msgs}->[0]->from, qr/\ANon-Quoter/,
+                is(scalar @$res, 2, "searched both bodies for $pfx");
+                like($res->[0]->from, qr/\ANon-Quoter/,
                         "non-quoter first for $pfx");
         }
 }
@@ -391,14 +401,22 @@ sub filter_mids {
         $rw_commit->();
         $ro->reopen;
         my $n = $ro->query('n:attached_fart.txt');
-        is(scalar @{$n->{msgs}}, 1, 'got result for n:');
+        is(scalar @$n, 1, 'got result for n:');
         my $res = $ro->query('part_deux.txt');
-        is(scalar @{$res->{msgs}}, 1, 'got result without n:');
-        is($n->{msgs}->[0]->mid, $res->{msgs}->[0]->mid,
+        is(scalar @$res, 1, 'got result without n:');
+        is($n->[0]->mid, $res->[0]->mid,
                 'same result with and without');
         my $txt = $ro->query('"inside another"');
-        is($txt->{msgs}->[0]->mid, $res->{msgs}->[0]->mid,
+        is($txt->[0]->mid, $res->[0]->mid,
                 'search inside text attachments works');
+
+        my $mid = $n->[0]->mid;
+        my ($id, $prev);
+        my $art = $ro->next_by_mid($mid, \$id, \$prev);
+        ok($art, 'article exists in OVER DB');
+        $rw->unindex_blob($amsg);
+        $rw->commit_txn_lazy;
+        is($ro->lookup_article($art->{num}), undef, 'gone from OVER DB');
 }
 
 done_testing();
diff --git a/t/thread-all.t b/t/thread-all.t
deleted file mode 100644
index d4e8c1fc..00000000
--- a/t/thread-all.t
+++ /dev/null
@@ -1,38 +0,0 @@
-# Copyright (C) 2016-2018 all contributors <meta@public-inbox.org>
-# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
-#
-# real-world testing of search threading
-use strict;
-use warnings;
-use Test::More;
-use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC);
-my $pi_dir = $ENV{GIANT_PI_DIR};
-plan skip_all => "GIANT_PI_DIR not defined for $0" unless $pi_dir;
-eval { require PublicInbox::Search; };
-plan skip_all => "Xapian missing for $0" if $@;
-my $srch = eval { PublicInbox::Search->new($pi_dir) };
-plan skip_all => "$pi_dir not initialized for $0" if $@;
-
-require PublicInbox::View;
-require PublicInbox::SearchThread;
-
-my $pfx = PublicInbox::Search::xpfx('thread');
-my $opts = { limit => 1000000, asc => 1 };
-my $t0 = clock_gettime(CLOCK_MONOTONIC);
-my $elapsed;
-
-my $sres = $srch->_do_enquire(undef, $opts);
-$elapsed = clock_gettime(CLOCK_MONOTONIC) - $t0;
-diag "enquire: $elapsed";
-
-$t0 = clock_gettime(CLOCK_MONOTONIC);
-my $msgs = PublicInbox::View::load_results($srch, $sres);
-$elapsed = clock_gettime(CLOCK_MONOTONIC) - $t0;
-diag "load_results $elapsed";
-
-$t0 = clock_gettime(CLOCK_MONOTONIC);
-PublicInbox::View::thread_results($msgs);
-$elapsed = clock_gettime(CLOCK_MONOTONIC) - $t0;
-diag "thread_results $elapsed";
-
-done_testing();
diff --git a/t/time.t b/t/time.t
new file mode 100644
index 00000000..370a0bd3
--- /dev/null
+++ b/t/time.t
@@ -0,0 +1,28 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use_ok 'PublicInbox::MIME';
+use PublicInbox::MsgTime qw(msg_datestamp);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a-mid@b>',
+                Date => 'Fri, 02 Oct 93 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+
+my $ts = msg_datestamp($mime->header_obj);
+use POSIX qw(strftime);
+is(strftime('%Y-%m-%d %H:%M:%S', gmtime($ts)), '1993-10-02 00:00:00',
+        'got expected date with 2 digit year');
+$mime->header_set(Date => 'Fri, 02 Oct 101 01:02:03 +0000');
+$ts = msg_datestamp($mime->header_obj);
+is(strftime('%Y-%m-%d %H:%M:%S', gmtime($ts)), '2001-10-02 01:02:03',
+        'got expected date with 3 digit year');
+
+done_testing();
diff --git a/t/v1-add-remove-add.t b/t/v1-add-remove-add.t
new file mode 100644
index 00000000..cd6e2811
--- /dev/null
+++ b/t/v1-add-remove-add.t
@@ -0,0 +1,45 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::MIME;
+use PublicInbox::Import;
+use PublicInbox::SearchIdx;
+use File::Temp qw/tempdir/;
+
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for v1-add-remove-add.t" if $@;
+}
+my $mainrepo = tempdir('pi-add-remove-add-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+is(system(qw(git init --bare), $mainrepo), 0);
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => 'test-add-remove-add',
+        -primary_address => 'test@example.com',
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+                'Message-ID' => '<a-mid@b>',
+        ],
+        body => "hello world\n",
+);
+my $im = PublicInbox::Import->new($ibx->git, undef, undef, $ibx);
+ok($im->add($mime), 'message added');
+ok($im->remove($mime), 'message added');
+ok($im->add($mime), 'message added again');
+$im->done;
+my $rw = PublicInbox::SearchIdx->new($ibx, 1);
+$rw->index_sync;
+my $msgs = $ibx->recent({limit => 10});
+is($msgs->[0]->{mid}, 'a-mid@b', 'message exists in history');
+is(scalar @$msgs, 1, 'only one message in history');
+is($ibx->mm->num_for('a-mid@b'), 2, 'exists with second article number');
+
+done_testing();
diff --git a/t/v2-add-remove-add.t b/t/v2-add-remove-add.t
new file mode 100644
index 00000000..c8d12d34
--- /dev/null
+++ b/t/v2-add-remove-add.t
@@ -0,0 +1,42 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::MIME;
+use File::Temp qw/tempdir/;
+
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for v2-add-remove-add.t" if $@;
+}
+use_ok 'PublicInbox::V2Writable';
+my $mainrepo = tempdir('pi-add-remove-add-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => "$mainrepo/v2",
+        name => 'test-v2writable',
+        version => 2,
+        -primary_address => 'test@example.com',
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+                'Message-ID' => '<a-mid@b>',
+        ],
+        body => "hello world\n",
+);
+my $im = PublicInbox::V2Writable->new($ibx, 1);
+$im->{parallel} = 0;
+ok($im->add($mime), 'message added');
+ok($im->remove($mime), 'message added');
+ok($im->add($mime), 'message added again');
+$im->done;
+my $msgs = $ibx->recent({limit => 1000});
+is($msgs->[0]->{mid}, 'a-mid@b', 'message exists in history');
+is(scalar @$msgs, 1, 'only one message in history');
+
+done_testing();
diff --git a/t/v2mda.t b/t/v2mda.t
new file mode 100644
index 00000000..ca1bb09c
--- /dev/null
+++ b/t/v2mda.t
@@ -0,0 +1,59 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::MIME;
+use File::Temp qw/tempdir/;
+use Fcntl qw(SEEK_SET);
+use Cwd;
+
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for v2mda.t" if $@;
+}
+use_ok 'PublicInbox::V2Writable';
+my $tmpdir = tempdir('pi-v2mda-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => "$tmpdir/inbox",
+        name => 'test-v2writable',
+        address => [ 'test@example.com' ],
+};
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+                'Message-ID' => '<foo@bar>',
+                'List-ID' => '<test.example.com>',
+        ],
+        body => "hello world\n",
+);
+
+my $mda = "blib/script/public-inbox-mda";
+ok(-f "blib/script/public-inbox-mda", '-mda exists');
+my $main_bin = getcwd()."/t/main-bin";
+local $ENV{PI_DIR} = "$tmpdir/foo";
+local $ENV{PATH} = "$main_bin:blib/script:$ENV{PATH}";
+my @cmd = (qw(public-inbox-init -V2), $ibx->{name},
+                $ibx->{mainrepo}, 'http://localhost/test',
+                $ibx->{address}->[0]);
+ok(PublicInbox::Import::run_die(\@cmd), 'initialized v2 inbox');
+
+open my $tmp, '+>', undef or die "failed to open anonymous tempfile: $!";
+ok($tmp->print($mime->as_string), 'wrote to temporary file');
+ok($tmp->flush, 'flushed temporary file');
+ok($tmp->sysseek(0, SEEK_SET), 'seeked');
+
+my $rdr = { 0 => fileno($tmp) };
+local $ENV{ORIGINAL_RECIPIENT} = 'test@example.com';
+ok(PublicInbox::Import::run_die(['public-inbox-mda'], undef, $rdr),
+        'mda delivered a message');
+
+$ibx = PublicInbox::Inbox->new($ibx);
+my $msgs = $ibx->search->query('');
+my $saved = $ibx->smsg_mime($msgs->[0]);
+is($saved->{mime}->as_string, $mime->as_string, 'injected message');
+
+done_testing();
diff --git a/t/v2mirror.t b/t/v2mirror.t
new file mode 100644
index 00000000..9e0c9e1d
--- /dev/null
+++ b/t/v2mirror.t
@@ -0,0 +1,176 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+
+# Integration tests for HTTP cloning + mirroring
+foreach my $mod (qw(Plack::Util Plack::Builder Danga::Socket
+                        HTTP::Date HTTP::Status Search::Xapian DBD::SQLite)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for v2mirror.t" if $@;
+}
+use File::Temp qw/tempdir/;
+use IO::Socket;
+use POSIX qw(dup2);
+use_ok 'PublicInbox::V2Writable';
+use PublicInbox::MIME;
+use PublicInbox::Config;
+use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
+# FIXME: too much setup
+my $tmpdir = tempdir('pi-v2mirror-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $script = 'blib/script/public-inbox';
+my $pi_config = "$tmpdir/config";
+{
+        open my $fh, '>', $pi_config or die "open($pi_config): $!";
+        print $fh <<"" or die "print $pi_config: $!";
+[publicinbox "v2"]
+        mainrepo = $tmpdir/in
+        address = test\@example.com
+
+        close $fh or die "close($pi_config): $!";
+}
+local $ENV{PI_CONFIG} = $pi_config;
+
+my $cfg = PublicInbox::Config->new($pi_config);
+my $ibx = $cfg->lookup('test@example.com');
+ok($ibx, 'inbox found');
+$ibx->{version} = 2;
+my $v2w = PublicInbox::V2Writable->new($ibx, 1);
+ok $v2w, 'v2w loaded';
+$v2w->{parallel} = 0;
+my $mime = PublicInbox::MIME->new(<<'');
+From: Me <me@example.com>
+To: You <you@example.com>
+Subject: a
+Date: Thu, 01 Jan 1970 00:00:00 +0000
+
+for my $i (1..9) {
+        $mime->header_set('Message-ID', "<$i\@example.com>");
+        $mime->header_set('Subject', "subject = $i");
+        ok($v2w->add($mime), "add msg $i OK");
+}
+$v2w->barrier;
+
+my %opts = (
+        LocalAddr => '127.0.0.1',
+        ReuseAddr => 1,
+        Proto => 'tcp',
+        Listen => 1024,
+);
+my ($sock, $pid);
+END { kill 'TERM', $pid if defined $pid };
+
+$! = 0;
+$sock = IO::Socket::INET->new(%opts);
+ok($sock, 'sock created');
+my $fl = fcntl($sock, F_GETFD, 0);
+$pid = fork;
+if ($pid == 0) {
+        # pretend to be systemd
+        fcntl($sock, F_SETFD, $fl &= ~FD_CLOEXEC);
+        dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+        $ENV{LISTEN_PID} = $$;
+        $ENV{LISTEN_FDS} = 1;
+        exec "$script-httpd", "--stdout=$tmpdir/out", "--stderr=$tmpdir/err";
+        die "FAIL: $!\n";
+}
+ok(defined $pid, 'forked httpd process successfully');
+my ($host, $port) = ($sock->sockhost, $sock->sockport);
+$sock = undef;
+
+my @cmd = (qw(git clone --mirror -q), "http://$host:$port/v2/0",
+        "$tmpdir/m/git/0.git");
+
+is(system(@cmd), 0, 'cloned OK');
+ok(-d "$tmpdir/m/git/0.git", 'mirror OK');;
+
+@cmd = ("$script-init", '-V2', 'm', "$tmpdir/m", 'http://example.com/m',
+        'alt@example.com');
+is(system(@cmd), 0, 'initialized public-inbox -V2');
+is(system("$script-index", "$tmpdir/m"), 0, 'indexed');
+
+my $mibx = { mainrepo => "$tmpdir/m", address => 'alt@example.com' };
+$mibx = PublicInbox::Inbox->new($mibx);
+is_deeply([$mibx->mm->minmax], [$ibx->mm->minmax], 'index synched minmax');
+
+for my $i (10..15) {
+        $mime->header_set('Message-ID', "<$i\@example.com>");
+        $mime->header_set('Subject', "subject = $i");
+        ok($v2w->add($mime), "add msg $i OK");
+}
+$v2w->barrier;
+is(system('git', "--git-dir=$tmpdir/m/git/0.git", 'fetch', '-q'), 0,
+        'fetch successful');
+
+my $mset = $mibx->search->reopen->query('m:15@example.com', {mset => 1});
+is(scalar($mset->items), 0, 'new message not found in mirror, yet');
+is(system("$script-index", "$tmpdir/m"), 0, 'index updated');
+is_deeply([$mibx->mm->minmax], [$ibx->mm->minmax], 'index synched minmax');
+$mset = $mibx->search->reopen->query('m:15@example.com', {mset => 1});
+is(scalar($mset->items), 1, 'found message in mirror');
+
+# purge:
+$mime->header_set('Message-ID', '<10@example.com>');
+$mime->header_set('Subject', 'subject = 10');
+{
+        my @warn;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        ok($v2w->purge($mime), 'purge a message');
+        my $warn = join('', @warn);
+        like($warn, qr/purge rewriting/);
+        my @subj = ($warn =~ m/^# subject .*$/mg);
+        is_deeply(\@subj, ["# subject = 10"], "only rewrote one");
+}
+
+$v2w->barrier;
+
+my $msgs = $mibx->search->{over_ro}->get_thread('10@example.com');
+my $to_purge = $msgs->[0]->{blob};
+like($to_purge, qr/\A[a-f0-9]{40,}\z/, 'read blob to be purged');
+$mset = $ibx->search->reopen->query('m:10@example.com', {mset => 1});
+is(scalar($mset->items), 0, 'purged message gone from origin');
+
+is(system('git', "--git-dir=$tmpdir/m/git/0.git", 'fetch', '-q'), 0,
+        'fetch successful');
+{
+        open my $err, '+>', "$tmpdir/index-err" or die "open: $!";
+        my $ipid = fork;
+        if ($ipid == 0) {
+                dup2(fileno($err), 2) or die "dup2 failed: $!";
+                exec("$script-index", '--prune', "$tmpdir/m");
+                die "exec fail: $!";
+        }
+        ok($ipid, 'running index..');
+        is(waitpid($ipid, 0), $ipid, 'index --prune done');
+        is($?, 0, 'no error from index');
+        ok(seek($err, 0, 0), 'rewound stderr');
+        $err = eval { local $/; <$err> };
+        like($err, qr/discontiguous range/, 'warned about discontiguous range');
+        unlike($err, qr/fatal/, 'no scary fatal error shown');
+}
+
+$mset = $mibx->search->reopen->query('m:10@example.com', {mset => 1});
+is(scalar($mset->items), 0, 'purged message not found in mirror');
+is_deeply([$mibx->mm->minmax], [$ibx->mm->minmax], 'minmax still synced');
+for my $i ((1..9),(11..15)) {
+        $mset = $mibx->search->query("m:$i\@example.com", {mset => 1});
+        is(scalar($mset->items), 1, "$i\@example.com remains visible");
+}
+is($mibx->git->check($to_purge), undef, 'unindex+prune successful in mirror');
+
+{
+        my @warn;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        $v2w->index_sync;
+        is_deeply(\@warn, [], 'no warnings from index_sync after purge');
+}
+
+$v2w->done;
+ok(kill('TERM', $pid), 'killed httpd');
+$pid = undef;
+waitpid(-1, 0);
+
+done_testing();
+
+1;
diff --git a/t/v2reindex.t b/t/v2reindex.t
new file mode 100644
index 00000000..9bc271fc
--- /dev/null
+++ b/t/v2reindex.t
@@ -0,0 +1,97 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::MIME;
+use PublicInbox::ContentId qw(content_digest);
+use File::Temp qw/tempdir/;
+use File::Path qw(remove_tree);
+
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for v2reindex.t" if $@;
+}
+use_ok 'PublicInbox::V2Writable';
+my $mainrepo = tempdir('pi-v2reindex-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => 'test-v2writable',
+        version => 2,
+        -primary_address => 'test@example.com',
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+local $ENV{NPROC} = 2;
+my $im = PublicInbox::V2Writable->new($ibx, 1);
+foreach my $i (1..10) {
+        $mime->header_set('Message-Id', "<$i\@example.com>");
+        ok($im->add($mime), "message $i added");
+        if ($i == 4) {
+                $im->remove($mime);
+        }
+}
+
+if ('test remove later') {
+        $mime->header_set('Message-Id', "<5\@example.com>");
+        $im->remove($mime);
+}
+
+$im->done;
+my $minmax = [ $ibx->mm->minmax ];
+ok(defined $minmax->[0] && defined $minmax->[1], 'minmax defined');
+
+eval { $im->index_sync({reindex => 1}) };
+is($@, '', 'no error from reindexing');
+$im->done;
+
+my $xap = "$mainrepo/xap".PublicInbox::Search::SCHEMA_VERSION();
+remove_tree($xap);
+ok(!-d $xap, 'Xapian directories removed');
+eval { $im->index_sync({reindex => 1}) };
+is($@, '', 'no error from reindexing');
+$im->done;
+ok(-d $xap, 'Xapian directories recreated');
+
+delete $ibx->{mm};
+is_deeply($minmax, [ $ibx->mm->minmax ], 'minmax unchanged');
+
+ok(unlink "$mainrepo/msgmap.sqlite3", 'remove msgmap');
+remove_tree($xap);
+ok(!-d $xap, 'Xapian directories removed again');
+{
+        my @warn;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        eval { $im->index_sync({reindex => 1}) };
+        is($@, '', 'no error from reindexing without msgmap');
+        is(scalar(@warn), 0, 'no warnings from reindexing');
+        $im->done;
+        ok(-d $xap, 'Xapian directories recreated');
+        delete $ibx->{mm};
+        is_deeply($minmax, [ $ibx->mm->minmax ], 'minmax unchanged');
+}
+
+ok(unlink "$mainrepo/msgmap.sqlite3", 'remove msgmap');
+remove_tree($xap);
+ok(!-d $xap, 'Xapian directories removed again');
+{
+        my @warn;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        eval { $im->index_sync({reindex => 1}) };
+        is($@, '', 'no error from reindexing without msgmap');
+        is_deeply(\@warn, [], 'no warnings');
+        $im->done;
+        ok(-d $xap, 'Xapian directories recreated');
+        delete $ibx->{mm};
+        is_deeply($minmax, [ $ibx->mm->minmax ], 'minmax unchanged');
+}
+
+done_testing();
diff --git a/t/v2writable.t b/t/v2writable.t
new file mode 100644
index 00000000..d37fb06e
--- /dev/null
+++ b/t/v2writable.t
@@ -0,0 +1,280 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use strict;
+use warnings;
+use Test::More;
+use PublicInbox::MIME;
+use PublicInbox::ContentId qw(content_digest);
+use File::Temp qw/tempdir/;
+foreach my $mod (qw(DBD::SQLite Search::Xapian)) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for nntpd.t" if $@;
+}
+use_ok 'PublicInbox::V2Writable';
+my $mainrepo = tempdir('pi-v2writable-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $ibx = {
+        mainrepo => $mainrepo,
+        name => 'test-v2writable',
+        version => 2,
+        -primary_address => 'test@example.com',
+};
+$ibx = PublicInbox::Inbox->new($ibx);
+my $mime = PublicInbox::MIME->create(
+        header => [
+                From => 'a@example.com',
+                To => 'test@example.com',
+                Subject => 'this is a subject',
+                'Message-ID' => '<a-mid@b>',
+                Date => 'Fri, 02 Oct 1993 00:00:00 +0000',
+        ],
+        body => "hello world\n",
+);
+
+my $im = eval {
+        local $ENV{NPROC} = '1';
+        PublicInbox::V2Writable->new($ibx, 1);
+};
+is($im->{partitions}, 1, 'one partition when forced');
+ok($im->add($mime), 'ordinary message added');
+my $git0;
+
+if ('ensure git configs are correct') {
+        my @cmd = (qw(git config), "--file=$mainrepo/all.git/config",
+                qw(core.sharedRepository 0644));
+        is(system(@cmd), 0, "set sharedRepository in all.git");
+        $git0 = PublicInbox::Git->new("$mainrepo/git/0.git");
+        chomp(my $v = $git0->qx(qw(config core.sharedRepository)));
+        is($v, '0644', 'child repo inherited core.sharedRepository');
+        chomp($v = $git0->qx(qw(config --bool repack.writeBitmaps)));
+        is($v, 'true', 'child repo inherited repack.writeBitmaps');
+}
+
+{
+        my @warn;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        is($im->add($mime), undef, 'obvious duplicate rejected');
+        is(scalar(@warn), 0, 'no warning about resent message');
+
+        @warn = ();
+        $mime->header_set('Message-Id', '<a-mid@b>', '<c@d>');
+        is($im->add($mime), undef, 'secondary MID ignored if first matches');
+        my $sec = PublicInbox::MIME->new($mime->as_string);
+        $sec->header_set('Date');
+        $sec->header_set('Message-Id', '<a-mid@b>', '<c@d>');
+        ok($im->add($sec), 'secondary MID used if data is different');
+        like(join(' ', @warn), qr/mismatched/, 'warned about mismatch');
+        like(join(' ', @warn), qr/alternative/, 'warned about alternative');
+        is_deeply([ '<a-mid@b>', '<c@d>' ],
+                [ $sec->header_obj->header_raw('Message-Id') ],
+                'no new Message-Id added');
+
+        my $sane_mid = qr/\A<[\w\-\.]+\@\w+>\z/;
+        @warn = ();
+        $mime->header_set('Message-Id', '<a-mid@b>');
+        $mime->body_set('different');
+        ok($im->add($mime), 'reused mid ok');
+        like(join(' ', @warn), qr/reused/, 'warned about reused MID');
+        my @mids = $mime->header_obj->header_raw('Message-Id');
+        is($mids[0], '<a-mid@b>', 'original mid not changed');
+        like($mids[1], $sane_mid, 'new MID added');
+        is(scalar(@mids), 2, 'only one new MID added');
+
+        @warn = ();
+        $mime->header_set('Message-Id', '<a-mid@b>');
+        $mime->body_set('this one needs a random mid');
+        my $hdr = $mime->header_obj;
+        my $gen = PublicInbox::Import::digest2mid(content_digest($mime), $hdr);
+        unlike($gen, qr![\+/=]!, 'no URL-unfriendly chars in Message-Id');
+        my $fake = PublicInbox::MIME->new($mime->as_string);
+        $fake->header_set('Message-Id', "<$gen>");
+        ok($im->add($fake), 'fake added easily');
+        is_deeply(\@warn, [], 'no warnings from a faker');
+        ok($im->add($mime), 'random MID made');
+        like(join(' ', @warn), qr/using random/, 'warned about using random');
+        @mids = $mime->header_obj->header_raw('Message-Id');
+        is($mids[0], '<a-mid@b>', 'original mid not changed');
+        like($mids[1], $sane_mid, 'new MID added');
+        is(scalar(@mids), 2, 'only one new MID added');
+
+        @warn = ();
+        $mime->header_set('Message-Id');
+        ok($im->add($mime), 'random MID made for MID free message');
+        @mids = $mime->header_obj->header_raw('Message-Id');
+        like($mids[0], $sane_mid, 'mid was generated');
+        is(scalar(@mids), 1, 'new generated');
+}
+
+{
+        $mime->header_set('Message-Id', '<abcde@1>', '<abcde@2>');
+        $mime->header_set('References', '<zz-mid@b>');
+        ok($im->add($mime), 'message with multiple Message-ID');
+        $im->done;
+        my $srch = $ibx->search;
+        my $mset1 = $srch->reopen->query('m:abcde@1', { mset => 1 });
+        is($mset1->size, 1, 'message found by first MID');
+        my $mset2 = $srch->reopen->query('m:abcde@2', { mset => 1 });
+        is($mset2->size, 1, 'message found by second MID');
+        is((($mset1->items)[0])->get_docid, (($mset2->items)[0])->get_docid,
+                'same document');
+}
+
+SKIP: {
+        use Fcntl qw(FD_CLOEXEC F_SETFD F_GETFD);
+        use Net::NNTP;
+        use IO::Socket;
+        use Socket qw(SO_KEEPALIVE IPPROTO_TCP TCP_NODELAY);
+        eval { require Danga::Socket };
+        skip "Danga::Socket missing $@", 2 if $@;
+        my $err = "$mainrepo/stderr.log";
+        my $out = "$mainrepo/stdout.log";
+        my %opts = (
+                LocalAddr => '127.0.0.1',
+                ReuseAddr => 1,
+                Proto => 'tcp',
+                Type => SOCK_STREAM,
+                Listen => 1024,
+        );
+        my $group = 'inbox.comp.test.v2writable';
+        my $pi_config = "$mainrepo/pi_config";
+        open my $fh, '>', $pi_config or die "open: $!\n";
+        print $fh <<EOF
+[publicinbox "test-v2writable"]
+        mainrepo = $mainrepo
+        version = 2
+        address = test\@example.com
+        newsgroup = $group
+EOF
+        ;
+        close $fh or die "close: $!\n";
+        my $sock = IO::Socket::INET->new(%opts);
+        ok($sock, 'sock created');
+        my $pid;
+        my $len;
+        END { kill 'TERM', $pid if defined $pid };
+        $! = 0;
+        my $fl = fcntl($sock, F_GETFD, 0);
+        ok(! $!, 'no error from fcntl(F_GETFD)');
+        is($fl, FD_CLOEXEC, 'cloexec set by default (Perl behavior)');
+        $pid = fork;
+        if ($pid == 0) {
+                use POSIX qw(dup2);
+                $ENV{PI_CONFIG} = $pi_config;
+                # pretend to be systemd
+                fcntl($sock, F_SETFD, $fl &= ~FD_CLOEXEC);
+                dup2(fileno($sock), 3) or die "dup2 failed: $!\n";
+                $ENV{LISTEN_PID} = $$;
+                $ENV{LISTEN_FDS} = 1;
+                my $nntpd = 'blib/script/public-inbox-nntpd';
+                exec $nntpd, "--stdout=$out", "--stderr=$err";
+                die "FAIL: $!\n";
+        }
+        ok(defined $pid, 'forked nntpd process successfully');
+        $! = 0;
+        fcntl($sock, F_SETFD, $fl |= FD_CLOEXEC);
+        ok(! $!, 'no error from fcntl(F_SETFD)');
+        my $host_port = $sock->sockhost . ':' . $sock->sockport;
+        my $n = Net::NNTP->new($host_port);
+        $n->group($group);
+        my $x = $n->xover('1-');
+        my %uniq;
+        foreach my $num (sort { $a <=> $b } keys %$x) {
+                my $mid = $x->{$num}->[3];
+                is($uniq{$mid}++, 0, "MID for $num is unique in XOVER");
+                is_deeply($n->xhdr('Message-ID', $num),
+                         { $num => $mid }, "XHDR lookup OK on num $num");
+                is_deeply($n->xhdr('Message-ID', $mid),
+                         { $mid => $mid }, "XHDR lookup OK on MID $num");
+        }
+        my %nn;
+        foreach my $mid (@{$n->newnews(0, $group)}) {
+                is($nn{$mid}++, 0, "MID is unique in NEWNEWS");
+        }
+        is_deeply([sort keys %nn], [sort keys %uniq]);
+
+        my %lg;
+        foreach my $num (@{$n->listgroup($group)}) {
+                is($lg{$num}++, 0, "num is unique in LISTGROUP");
+        }
+        is_deeply([sort keys %lg], [sort keys %$x],
+                'XOVER and LISTGROUPS return the same article numbers');
+
+        my $xref = $n->xhdr('Xref', '1-');
+        is_deeply([sort keys %lg], [sort keys %$xref], 'Xref range OK');
+
+        my $mids = $n->xhdr('Message-ID', '1-');
+        is_deeply([sort keys %lg], [sort keys %$xref], 'Message-ID range OK');
+
+        my $rover = $n->xrover('1-');
+        is_deeply([sort keys %lg], [sort keys %$rover], 'XROVER range OK');
+};
+{
+        local $ENV{NPROC} = 2;
+        my @before = $git0->qx(qw(log --pretty=oneline));
+        my $before = $git0->qx(qw(log --pretty=raw --raw -r --no-abbrev));
+        $im = PublicInbox::V2Writable->new($ibx, 1);
+        is($im->{partitions}, 1, 'detected single partition from previous');
+        my $smsg = $im->remove($mime, 'test removal');
+        $im->done;
+        my @after = $git0->qx(qw(log --pretty=oneline));
+        my $tip = shift @after;
+        like($tip, qr/\A[a-f0-9]+ test removal\n\z/s,
+                'commit message propagated to git');
+        is_deeply(\@after, \@before, 'only one commit written to git');
+        is($ibx->mm->num_for($smsg->mid), undef, 'no longer in Msgmap by mid');
+        my $num = $smsg->{num};
+        like($num, qr/\A\d+\z/, 'numeric number in return message');
+        is($ibx->mm->mid_for($num), undef, 'no longer in Msgmap by num');
+        my $srch = $ibx->search->reopen;
+        my $mset = $srch->query('m:'.$smsg->mid, { mset => 1});
+        is($mset->size, 0, 'no longer found in Xapian');
+        my @log1 = qw(log -1 --pretty=raw --raw -r --no-abbrev --no-renames);
+        is($srch->{over_ro}->get_art($num), undef,
+                'removal propagated to Over DB');
+
+        my $after = $git0->qx(@log1);
+        if ($after =~ m!( [a-f0-9]+ )A\td$!m) {
+                my $oid = $1;
+                ok(index($before, $oid) > 0, 'no new blob introduced');
+        } else {
+                fail('failed to extract blob from log output');
+        }
+        is($im->remove($mime, 'test removal'), undef,
+                'remove is idempotent');
+        $im->done;
+        is($git0->qx(@log1),
+                $after, 'no git history made with idempotent remove');
+        eval { $im->done };
+        ok(!$@, '->done is idempotent');
+}
+
+{
+        ok($im->add($mime), 'add message to be purged');
+        local $SIG{__WARN__} = sub {};
+        ok(my $cmts = $im->purge($mime), 'purged message');
+        like($cmts->[0], qr/\A[a-f0-9]{40}\z/, 'purge returned current commit');
+        $im->done;
+}
+
+{
+        my @warn;
+        my $x = 'x'x250;
+        my $y = 'y'x250;
+        local $SIG{__WARN__} = sub { push @warn, @_ };
+        $mime->header_set('Subject', 'long mid');
+        $mime->header_set('Message-ID', "<$x>");
+        ok($im->add($mime), 'add excessively long Message-ID');
+
+        $mime->header_set('Message-ID', "<$y>");
+        $mime->header_set('References', "<$x>");
+        ok($im->add($mime), 'add excessively long References');
+        $im->barrier;
+
+        my $msgs = $ibx->search->reopen->get_thread('x'x244);
+        is(2, scalar(@$msgs), 'got both messages');
+        is($msgs->[0]->{mid}, 'x'x244, 'stored truncated mid');
+        is($msgs->[1]->{references}, '<'.('x'x244).'>', 'stored truncated ref');
+        is($msgs->[1]->{mid}, 'y'x244, 'stored truncated mid(2)');
+        $im->done;
+}
+
+done_testing();
diff --git a/t/view.t b/t/view.t
index 22f5c7e4..8ae42256 100644
--- a/t/view.t
+++ b/t/view.t
@@ -16,6 +16,7 @@ my $ctx = {
                 base_url => sub { 'http://example.com/' },
                 cloneurl => sub {[]},
                 nntp_url => sub {[]},
+                max_git_part => sub { undef },
                 description => sub { '' }),
 };
 $ctx->{-inbox}->{-primary_address} = 'test@example.com';
diff --git a/t/watch_maildir.t b/t/watch_maildir.t
index 30e94c1e..7178f29e 100644
--- a/t/watch_maildir.t
+++ b/t/watch_maildir.t
@@ -31,7 +31,8 @@ Date: Sat, 18 Jun 2016 00:00:00 +0000
 something
 EOF
 PublicInbox::Emergency->new($maildir)->prepare(\$msg);
-ok(POSIX::mkfifo("$maildir/cur/fifo", 0777));
+ok(POSIX::mkfifo("$maildir/cur/fifo", 0777),
+        'create FIFO to ensure we do not get stuck on it :P');
 my $sem = PublicInbox::Emergency->new($spamdir); # create dirs
 
 my $config = PublicInbox::Config->new({
diff --git a/t/watch_maildir_v2.t b/t/watch_maildir_v2.t
new file mode 100644
index 00000000..a76e413f
--- /dev/null
+++ b/t/watch_maildir_v2.t
@@ -0,0 +1,125 @@
+# Copyright (C) 2018 all contributors <meta@public-inbox.org>
+# License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
+use Test::More;
+use File::Temp qw/tempdir/;
+use PublicInbox::MIME;
+use Cwd;
+use PublicInbox::Config;
+my @mods = qw(Filesys::Notify::Simple PublicInbox::V2Writable);
+foreach my $mod (@mods) {
+        eval "require $mod";
+        plan skip_all => "$mod missing for watch_maildir_v2.t" if $@;
+}
+
+my $tmpdir = tempdir('watch_maildir-v2-XXXXXX', TMPDIR => 1, CLEANUP => 1);
+my $mainrepo = "$tmpdir/v2";
+my $maildir = "$tmpdir/md";
+my $spamdir = "$tmpdir/spam";
+use_ok 'PublicInbox::WatchMaildir';
+use_ok 'PublicInbox::Emergency';
+my $cfgpfx = "publicinbox.test";
+my $addr = 'test-public@example.com';
+my @cmd = ('blib/script/public-inbox-init', '-V2', 'test', $mainrepo,
+        'http://example.com/v2list', $addr);
+local $ENV{PI_CONFIG} = "$tmpdir/pi_config";
+is(system(@cmd), 0, 'public-inbox init OK');
+
+my $msg = <<EOF;
+From: user\@example.com
+To: $addr
+Subject: spam
+Message-Id: <a\@b.com>
+Date: Sat, 18 Jun 2016 00:00:00 +0000
+
+something
+EOF
+PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+ok(POSIX::mkfifo("$maildir/cur/fifo", 0777),
+        'create FIFO to ensure we do not get stuck on it :P');
+my $sem = PublicInbox::Emergency->new($spamdir); # create dirs
+
+my $config = PublicInbox::Config->new({
+        "$cfgpfx.address" => $addr,
+        "$cfgpfx.mainrepo" => $mainrepo,
+        "$cfgpfx.watch" => "maildir:$maildir",
+        "$cfgpfx.filter" => 'PublicInbox::Filter::Vger',
+        "publicinboxlearn.watchspam" => "maildir:$spamdir",
+});
+my $ibx = $config->lookup_name('test');
+ok($ibx, 'found inbox by name');
+my $srch = $ibx->search;
+
+PublicInbox::WatchMaildir->new($config)->scan('full');
+my ($total, undef) = $srch->reopen->query('');
+is($total, 1, 'got one revision');
+
+# my $git = PublicInbox::Git->new("$mainrepo/git/0.git");
+# my @list = $git->qx(qw(rev-list refs/heads/master));
+# is(scalar @list, 1, 'one revision in rev-list');
+
+my $write_spam = sub {
+        is(scalar glob("$spamdir/new/*"), undef, 'no spam existing');
+        $sem->prepare(\$msg);
+        $sem->commit;
+        my @new = glob("$spamdir/new/*");
+        is(scalar @new, 1);
+        my @p = split(m!/+!, $new[0]);
+        ok(link($new[0], "$spamdir/cur/".$p[-1].":2,S"));
+        is(unlink($new[0]), 1);
+};
+$write_spam->();
+is(unlink(glob("$maildir/new/*")), 1, 'unlinked old spam');
+PublicInbox::WatchMaildir->new($config)->scan('full');
+is(($srch->reopen->query(''))[0], 0, 'deleted file');
+
+# check with scrubbing
+{
+        $msg .= qq(--
+To unsubscribe from this list: send the line "unsubscribe git" in
+the body of a message to majordomo\@vger.kernel.org
+More majordomo info at  http://vger.kernel.org/majordomo-info.html\n);
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        PublicInbox::WatchMaildir->new($config)->scan('full');
+        my ($nr, $msgs) = $srch->reopen->query('');
+        is($nr, 1, 'got one file back');
+        my $mref = $ibx->msg_by_smsg($msgs->[0]);
+        like($$mref, qr/something\n\z/s, 'message scrubbed on import');
+
+        is(unlink(glob("$maildir/new/*")), 1, 'unlinked spam');
+        $write_spam->();
+        PublicInbox::WatchMaildir->new($config)->scan('full');
+        ($nr, $msgs) = $srch->reopen->query('');
+        is($nr, 0, 'inbox is empty again');
+}
+
+{
+        my $fail_bin = getcwd()."/t/fail-bin";
+        ok(-x "$fail_bin/spamc", "mock spamc exists");
+        my $fail_path = "$fail_bin:$ENV{PATH}"; # for spamc ham mock
+        local $ENV{PATH} = $fail_path;
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        $config->{'publicinboxwatch.spamcheck'} = 'spamc';
+        {
+                local $SIG{__WARN__} = sub {}; # quiet spam check warning
+                PublicInbox::WatchMaildir->new($config)->scan('full');
+        }
+        ($nr, $msgs) = $srch->reopen->query('');
+        is($nr, 0, 'inbox is still empty');
+        is(unlink(glob("$maildir/new/*")), 1);
+}
+
+{
+        my $main_bin = getcwd()."/t/main-bin";
+        ok(-x "$main_bin/spamc", "mock spamc exists");
+        my $main_path = "$main_bin:$ENV{PATH}"; # for spamc ham mock
+        local $ENV{PATH} = $main_path;
+        PublicInbox::Emergency->new($maildir)->prepare(\$msg);
+        $config->{'publicinboxwatch.spamcheck'} = 'spamc';
+        PublicInbox::WatchMaildir->new($config)->scan('full');
+        ($nr, $msgs) = $srch->reopen->query('');
+        is($nr, 1, 'inbox has one mail after spamc OK-ed a message');
+        my $mref = $ibx->msg_by_smsg($msgs->[0]);
+        like($$mref, qr/something\n\z/s, 'message scrubbed on import');
+}
+
+done_testing;