[cloud functions] Refactor cloud functions to allow unit testing with mocks

This change factors out all the access to the Firestore database into a
FirestoreService class, which is replaced by a mock during unit testing.

It also groups related functions with shared state into classes.

Change-Id: I1ce40a91135a5ddb4befcd985c6f6ba87990ff4b
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/126740
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/functions/node/builder.dart b/functions/node/builder.dart
new file mode 100644
index 0000000..75e095c
--- /dev/null
+++ b/functions/node/builder.dart
@@ -0,0 +1,176 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+import 'package:node_http/node_http.dart' as http;
+// For testing, use instead: import 'package:http/http.dart' as http;
+// TODO(whesse): Inject http Client() through dependency injection.
+
+import 'firestore.dart';
+
+void info(Object message) {
+  print("Info: $message");
+}
+
+void error(Object message) {
+  print("Error: $message");
+}
+
+const prefix = ")]}'\n";
+
+bool isChangedResult(Map<String, dynamic> result) =>
+    result['changed'] && !result['flaky'] && !result['previous_flaky'];
+
+const months = const {
+  'Jan': '01',
+  'Feb': '02',
+  'Mar': '03',
+  'Apr': '04',
+  'May': '05',
+  'Jun': '06',
+  'Jul': '07',
+  'Aug': '08',
+  'Sep': '09',
+  'Oct': '10',
+  'Nov': '11',
+  'Dec': '12'
+};
+
+DateTime parseGitilesDateTime(String gitiles) {
+  final parts = gitiles.split(' ');
+  final year = parts[4];
+  final month = months[parts[1]];
+  final day = parts[2].padLeft(2, '0');
+  return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
+}
+
+/// A Builder holds information about a CI build, and can
+/// store the changed results from that build, using an injected
+/// Firestore() object.
+/// Tryjob builds are represented by a subclass Tryjob of this class.
+class Build {
+  final FirestoreService firestore;
+  final String commitHash;
+  final Map<String, dynamic> firstResult;
+  String builderName;
+  int buildNumber;
+  int startIndex;
+  int endIndex;
+
+  Statistics stats = Statistics();
+
+  Build(this.commitHash, this.firstResult, this.firestore)
+      : builderName = firstResult['builder_name'],
+        buildNumber = int.parse(firstResult['build_number']);
+
+  Future<void> process(List<Map<String, dynamic>> results) async {
+    final configurations =
+        results.map((change) => change['configuration'] as String).toSet();
+    await update(configurations);
+    await Future.forEach(
+        results.where(isChangedResult), (result) => storeChange(result));
+    stats.report();
+  }
+
+  Future<void> update(Iterable<String> configurations) async {
+    await storeBuildCommitsInfo();
+    await storeConfigurationsInfo(configurations);
+    await firestore.updateBuildInfo(builderName, buildNumber, endIndex);
+  }
+
+  /// Stores the commit info for the blamelist of result.
+  /// If the info is already there does nothing.
+  /// Saves the commit indices of the start and end of the blamelist.
+  Future<void> storeBuildCommitsInfo() async {
+    // Get indices of change.  Range includes startIndex and endIndex.
+    var commit = await firestore.getCommit(commitHash);
+    if (commit == null) {
+      await getMissingCommits();
+      commit = await firestore.getCommit(commitHash);
+      if (commit == null) {
+        error('Result received with unknown commit hash $commitHash');
+      }
+    }
+    endIndex = commit['index'];
+    // If this is a new builder, use the current commit as a trivial blamelist.
+    if (firstResult['previous_commit_hash'] == null) {
+      startIndex = endIndex;
+    } else {
+      commit = await firestore.getCommit(firstResult['previous_commit_hash']);
+      startIndex = commit['index'] + 1;
+    }
+  }
+
+  Future<void> getMissingCommits() async {
+    final lastCommit = await firestore.getLastCommit(commitHash);
+    final lastHash = lastCommit['id'];
+    final lastIndex = lastCommit['index'];
+
+    final logUrl = 'https://dart.googlesource.com/sdk/+log/';
+    final range = '$lastHash..master';
+    final parameters = ['format=JSON', 'topo-order', 'n=1000'];
+    final url = '$logUrl$range?${parameters.join('&')}';
+    final client = http.NodeClient();
+    // For testing, use http.Client();  TODO(whesse): Inject correct http.
+    final response = await client.get(url);
+    final protectedJson = response.body;
+    if (!protectedJson.startsWith(prefix))
+      throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
+    final commits = jsonDecode(protectedJson.substring(prefix.length))['log']
+        as List<dynamic>;
+    if (commits.isEmpty) {
+      info('Found no new commits between $lastHash and master');
+      return;
+    }
+    stats.commitsFetched = commits.length;
+    final first = commits.last as Map<String, dynamic>;
+    if (first['parents'].first != lastHash) {
+      error('First new commit ${first['parents'].first} is not'
+          ' a child of last known commit $lastHash when fetching new commits');
+      throw ('First new commit ${first['parents'].first} is not'
+          ' a child of last known commit $lastHash when fetching new commits');
+    }
+    if (!commits.any((commit) => commit['commit'] == commitHash)) {
+      info('Did not find commit $commitHash when fetching new commits');
+      return;
+    }
+    var index = lastIndex + 1;
+    for (Map<String, dynamic> commit in commits.reversed) {
+      await firestore.addCommit(commit['commit'], {
+        'author': commit['author']['email'],
+        'created': parseGitilesDateTime(commit['committer']['time']),
+        'index': index,
+        'title': commit['message'].split('\n').first
+      });
+      ++index;
+    }
+  }
+
+  Future<void> storeConfigurationsInfo(Iterable<String> configurations) async {
+    for (final configuration in configurations) {
+      await firestore.updateConfiguration(configuration, builderName);
+    }
+  }
+
+  Future<void> storeChange(Map<String, dynamic> change) async {
+    firestore.storeChange(change, startIndex, endIndex);
+    stats.changes++;
+  }
+}
+
+class Statistics {
+  int results = 0;
+  int changes = 0;
+  int newRecords = 0;
+  int modifiedRecords = 0;
+  int commitsFetched = 0;
+
+  void report() {
+    info("Number of changed results processed: $changes");
+    info("Number of results processed: $results");
+    info("Number of firestore records produced: $newRecords");
+    info("Number of firestore records modified: $modifiedRecords");
+    info("Number of commits fetched: $commitsFetched");
+  }
+}
diff --git a/functions/node/firestore.dart b/functions/node/firestore.dart
new file mode 100644
index 0000000..36c99fd
--- /dev/null
+++ b/functions/node/firestore.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// FirestoreService is implemented by FirestoreServiceImpl, for production
+/// use, and by FirestoreServiceMock, for testing.
+/// The implementation must be in a separate file, so that testing can
+/// run on Dart native, not just on the nodeJS platform.
+abstract class FirestoreService {
+  Future<bool> hasPatchset(String review, String patchset);
+
+  Future<Map<String, dynamic>> getCommit(String hash);
+
+  Future<Map<String, dynamic>> getLastCommit(String hash);
+
+  Future<void> addCommit(String id, Map<String, dynamic> data);
+
+  Future<void> updateConfiguration(String configuration, String builder);
+
+  Future<void> updateBuildInfo(String builder, int buildNumber, int index);
+
+  Future<void> storeChange(
+      Map<String, dynamic> change, int startIndex, int endIndex);
+
+  Future<void> storeTryChange(
+      Map<String, dynamic> change, int review, int patchset);
+
+  Future<void> storeReview(String review, Map<String, dynamic> data);
+  Future<void> storePatchset(
+      String review, String patchset, Map<String, dynamic> data);
+}
diff --git a/functions/node/firestore_impl.dart b/functions/node/firestore_impl.dart
new file mode 100644
index 0000000..2f8b457
--- /dev/null
+++ b/functions/node/firestore_impl.dart
@@ -0,0 +1,203 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:math' show max, min;
+import 'package:firebase_functions_interop/firebase_functions_interop.dart';
+
+import 'firestore.dart';
+
+void info(Object message) {
+  print("Info: $message");
+}
+
+void error(Object message) {
+  print("Error: $message");
+}
+
+// Cloud functions run the cloud function many times in the same isolate.
+// Use static initializer to run global initialization once.
+Firestore firestore = createFirestore();
+
+Firestore createFirestore() {
+  final app = FirebaseAdmin.instance.initializeApp();
+  return app.firestore()
+    ..settings(FirestoreSettings(timestampsInSnapshots: true));
+}
+
+class FirestoreServiceImpl implements FirestoreService {
+  Future<bool> hasPatchset(String review, String patchset) => firestore
+      .document('reviews/$review/patchsets/$patchset')
+      .get()
+      .then((s) => s.exists);
+
+  Future<Map<String, dynamic>> getCommit(String hash) => firestore
+      .document('commits/$hash')
+      .get()
+      .then((s) => s.exists ? s.data.toMap() : null);
+
+  Future<Map<String, dynamic>> getLastCommit(String hash) async {
+    QuerySnapshot lastCommit = await firestore
+        .collection('commits')
+        .orderBy('index', descending: true)
+        .limit(1)
+        .get();
+    final result = lastCommit.documents.first.data.toMap();
+    result['id'] = lastCommit.documents.first.documentID;
+    return result;
+  }
+
+  Future<void> addCommit(String id, Map<String, dynamic> data) async {
+    data['created'] = Timestamp.fromDateTime(data['created']);
+    final docRef = firestore.document('commits/$id');
+    await docRef.setData(DocumentData.fromMap(data));
+  }
+
+  Future<void> updateConfiguration(String configuration, String builder) async {
+    final record =
+        await firestore.document('configurations/$configuration').get();
+    if (!record.exists || record.data.getString('builder') != builder) {
+      await firestore
+          .document('configurations/$configuration')
+          .setData(DocumentData.fromMap({'builder': builder}));
+      if (!record.exists) {
+        info('Configuration document $configuration -> $builder created');
+      } else {
+        info('Configuration document changed: $configuration -> $builder '
+            '(was ${record.data.getString("builder")}');
+      }
+    }
+  }
+
+  Future<void> updateBuildInfo(
+      String builder, int buildNumber, int index) async {
+    final documentRef = firestore.document('builds/$builder:$index');
+    final record = await documentRef.get();
+    if (!record.exists) {
+      await documentRef.setData(DocumentData.fromMap(
+          {'builder': builder, 'build_number': buildNumber, 'index': index}));
+      info('Created build record: '
+          'builder: $builder, build_number: $buildNumber, index: $index');
+    } else if (record.data.getInt('index') != index) {
+      error(
+          'Build $buildNumber of $builder had commit index ${record.data.getInt('index')},'
+          'should be $index.');
+    }
+  }
+
+  Future<void> storeChange(
+      Map<String, dynamic> change, int startIndex, int endIndex) async {
+    String name = change['name'];
+    String result = change['result'];
+    String previousResult = change['previous_result'] ?? 'new test';
+    QuerySnapshot snapshot = await firestore
+        .collection('results')
+        .orderBy('blamelist_end_index', descending: true)
+        .where('name', isEqualTo: name)
+        .where('result', isEqualTo: result)
+        .where('previous_result', isEqualTo: previousResult)
+        .where('expected', isEqualTo: change['expected'])
+        .limit(5) // We will pick the right one, probably the latest.
+        .get();
+
+    // Find an existing change group with a blamelist that intersects this change.
+
+    bool blamelistIncludesChange(DocumentSnapshot groupDocument) {
+      var group = groupDocument.data;
+      var groupStart = group.getInt('blamelist_start_index');
+      var groupEnd = group.getInt('blamelist_end_index');
+      return startIndex <= groupEnd && endIndex >= groupStart;
+    }
+
+    DocumentSnapshot group = snapshot.documents
+        .firstWhere(blamelistIncludesChange, orElse: () => null);
+
+    if (group == null) {
+      info("Adding group for $name");
+      return firestore.collection('results').add(DocumentData.fromMap({
+            'name': name,
+            'result': result,
+            'previous_result': previousResult,
+            'expected': change['expected'],
+            'blamelist_start_index': startIndex,
+            'blamelist_end_index': endIndex,
+            'trivial_blamelist': startIndex == endIndex,
+            'configurations': <String>[change['configuration']]
+          }));
+    }
+
+    // Update the change group in a transaction.
+    // Add new configuration and narrow the blamelist.
+    Future<void> updateGroup(Transaction transaction) async {
+      final DocumentSnapshot groupSnapshot =
+          await transaction.get(group.reference);
+      final data = groupSnapshot.data;
+      final newStart = max(startIndex, data.getInt('blamelist_start_index'));
+      final newEnd = min(endIndex, data.getInt('blamelist_end_index'));
+      final updateMap = <String, dynamic>{
+        'blamelist_start_index':
+            max(startIndex, data.getInt('blamelist_start_index')),
+        'blamelist_end_index': min(endIndex, data.getInt('blamelist_end_index'))
+      };
+      updateMap['trivial_blamelist'] = (updateMap['blamelist_start_index'] ==
+          updateMap['blamelist_end_index']);
+      final update = UpdateData.fromMap({
+        'blamelist_start_index': newStart,
+        'blamelist_end_index': newEnd,
+        'trivial_blamelist': newStart == newEnd
+      });
+      update.setFieldValue('configurations',
+          Firestore.fieldValues.arrayUnion([change['configuration']]));
+      group.reference.updateData(update);
+    }
+
+    return firestore.runTransaction(updateGroup);
+  }
+
+  Future<void> storeTryChange(
+      Map<String, dynamic> change, int review, int patchset) async {
+    String name = change['name'];
+    String result = change['result'];
+    String expected = change['expected'];
+    String reviewPath = change['commit_hash'];
+    String previousResult = change['previous_result'] ?? 'new test';
+    QuerySnapshot snapshot = await firestore
+        .collection('try_results')
+        .where('review_path', isEqualTo: reviewPath)
+        .where('name', isEqualTo: name)
+        .where('result', isEqualTo: result)
+        .where('previous_result', isEqualTo: previousResult)
+        .where('expected', isEqualTo: expected)
+        .limit(1)
+        .get();
+
+    if (snapshot.isEmpty) {
+      info("Adding group for $name");
+
+      return firestore.collection('try_results').add(DocumentData.fromMap({
+            'name': name,
+            'result': result,
+            'previous_result': previousResult,
+            'expected': expected,
+            'review_path': reviewPath,
+            'review': review,
+            'patchset': patchset,
+            'configurations': <String>[change['configuration']]
+          }));
+    } else {
+      final update = UpdateData()
+        ..setFieldValue('configurations',
+            Firestore.fieldValues.arrayUnion([change['configuration']]));
+      snapshot.documents.first.reference.updateData(update);
+    }
+  }
+
+  Future<void> storeReview(String review, Map<String, dynamic> data) =>
+      firestore.document('reviews/$review').setData(DocumentData.fromMap(data));
+
+  Future<void> storePatchset(
+          String review, String patchset, Map<String, dynamic> data) =>
+      firestore
+          .document('reviews/$review/patchsets/$patchset')
+          .setData(DocumentData.fromMap(data));
+}
diff --git a/functions/node/gerrit_change.dart b/functions/node/gerrit_change.dart
index b061dec..5bb6de0 100644
--- a/functions/node/gerrit_change.dart
+++ b/functions/node/gerrit_change.dart
@@ -4,12 +4,12 @@
 
 import 'dart:convert';
 
-import 'package:firebase_functions_interop/firebase_functions_interop.dart';
 import 'package:node_http/node_http.dart' as http;
 
+import 'firestore.dart';
+
 class GerritInfo {
   static const gerritUrl = 'https://dart-review.googlesource.com/changes';
-  static final reviewRefRegExp = RegExp(r'refs/changes/(\d*)/(\d*)');
   static const trivialKinds = const {
     'TRIVIAL_REBASE',
     'NO_CHANGE',
@@ -17,23 +17,19 @@
   };
   static const prefix = ")]}'\n";
 
-  Firestore firestore;
+  FirestoreService firestore;
   String review;
   String patchset;
 
-  GerritInfo(String changeRef, this.firestore) {
-    final match = reviewRefRegExp.matchAsPrefix(changeRef);
-    review = match[1];
-    patchset = match[2];
+  GerritInfo(int review, int patchset, this.firestore) {
+    this.review = review.toString();
+    this.patchset = patchset.toString();
   }
 
 // Fetch the owner, changeId, message, and date of a Gerrit change.
   Future<void> update() async {
+    if (await firestore.hasPatchset(review, patchset)) return;
     final client = http.NodeClient();
-    var patchsetRef = firestore.document('reviews/$review/patchsets/$patchset');
-    DocumentSnapshot patchsetDoc = await patchsetRef.get();
-    if (patchsetDoc.exists) return;
-
     // Get the Gerrit change's commit from the Gerrit API.
     final url = '$gerritUrl/$review?o=ALL_REVISIONS&o=DETAILED_ACCOUNTS';
     final response = await client.get(url);
@@ -42,9 +38,7 @@
       throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
     final reviewInfo = jsonDecode(protectedJson.substring(prefix.length))
         as Map<String, dynamic>;
-    final reviewRef = firestore.document('reviews/$review');
-    await reviewRef
-        .setData(DocumentData.fromMap({'subject': reviewInfo['subject']}));
+    await firestore.storeReview(review, {'subject': reviewInfo['subject']});
 
     // Add the patchset information to the patchsets subcollection.
     final revisions = reviewInfo['revisions'].values.toList()
@@ -55,14 +49,12 @@
       if (!trivialKinds.contains(revision['kind'])) {
         patchsetGroupFirst = number;
       }
-      patchsetRef = firestore.document('reviews/$review/patchsets/$number');
-
-      await patchsetRef.setData(DocumentData.fromMap({
+      await firestore.storePatchset(review, number, {
         'number': number,
         'patchset_group': patchsetGroupFirst,
         'description': revision['description'],
         'kind': revision['kind']
-      }));
+      });
     }
   }
 }
diff --git a/functions/node/index.dart b/functions/node/index.dart
index a1ee928..4b7735d 100644
--- a/functions/node/index.dart
+++ b/functions/node/index.dart
@@ -2,334 +2,25 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
-import 'dart:convert';
-import 'dart:math' show max, min;
-
 import 'package:firebase_functions_interop/firebase_functions_interop.dart';
-import 'package:node_http/node_http.dart' as http;
 
-import 'gerrit_change.dart';
-
-const prefix = ")]}'\n";
-
-void info(Object message) {
-  print("Info: $message");
-}
-
-void error(Object message) {
-  print("Error: $message");
-}
+import 'builder.dart';
+import 'firestore_impl.dart';
+import 'tryjob.dart';
 
 void main() {
   functions['receiveChanges'] =
       functions.pubsub.topic('results').onPublish(receiveChanges);
 }
 
-// Cloud functions run the cloud function many times in the same isolate.
-// Use static initializer to run global initialization once.
-Firestore firestore = createFirestore();
-
-Firestore createFirestore() {
-  final app = FirebaseAdmin.instance.initializeApp();
-  return app.firestore()
-    ..settings(FirestoreSettings(timestampsInSnapshots: true));
-}
-
-bool isChangedResult(Map<String, dynamic> result) =>
-    result['changed'] && !result['flaky'] && !result['previous_flaky'];
-
 Future<void> receiveChanges(Message message, EventContext context) async {
-  final stats = Statistics();
   final results = (message.json as List).cast<Map<String, dynamic>>();
   final first = results.first;
   final String commit = first['commit_hash'];
-  final String builder = first['builder_name'];
-  final int buildNumber = int.parse(first['build_number']);
 
   if (commit.startsWith('refs/changes')) {
-    await GerritInfo(commit, firestore).update();
-    await Future.forEach(
-        results.where(isChangedResult), (result) => storeTryChange(result));
+    return Tryjob(commit, FirestoreServiceImpl()).process(results);
   } else {
-    var blamelist = await storeBuildCommitsInfo(first, stats);
-    final configurations =
-        results.map((change) => change['configuration'] as String).toSet();
-    await storeConfigurationsInfo(builder, configurations);
-    await storeBuildInfo(builder, buildNumber, blamelist['endIndex']);
-    await Future.forEach(results.where(isChangedResult),
-        (result) => storeChange(result, blamelist, stats));
+    return Build(commit, first, FirestoreServiceImpl()).process(results);
   }
-  stats.report();
-}
-
-class Statistics {
-  int results = 0;
-  int changes = 0;
-  int newRecords = 0;
-  int modifiedRecords = 0;
-  int commitsFetched = 0;
-  String builder;
-  int buildNumber;
-
-  void report() {
-    info("Number of changed results processed: $changes");
-    info("Number of results processed: $results");
-    info("Number of firestore records produced: $newRecords");
-    info("Number of firestore records modified: $modifiedRecords");
-    info("Number of commits fetched: $commitsFetched");
-  }
-}
-
-/// Stores the commit info for the blamelist of result.
-/// If the info is already there does nothing.
-/// Returns the commit indices of the start and end of the blamelist.
-Future<Map<String, int>> storeBuildCommitsInfo(
-    Map<String, dynamic> result, Statistics stats) async {
-  // Get indices of change.  Range includes startIndex and endIndex.
-  final hash = result['commit_hash'] as String;
-  final docRef = await firestore.document('commits/$hash');
-  var docSnapshot = await docRef.get();
-  if (!docSnapshot.exists) {
-    await getMissingCommits(hash, stats);
-    docSnapshot = await docRef.get();
-    if (!docSnapshot.exists) {
-      error('Result received with unknown commit hash $hash');
-    }
-  }
-  final endIndex = docSnapshot.data.getInt('index');
-  // If this is a new builder, use the current commit as a trivial blamelist.
-  var startIndex = endIndex;
-  if (result['previous_commit_hash'] != null) {
-    final commit = await firestore
-        .document('commits/${result['previous_commit_hash']}')
-        .get();
-    startIndex = commit.data.getInt('index') + 1;
-  }
-  return {"startIndex": startIndex, "endIndex": endIndex};
-}
-
-Future<void> storeConfigurationsInfo(
-    String builder, Iterable<String> configurations) async {
-  for (final configuration in configurations) {
-    final record =
-        await firestore.document('configurations/$configuration').get();
-    if (!record.exists || record.data.getString('builder') != builder) {
-      await firestore
-          .document('configurations/$configuration')
-          .setData(DocumentData.fromMap({'builder': builder}));
-      if (!record.exists) {
-        info('Configuration document $configuration -> $builder created');
-      } else {
-        info('Configuration document changed: $configuration -> $builder '
-            '(was ${record.data.getString("builder")}');
-      }
-    }
-  }
-}
-
-Future<void> storeBuildInfo(
-    String builder, int buildNumber, int commitIndex) async {
-  final documentRef = firestore.document('builds/$builder:$commitIndex');
-  final record = await documentRef.get();
-  if (!record.exists) {
-    await documentRef.setData(DocumentData.fromMap({
-      'builder': builder,
-      'build_number': buildNumber,
-      'index': commitIndex
-    }));
-    info('Created build record: '
-        'builder: $builder, build_number: $buildNumber, index: $commitIndex');
-  } else if (record.data.getInt('index') != commitIndex) {
-    error(
-        'Build $buildNumber of $builder had commit index ${record.data.getInt('index')},'
-        'should be $commitIndex.');
-  }
-}
-
-Future<void> storeChange(Map<String, dynamic> change,
-    Map<String, int> buildInfo, Statistics stats) async {
-  stats.changes++;
-  String name = change['name'];
-  String result = change['result'];
-  String previousResult = change['previous_result'] ?? 'new test';
-  QuerySnapshot snapshot = await firestore
-      .collection('results')
-      .orderBy('blamelist_end_index', descending: true)
-      .where('name', isEqualTo: name)
-      .where('result', isEqualTo: result)
-      .where('previous_result', isEqualTo: previousResult)
-      .where('expected', isEqualTo: change['expected'])
-      .limit(5) // We will pick the right one, probably the latest.
-      .get();
-
-  // Find an existing change group with a blamelist that intersects this change.
-  final startIndex = buildInfo['startIndex'];
-  final endIndex = buildInfo['endIndex'];
-
-  bool blamelistIncludesChange(DocumentSnapshot groupDocument) {
-    var group = groupDocument.data;
-    var groupStart = group.getInt('blamelist_start_index');
-    var groupEnd = group.getInt('blamelist_end_index');
-    return startIndex <= groupEnd && endIndex >= groupStart;
-  }
-
-  DocumentSnapshot group = snapshot.documents
-      .firstWhere(blamelistIncludesChange, orElse: () => null);
-
-  if (group == null) {
-    info("Adding group for $name");
-    return firestore.collection('results').add(DocumentData.fromMap({
-          'name': name,
-          'result': result,
-          'previous_result': previousResult,
-          'expected': change['expected'],
-          'blamelist_start_index': startIndex,
-          'blamelist_end_index': endIndex,
-          'trivial_blamelist': startIndex == endIndex,
-          'configurations': <String>[change['configuration']]
-        }));
-  }
-
-  // Update the change group in a transaction.
-  // Add new configuration and narrow the blamelist.
-  Future<void> updateGroup(Transaction transaction) async {
-    final DocumentSnapshot groupSnapshot =
-        await transaction.get(group.reference);
-    final data = groupSnapshot.data;
-    final newStart = max(startIndex, data.getInt('blamelist_start_index'));
-    final newEnd = min(endIndex, data.getInt('blamelist_end_index'));
-    final updateMap = <String, dynamic>{
-      'blamelist_start_index':
-          max(startIndex, data.getInt('blamelist_start_index')),
-      'blamelist_end_index': min(endIndex, data.getInt('blamelist_end_index'))
-    };
-    updateMap['trivial_blamelist'] = (updateMap['blamelist_start_index'] ==
-        updateMap['blamelist_end_index']);
-    final update = UpdateData.fromMap({
-      'blamelist_start_index': newStart,
-      'blamelist_end_index': newEnd,
-      'trivial_blamelist': newStart == newEnd
-    });
-    update.setFieldValue('configurations',
-        Firestore.fieldValues.arrayUnion([change['configuration']]));
-    group.reference.updateData(update);
-  }
-
-  return firestore.runTransaction(updateGroup);
-}
-
-Future<void> storeTryChange(Map<String, dynamic> change) async {
-  String name = change['name'];
-  String result = change['result'];
-  String expected = change['expected'];
-  String reviewPath = change['commit_hash'];
-  String previousResult = change['previous_result'] ?? 'new test';
-  QuerySnapshot snapshot = await firestore
-      .collection('try_results')
-      .where('review_path', isEqualTo: reviewPath)
-      .where('name', isEqualTo: name)
-      .where('result', isEqualTo: result)
-      .where('previous_result', isEqualTo: previousResult)
-      .where('expected', isEqualTo: expected)
-      .limit(1)
-      .get();
-
-  if (snapshot.isEmpty) {
-    info("Adding group for $name");
-
-    final reviewInfo = GerritInfo(reviewPath, firestore);
-    int review = int.parse(reviewInfo.review);
-    int patchset = int.parse(reviewInfo.patchset);
-
-    return firestore.collection('try_results').add(DocumentData.fromMap({
-          'name': name,
-          'result': result,
-          'previous_result': previousResult,
-          'expected': expected,
-          'review_path': reviewPath,
-          'review': review,
-          'patchset': patchset,
-          'configurations': <String>[change['configuration']]
-        }));
-  } else {
-    final update = UpdateData()
-      ..setFieldValue('configurations',
-          Firestore.fieldValues.arrayUnion([change['configuration']]));
-    snapshot.documents.first.reference.updateData(update);
-  }
-}
-
-Future<void> getMissingCommits(String hash, Statistics stats) async {
-  final client = http.NodeClient();
-  final QuerySnapshot lastCommit = await firestore
-      .collection('commits')
-      .orderBy('index', descending: true)
-      .limit(1)
-      .get();
-  final lastHash = lastCommit.documents.first.documentID;
-  final lastIndex = lastCommit.documents.first.data.getInt('index');
-
-  final logUrl = 'https://dart.googlesource.com/sdk/+log/';
-  final range = '$lastHash..master';
-  final parameters = ['format=JSON', 'topo-order', 'n=1000'];
-  final url = '$logUrl$range?${parameters.join('&')}';
-  final response = await client.get(url);
-  final protectedJson = response.body;
-  if (!protectedJson.startsWith(prefix))
-    throw Exception('Gerrit response missing prefix $prefix: $protectedJson');
-  final commits = jsonDecode(protectedJson.substring(prefix.length))['log']
-      as List<dynamic>;
-  if (commits.isEmpty) {
-    info('Found no new commits between $lastHash and master');
-    return;
-  }
-  stats.commitsFetched = commits.length;
-  final first = commits.last as Map<String, dynamic>;
-  if (first['parents'].first != lastHash) {
-    error('First new commit ${first['parents'].first} is not'
-        ' a child of last known commit $lastHash when fetching new commits');
-    throw ('First new commit ${first['parents'].first} is not'
-        ' a child of last known commit $lastHash when fetching new commits');
-  }
-  if (!commits.any((commit) => commit['commit'] == hash)) {
-    info('Did not find commit $hash when fetching new commits');
-    return;
-  }
-  var index = lastIndex + 1;
-  for (Map<String, dynamic> commit in commits.reversed) {
-    // Create new commit document for this commit.
-    final docRef = firestore.document('commits/${commit['commit']}');
-    final data = DocumentData.fromMap({
-      'author': commit['author']['email'],
-      'created': Timestamp.fromDateTime(
-          parseGitilesDateTime(commit['committer']['time'])),
-      'index': index,
-      'title': commit['message'].split('\n').first
-    });
-    await docRef.setData(data);
-    ++index;
-  }
-}
-
-const months = const {
-  'Jan': '01',
-  'Feb': '02',
-  'Mar': '03',
-  'Apr': '04',
-  'May': '05',
-  'Jun': '06',
-  'Jul': '07',
-  'Aug': '08',
-  'Sep': '09',
-  'Oct': '10',
-  'Nov': '11',
-  'Dec': '12'
-};
-
-DateTime parseGitilesDateTime(String gitiles) {
-  final parts = gitiles.split(' ');
-  final year = parts[4];
-  final month = months[parts[1]];
-  final day = parts[2].padLeft(2, '0');
-  return DateTime.parse('$year-$month-$day ${parts[3]} ${parts[5]}');
 }
diff --git a/functions/node/test/firestore_mock.dart b/functions/node/test/firestore_mock.dart
new file mode 100644
index 0000000..1bbd969
--- /dev/null
+++ b/functions/node/test/firestore_mock.dart
@@ -0,0 +1,17 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:mockito/mockito.dart';
+
+import '../firestore.dart';
+import 'test_data.dart';
+
+class FirestoreServiceMock extends Mock implements FirestoreService {
+  FirestoreServiceMock() {
+    when(this.getCommit(existingCommitHash))
+        .thenAnswer((_) => Future.value(existingCommit));
+    when(this.getCommit(previousCommitHash))
+        .thenAnswer((_) => Future.value(previousCommit));
+  }
+}
diff --git a/functions/node/test/test.dart b/functions/node/test/test.dart
index 8c99f6d..85a83fe 100644
--- a/functions/node/test/test.dart
+++ b/functions/node/test/test.dart
@@ -4,68 +4,26 @@
 
 import 'dart:convert';
 
-import '../index.dart';
-import '../gerrit_change.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
 
-String resultJson = '''
-{
-"name":"dart2js_extra/local_function_signatures_strong_test/none",
-"configuration":"dart2js-new-rti-linux-x64-d8",
-"suite":"dart2js_extra",
-"test_name":"local_function_signatures_strong_test/none",
-"time_ms":2384,
-"result":"Pass",
-"expected":"Pass",
-"matches":true,
-"bot_name":"luci-dart-try-xenial-70-8fkh",
-"commit_hash":"9cd47ac2e6ac024e5a0fd1d5667d94a8c2fd2d5e",
-"commit_time":1563576771,
-"build_number":"307",
-"builder_name":"dart2js-rti-linux-x64-d8",
-"flaky":false,
-"previous_flaky":false,
-"previous_result":"RuntimeError",
-"previous_commit_hash":"ebc180be95efd89b8745ddffcedf970af6e36dc1",
-"previous_commit_time":1563576211,
-"previous_build_number":"306",
-"changed":true
-}
-''';
-
-String tryResultJson = '''
-{
-"name":"dart2js_extra/local_function_signatures_strong_test/none",
-"configuration":"dart2js-new-rti-linux-x64-d8",
-"suite":"dart2js_extra",
-"test_name":"local_function_signatures_strong_test/none",
-"time_ms":2384,
-"result":"Pass",
-"expected":"Pass",
-"matches":true,
-"bot_name":"luci-dart-try-xenial-70-8fkh",
-"commit_hash":"refs/changes/12345/2",
-"commit_time":1563576771,
-"build_number":"307",
-"builder_name":"dart2js-rti-linux-x64-d8",
-"flaky":false,
-"previous_flaky":false,
-"previous_result":"RuntimeError",
-"previous_commit_hash":"ebc180be95efd89b8745ddffcedf970af6e36dc1",
-"previous_commit_time":1563576211,
-"previous_build_number":"306",
-"changed":true
-}
-''';
+import '../builder.dart';
+import 'firestore_mock.dart';
+import 'test_data.dart';
 
 void main() async {
-  print("starting test");
-  Map<String, dynamic> result = jsonDecode(resultJson);
-  final stats = Statistics();
-  final info = await storeBuildCommitsInfo(result, stats);
-  print("info: $info");
-  stats.report();
-  await storeChange(result, info, stats);
-  stats.report();
-  await storeTryChange(jsonDecode(tryResultJson) as Map<String, dynamic>);
-  await GerritInfo('refs/changes/119860/5', firestore).update();
+  test("Store already existing commit", () async {
+    final firestore = FirestoreServiceMock();
+    final change = alreadyExistingCommitChange;
+    final hash = change['commit_hash'];
+
+    final builder = Build(existingCommitHash, change, firestore);
+    await builder.storeBuildCommitsInfo();
+    expect(builder.endIndex, existingCommit['index']);
+    expect(builder.startIndex, previousCommit['index'] + 1);
+    verifyInOrder([
+      firestore.getCommit(existingCommitHash),
+      firestore.getCommit(previousCommitHash)
+    ]);
+  });
 }
diff --git a/functions/node/test/test_data.dart b/functions/node/test/test_data.dart
new file mode 100644
index 0000000..140ce6f
--- /dev/null
+++ b/functions/node/test/test_data.dart
@@ -0,0 +1,45 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:core';
+
+const Map<String, dynamic> alreadyExistingCommitChange = {
+  "name": "dart2js_extra/local_function_signatures_strong_test/none",
+  "configuration": "dart2js-new-rti-linux-x64-d8",
+  "suite": "dart2js_extra",
+  "test_name": "local_function_signatures_strong_test/none",
+  "time_ms": 2384,
+  "result": "Pass",
+  "expected": "Pass",
+  "matches": true,
+  "bot_name": "luci-dart-try-xenial-70-8fkh",
+  "commit_hash": existingCommitHash,
+  "commit_time": 1563576771,
+  "build_number": "307",
+  "builder_name": "dart2js-rti-linux-x64-d8",
+  "flaky": false,
+  "previous_flaky": false,
+  "previous_result": "RuntimeError",
+  "previous_commit_hash": previousCommitHash,
+  "previous_commit_time": 1563576211,
+  "previous_build_number": "306",
+  "changed": true
+};
+
+const String existingCommitHash = 'an already existing commit hash';
+
+Map<String, dynamic> existingCommit = {
+  'author': 'test_user@example.com',
+  'created': DateTime.parse('2019-11-22 22:19:00Z'),
+  'index': 52,
+  'title': 'A commit used for testing, with index 52'
+};
+
+const String previousCommitHash = 'a previous existing commit hash';
+Map<String, dynamic> previousCommit = {
+  'author': 'previous_user@example.com',
+  'created': DateTime.parse('2019-11-24 11:18:00Z'),
+  'index': 49,
+  'title': 'A commit used for testing, with index 49'
+};
diff --git a/functions/node/tryjob.dart b/functions/node/tryjob.dart
new file mode 100644
index 0000000..7158d7f
--- /dev/null
+++ b/functions/node/tryjob.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2019, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'firestore.dart';
+import 'gerrit_change.dart';
+
+bool isChangedResult(Map<String, dynamic> result) =>
+    result['changed'] && !result['flaky'] && !result['previous_flaky'];
+
+class Tryjob {
+  static final changeRefRegExp = RegExp(r'refs/changes/(\d*)/(\d*)');
+  FirestoreService firestore;
+  int review;
+  int patchset;
+
+  Tryjob(String changeRef, this.firestore) {
+    final match = changeRefRegExp.matchAsPrefix(changeRef);
+    review = int.parse(match[1]);
+    patchset = int.parse(match[2]);
+  }
+
+  Future<void> update() {
+    return GerritInfo(review, patchset, firestore).update();
+  }
+
+  Future<void> process(List<Map<String, dynamic>> results) async {
+    await update();
+    await Future.forEach(results.where(isChangedResult),
+        (change) => firestore.storeTryChange(change, review, patchset));
+  }
+}
diff --git a/functions/pubspec.lock b/functions/pubspec.lock
index a99f498..5cb1a22 100644
--- a/functions/pubspec.lock
+++ b/functions/pubspec.lock
@@ -29,6 +29,13 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.1.22"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.5"
   build:
     dependency: transitive
     description:
@@ -134,6 +141,13 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.1"
+  coverage:
+    dependency: transitive
+    description:
+      name: coverage
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.13.3+1"
   crypto:
     dependency: transitive
     description:
@@ -266,7 +280,7 @@
       name: matcher
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "0.12.5"
+    version: "0.12.6"
   meta:
     dependency: transitive
     description:
@@ -281,6 +295,20 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.9.6+3"
+  mockito:
+    dependency: "direct dev"
+    description:
+      name: mockito
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.1.1"
+  multi_server_socket:
+    dependency: transitive
+    description:
+      name: multi_server_socket
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.2"
   node_http:
     dependency: transitive
     description:
@@ -393,6 +421,20 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.7.5"
+  shelf_packages_handler:
+    dependency: transitive
+    description:
+      name: shelf_packages_handler
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  shelf_static:
+    dependency: transitive
+    description:
+      name: shelf_static
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.8"
   shelf_web_socket:
     dependency: transitive
     description:
@@ -400,6 +442,20 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "0.2.3"
+  source_map_stack_trace:
+    dependency: transitive
+    description:
+      name: source_map_stack_trace
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.5"
+  source_maps:
+    dependency: transitive
+    description:
+      name: source_maps
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.10.8"
   source_span:
     dependency: transitive
     description:
@@ -442,6 +498,27 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.1.0"
+  test:
+    dependency: "direct dev"
+    description:
+      name: test
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.9.4"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.11"
+  test_core:
+    dependency: transitive
+    description:
+      name: test_core
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.15"
   timing:
     dependency: transitive
     description:
@@ -456,6 +533,13 @@
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.1.6"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.2"
   watcher:
     dependency: transitive
     description:
diff --git a/functions/pubspec.yaml b/functions/pubspec.yaml
index 090ab02..defd5b8 100644
--- a/functions/pubspec.yaml
+++ b/functions/pubspec.yaml
@@ -13,4 +13,5 @@
 dev_dependencies:
   build_runner: ^1.7.1
   build_node_compilers: ^0.2.3
-
+  mockito: ^4.1.0
+  test: ^1.9.4