Add tests for dash

Time-traveling test-driven-development.

--
MOS_MIGRATED_REVID=98399070
diff --git a/WORKSPACE b/WORKSPACE
index 4cd9877..33a42b6 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -36,3 +36,8 @@
     name = "javax/servlet/api",
     actual = "//tools/build_rules/appengine:javax.servlet.api",
 )
+
+maven_jar(
+    name = "easymock",
+    artifact = "org.easymock:easymock:3.1",
+)
diff --git a/src/tools/dash/README.md b/src/tools/dash/README.md
deleted file mode 100644
index c68de45..0000000
--- a/src/tools/dash/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# A Dashboard for Bazel
-
-This is a self-hosted dashboard for Bazel. In particular, this runs a server
-that turns build results and logs into webpages.
-
-## Running the server
-
-Build and run the server:
-
-```bash
-$ bazel build //src/tools/dash:dash
-$ bazel-bin/src/tools/dash
-```
-
-Once you see the log message `INFO: Dev App Server is now running`, you
-can visit [http://localhost:8080] to see the main page (which should say "No
-builds, yet!").
-
-This builds a .war file that can be deployed to AppEngine (although this
-doc assumes you'll run it locally).
-
-_Note: as of this writing, there is no authentication, rate limiting, or other
-protection for the dashboard. Anyone who can access the URL can read and write
-data to it. You may want to specify the `--address` or `--host` option
-(depending on AppEngine SDK version) when you run `dash` to bind the server to
-an internal network address._
-
-## Configuring Bazel to write results to the dashboard
-
-You will need to tell Bazel where to send build results. Run `bazel` with the
-`--use_dash` and `--dash_url=http://localhost:8080` flags, for
-example:
-
-```bash
-$ bazel build --use_dash --dash_url=http://localhost:8080 //foo:bar
-```
-
-If you don't want to have to specify the flags for every build and test, add
-the following lines to your .bazelrc (either in your home directory,
-_~/.bazelrc_, or on a per-project basis):
-
-```
-build --use_dash
-build --dash_url=http://localhost:8080
-```
-
-Then build results will be sent to the dashboard by default.  You can specify
-`--use_dash=false` for a particular build if you don't want it sent.
-
-Please email the
-[mailing list](https://groups.google.com/forum/#!forum/bazel-discuss)
-with any questions or concerns.
diff --git a/src/tools/dash/src/main/java/BUILD b/src/tools/dash/src/main/java/BUILD
index 285b686..7c317d1 100644
--- a/src/tools/dash/src/main/java/BUILD
+++ b/src/tools/dash/src/main/java/BUILD
@@ -1,8 +1,14 @@
 java_binary(
     name = "java-bin",
-    srcs = glob(["**/*.java"]),
     main_class = "does.not.exist",
     visibility = ["//src/tools/dash:__pkg__"],
+    deps = [":servlets"],
+)
+
+java_library(
+    name = "servlets",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//src/tools/dash/src/test/java/com/google/devtools/dash:__pkg__"],
     deps = [
         "@appengine-java//:api",
         "//external:javax/servlet/api",
diff --git a/src/tools/dash/src/main/java/com/google/devtools/dash/BuildViewServlet.java b/src/tools/dash/src/main/java/com/google/devtools/dash/BuildViewServlet.java
index e7faf2c..c10193b 100644
--- a/src/tools/dash/src/main/java/com/google/devtools/dash/BuildViewServlet.java
+++ b/src/tools/dash/src/main/java/com/google/devtools/dash/BuildViewServlet.java
@@ -18,8 +18,6 @@
 import com.google.appengine.api.datastore.DatastoreService;
 import com.google.appengine.api.datastore.DatastoreServiceFactory;
 import com.google.appengine.api.datastore.Entity;
-import com.google.appengine.api.datastore.Key;
-import com.google.appengine.api.datastore.KeyFactory;
 import com.google.appengine.api.datastore.PreparedQuery;
 import com.google.appengine.api.datastore.Query;
 import com.google.appengine.api.datastore.Query.FilterOperator;
@@ -42,8 +40,6 @@
  * Handles HTTP gets of builds/tests.
  */
 public class BuildViewServlet extends HttpServlet {
-  private static final String BUILD_ID =
-      "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
   private DatastoreService datastore;
 
   public BuildViewServlet() {
@@ -56,13 +52,12 @@
     DashRequest request;
     try {
       request = new DashRequest(req);
-    } catch (IllegalArgumentException e) {
+    } catch (DashRequest.DashRequestException e) {
       // TODO(kchodorow): make an error page.
       response.setContentType("text/html");
       response.getWriter().println("Error: " + HtmlEscapers.htmlEscaper().escape(e.getMessage()));
       return;
     }
-    Key buildKey = KeyFactory.createKey(DashRequest.KEY_KIND, request.getBuildId());
 
     BuildData.Builder data = BuildData.newBuilder();
     Query query = new Query(DashRequest.KEY_KIND).setFilter(new FilterPredicate(
diff --git a/src/tools/dash/src/main/java/com/google/devtools/dash/DashRequest.java b/src/tools/dash/src/main/java/com/google/devtools/dash/DashRequest.java
index c4af2ff..7eab485 100644
--- a/src/tools/dash/src/main/java/com/google/devtools/dash/DashRequest.java
+++ b/src/tools/dash/src/main/java/com/google/devtools/dash/DashRequest.java
@@ -42,20 +42,20 @@
   private final String buildId;
   private final Blob blob;
 
-  DashRequest(HttpServletRequest request) {
+  DashRequest(HttpServletRequest request) throws DashRequestException {
     Matcher matcher = URI_REGEX.matcher(request.getRequestURI());
     if (matcher.find()) {
-      pageName = matcher.group(0);
-      buildId = matcher.group(1);
+      pageName = matcher.group(1);
+      buildId = matcher.group(2);
     } else {
-      throw new IllegalArgumentException("Invalid URI pattern: " + request.getRequestURI());
+      throw new DashRequestException("Invalid URI pattern: " + request.getRequestURI());
     }
     try {
       // Requests are capped at 32MB (see
       // https://cloud.google.com/appengine/docs/quotas?csw=1#Requests).
       blob = new Blob(ByteStreams.toByteArray(request.getInputStream()));
     } catch (IOException e) {
-      throw new IllegalArgumentException("Could not read request body: " + e.getMessage());
+      throw new DashRequestException("Could not read request body: " + e.getMessage());
     }
   }
 
@@ -70,4 +70,10 @@
     entity.setProperty(BUILD_DATA, blob);
     return entity;
   }
+
+  static class DashRequestException extends Exception {
+    public DashRequestException(String message) {
+      super(message);
+    }
+  }
 }
diff --git a/src/tools/dash/src/main/java/com/google/devtools/dash/StoreServlet.java b/src/tools/dash/src/main/java/com/google/devtools/dash/StoreServlet.java
index 2cb9fce..05a0efc 100644
--- a/src/tools/dash/src/main/java/com/google/devtools/dash/StoreServlet.java
+++ b/src/tools/dash/src/main/java/com/google/devtools/dash/StoreServlet.java
@@ -39,7 +39,7 @@
     DashRequest request;
     try {
       request = new DashRequest(req);
-    } catch (IllegalArgumentException e) {
+    } catch (DashRequest.DashRequestException e) {
       response.setContentType("text/json");
       response.getWriter().println(
           "{ \"error\": \"" + e.getMessage().replaceAll("\"", "") + "\" }");
diff --git a/src/tools/dash/src/main/webapp/WEB-INF/appengine-web.xml b/src/tools/dash/src/main/webapp/WEB-INF/appengine-web.xml
deleted file mode 100644
index 7118a0e..0000000
--- a/src/tools/dash/src/main/webapp/WEB-INF/appengine-web.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
-    <application>dash-of-bazel</application>
-    <version>1</version>
-    <threadsafe>true</threadsafe>
-</appengine-web-app>
diff --git a/src/tools/dash/src/main/webapp/WEB-INF/web.xml b/src/tools/dash/src/main/webapp/WEB-INF/web.xml
deleted file mode 100644
index a4b6daf..0000000
--- a/src/tools/dash/src/main/webapp/WEB-INF/web.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE web-app PUBLIC
-"-//Oracle Corporation//DTD Web Application 2.3//EN"
-"http://java.sun.com/dtd/web-app_2_3.dtd">
-
-<web-app xmlns="http://java.sun.com/xml/ns/javaee" version="2.5">
-  <servlet>
-    <servlet-name>build</servlet-name>
-    <servlet-class>com.google.devtools.dash.BuildViewServlet</servlet-class>
-  </servlet>
-  <servlet>
-    <servlet-name>store</servlet-name>
-    <servlet-class>com.google.devtools.dash.StoreServlet</servlet-class>
-  </servlet>
-
-  <servlet-mapping>
-    <servlet-name>build</servlet-name>
-    <url-pattern>/result/*</url-pattern>
-  </servlet-mapping>
-  <servlet-mapping>
-    <servlet-name>store</servlet-name>
-    <url-pattern>/start/*</url-pattern>
-  </servlet-mapping>
-  <servlet-mapping>
-    <servlet-name>store</servlet-name>
-    <url-pattern>/options/*</url-pattern>
-  </servlet-mapping>
-  <servlet-mapping>
-    <servlet-name>store</servlet-name>
-    <url-pattern>/targets/*</url-pattern>
-  </servlet-mapping>
-  <servlet-mapping>
-    <servlet-name>store</servlet-name>
-    <url-pattern>/test/*</url-pattern>
-  </servlet-mapping>
-
-  <welcome-file-list>
-    <welcome-file>index.html</welcome-file>
-  </welcome-file-list>
-</web-app>
diff --git a/src/tools/dash/src/main/webapp/dashboard.css b/src/tools/dash/src/main/webapp/dashboard.css
deleted file mode 100644
index d3d3904..0000000
--- a/src/tools/dash/src/main/webapp/dashboard.css
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * Base structure
- */
-
-/* Move down content because we have a fixed navbar that is 50px tall */
-body {
-  padding-top: 50px;
-}
-
-
-/*
- * Global add-ons
- */
-
-.sub-header {
-  padding-bottom: 10px;
-  border-bottom: 1px solid #eee;
-}
-
-/*
- * Top navigation
- * Hide default border to remove 1px line.
- */
-.navbar-fixed-top {
-  border: 0;
-}
-
-/*
- * Sidebar
- */
-
-/* Hide for mobile, show later */
-.sidebar {
-  display: none;
-}
-@media (min-width: 768px) {
-  .sidebar {
-    position: fixed;
-    top: 51px;
-    bottom: 0;
-    left: 0;
-    z-index: 1000;
-    display: block;
-    padding: 20px;
-    overflow-x: hidden;
-    overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
-    background-color: #f5f5f5;
-    border-right: 1px solid #eee;
-  }
-}
-
-/* Sidebar navigation */
-.nav-sidebar {
-  margin-right: -21px; /* 20px padding + 1px border */
-  margin-bottom: 20px;
-  margin-left: -20px;
-}
-.nav-sidebar > li > a {
-  padding-right: 20px;
-  padding-left: 20px;
-}
-.nav-sidebar > .active > a,
-.nav-sidebar > .active > a:hover,
-.nav-sidebar > .active > a:focus {
-  color: #fff;
-  background-color: #428bca;
-}
-
-
-/*
- * Main content
- */
-
-.main {
-  padding: 20px;
-}
-@media (min-width: 768px) {
-  .main {
-    padding-right: 40px;
-    padding-left: 40px;
-  }
-}
-.main .page-header {
-  margin-top: 0;
-}
-
-
-/*
- * Placeholder dashboard ideas
- */
-
-.placeholders {
-  margin-bottom: 30px;
-  text-align: center;
-}
-.placeholders h4 {
-  margin-bottom: 0;
-}
-.placeholder {
-  margin-bottom: 20px;
-}
-.placeholder img {
-  display: inline-block;
-  border-radius: 50%;
-}
-
-code {
- color: #00A388;
- background-color: transparent;
-}
diff --git a/src/tools/dash/src/main/webapp/favicon.ico b/src/tools/dash/src/main/webapp/favicon.ico
deleted file mode 100644
index a37c760..0000000
--- a/src/tools/dash/src/main/webapp/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/src/tools/dash/src/main/webapp/index.html b/src/tools/dash/src/main/webapp/index.html
deleted file mode 100644
index db1b6af..0000000
--- a/src/tools/dash/src/main/webapp/index.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <!-- TODO(kchodorow): make this and result.html inherit from a parent template. -->
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <!-- The above 3 meta tags *must* come first in the head; any other head content must come
-    *after* these tags -->
-    <meta name="description" content="">
-    <meta name="author" content="">
-    <link rel="icon" href="/favicon.ico">
-
-    <title>Build $build_data.getBuildId()</title>
-
-    <!-- Bootstrap core CSS -->
-    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"
-          rel="stylesheet">
-
-    <!-- Custom styles for this template -->
-    <link href="/dashboard.css" rel="stylesheet">
-
-    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
-    <!--[if lt IE 9]>
-      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
-      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
-    <![endif]-->
-  </head>
-
-  <body>
-
-    <nav class="navbar navbar-inverse navbar-fixed-top">
-      <div class="container-fluid">
-        <div class="navbar-header">
-          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse"
-                  data-target="#navbar" aria-expanded="false" aria-controls="navbar">
-            <span class="sr-only">Toggle navigation</span>
-            <span class="icon-bar"></span>
-            <span class="icon-bar"></span>
-            <span class="icon-bar"></span>
-          </button>
-          <a class="navbar-brand" href="#">Dash of Bazel</a>
-        </div>
-        <div id="navbar" class="navbar-collapse collapse">
-          <ul class="nav navbar-nav navbar-right">
-            <li><a href="/">Dashboard</a></li>
-            <!-- TODO(kchodorow): link to a bazel.io documentation. -->
-            <li><a href="#todo">Help</a></li>
-          </ul>
-          <form class="navbar-form navbar-right">
-            <!-- TODO(kchodorow): add fulltext search. -->
-            <input type="text" class="form-control" placeholder="Search...">
-          </form>
-        </div>
-      </div>
-    </nav>
-
-    <div class="container-fluid">
-      <!-- TODO(kchodorow): add a list of existing builds. -->
-      <h2>No builds, yet!</h2>
-    </div>
-
-    <!-- Bootstrap core JavaScript
-    ================================================== -->
-    <!-- Placed at the end of the document so the pages load faster -->
-    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
-    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
-  </body>
-</html>
diff --git a/src/tools/dash/src/main/webapp/result.html b/src/tools/dash/src/main/webapp/result.html
deleted file mode 100644
index 106fec6..0000000
--- a/src/tools/dash/src/main/webapp/result.html
+++ /dev/null
@@ -1,156 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <!-- The above 3 meta tags *must* come first in the head; any other head content must come
-    *after* these tags -->
-    <meta name="description" content="">
-    <meta name="author" content="">
-    <link rel="icon" href="/favicon.ico">
-
-    <title>Build $build_data.getBuildId()</title>
-
-    <!-- Bootstrap core CSS -->
-    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"
-          rel="stylesheet">
-
-    <!-- Custom styles for this template -->
-    <link href="/dashboard.css" rel="stylesheet">
-
-    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
-    <!--[if lt IE 9]>
-    <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
-    <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
-    <![endif]-->
-</head>
-
-<body>
-
-<nav class="navbar navbar-inverse navbar-fixed-top">
-    <div class="container-fluid">
-        <div class="navbar-header">
-            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse"
-                    data-target="#navbar" aria-expanded="false" aria-controls="navbar">
-                <span class="sr-only">Toggle navigation</span>
-                <span class="icon-bar"></span>
-                <span class="icon-bar"></span>
-                <span class="icon-bar"></span>
-            </button>
-            <a class="navbar-brand" href="#">$build_data.getBuildId()</a>
-        </div>
-        <div id="navbar" class="navbar-collapse collapse">
-            <ul class="nav navbar-nav navbar-right">
-                <li><a href="/">Dashboard</a></li>
-                <!-- TODO(kchodorow): link to a bazel.io documentation. -->
-                <li><a href="#">Help</a></li>
-            </ul>
-            <form class="navbar-form navbar-right">
-                <!-- TODO(kchodorow): add fulltext search. -->
-                <input type="text" class="form-control" placeholder="Search...">
-            </form>
-        </div>
-    </div>
-</nav>
-
-<div class="container-fluid">
-    <div class="row">
-        <div class="col-sm-3 col-md-2 sidebar">
-            <ul class="nav nav-sidebar">
-                <li class="active">
-                    <a href="#results">
-                        Results <span class="sr-only">(current)</span>
-                    </a>
-                </li>
-                <li><a href="#command-line">Command line</a></li>
-                <li><a href="#env">Environment</a></li>
-            </ul>
-        </div>
-
-        <div id="results" class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
-            <h1 class="page-header">bazel $build_data.getCommandName() results</h1>
-            <div class="table-responsive">
-                <table class="table table-striped">
-                    <thead>
-                    <tr>
-                        <th>Target</th>
-                        <th>Status</th>
-                    </tr>
-                    </thead>
-                    <tbody>
-                    #if ($build_data.getCommandName() == "test")
-                    #foreach( $result in $build_data.getTestDataList() )
-                    <tr>
-                        #if ( $result.getPassed() )
-                        <td>
-                            <div>
-                                <code style="color:#79BD8F; background-color: transparent;">$result.getLabel()</code>
-                            </div>
-                        </td>
-                        <td style="color:#79BD8F;">Passed</td>
-                        #else
-                        <td>
-                            <div onclick="$('#log-$velocityCount').toggle();">
-                                <code style="color:#FF6138;">$result.getLabel()</code>
-                            </div>
-                    <pre id="log-$velocityCount" style="display: none;">
-$result.getLog().toStringUtf8()
-                    </pre>
-                            #if ( $result.getTruncated() )
-                            <div>Truncated after 1MB, see local log for full output.</div>
-                            #end
-                        </td>
-                        <td style="color:#FF6138;">Failed</td>
-                        #end
-                    </tr>
-                    #end
-                    #else
-                    #foreach( $result in $build_data.getTargetsList() )
-                    <tr>
-                        <td><code>$result.getLabel()</code></td>
-                        <td>Built</td>
-                    </tr>
-                    #end
-                    #end
-                    </tbody>
-                </table>
-            </div>
-        </div>
-
-        <div id="command-line" class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
-            <h1>bazel command line</h1>
-            <div>
-            <pre>
-bazel #foreach( $option in $build_data.getCommandLine().getStartupOptionsList())--$option #end $build_data.getCommandName() #foreach( $option in $build_data.getCommandLine().getOptionsList())--$option #end #foreach( $residue in $build_data.getCommandLine().getResidueList())$residue #end
-            </pre>
-            </div>
-        </div>
-
-        <div id="env" class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
-            <h1>bazel environment</h1>
-            <div>
-                <table>
-                    <tr>
-                        <th>Name</th>
-                        <th>Value</th>
-                    </tr>
-                    #foreach( $env in $build_data.getClientEnvList())
-                    <tr>
-                        <td><code>$env.getName()</code></td>
-                        <td><code>$env.getValue()</code></td>
-                    </tr>
-                    #end
-                    </pre>
-            </div>
-        </div>
-    </div>
-</div>
-
-<!-- Bootstrap core JavaScript
-================================================== -->
-<!-- Placed at the end of the document so the pages load faster -->
-<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
-<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
-</body>
-</html>
diff --git a/src/tools/dash/src/test/java/com/google/devtools/dash/AllTests.java b/src/tools/dash/src/test/java/com/google/devtools/dash/AllTests.java
new file mode 100644
index 0000000..4d92ffb
--- /dev/null
+++ b/src/tools/dash/src/test/java/com/google/devtools/dash/AllTests.java
@@ -0,0 +1,26 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.dash;
+
+import com.google.devtools.build.lib.testutil.ClasspathSuite;
+
+import org.junit.runner.RunWith;
+
+/**
+ * Runs all tests.
+ */
+@RunWith(ClasspathSuite.class)
+public class AllTests {
+}
diff --git a/src/tools/dash/src/test/java/com/google/devtools/dash/BUILD b/src/tools/dash/src/test/java/com/google/devtools/dash/BUILD
new file mode 100644
index 0000000..d65b06c
--- /dev/null
+++ b/src/tools/dash/src/test/java/com/google/devtools/dash/BUILD
@@ -0,0 +1,34 @@
+java_library(
+    name = "AllTests",
+    srcs = ["AllTests.java"],
+    deps = [
+        "//src/test/java:testutil",
+        "//third_party:junit4",
+    ],
+)
+
+java_library(
+    name = "util",
+    srcs = ["ProtoInputStream.java"],
+    deps = [
+        "//external:javax/servlet/api",
+        "//third_party:protobuf",
+    ],
+)
+
+java_test(
+    name = "dash",
+    srcs = [
+        "DashRequestTest.java",
+    ],
+    runtime_deps = [":AllTests"],
+    deps = [
+        "@appengine-java//:jars",
+        "@easymock//jar",
+        ":util",
+        "//src/main/protobuf:proto_dash",
+        "//src/tools/dash/src/main/java:servlets",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
diff --git a/src/tools/dash/src/test/java/com/google/devtools/dash/DashRequestTest.java b/src/tools/dash/src/test/java/com/google/devtools/dash/DashRequestTest.java
new file mode 100644
index 0000000..02adc72
--- /dev/null
+++ b/src/tools/dash/src/test/java/com/google/devtools/dash/DashRequestTest.java
@@ -0,0 +1,98 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.dash;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.replay;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.appengine.api.datastore.Blob;
+import com.google.appengine.api.datastore.Entity;
+import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
+import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
+import com.google.devtools.build.lib.bazel.dash.DashProtos;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Tests for {@link DashRequest}.
+ */
+@RunWith(JUnit4.class)
+public class DashRequestTest {
+
+  private final LocalServiceTestHelper helper =
+      new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig());
+  private HttpServletRequest request;
+
+  @Before
+  public void setUp() {
+    helper.setUp();
+    request = createMock(HttpServletRequest.class);
+  }
+
+  @After
+  public void tearDown() {
+    helper.tearDown();
+  }
+
+  @Test
+  public void testUriParsing() throws Exception {
+    final String buildId = "3b9a81d9-0ed3-48d2-84c6-6296aecc21e6";
+    final DashProtos.BuildData data = DashProtos.BuildData.newBuilder().setBuildId(buildId).build();
+    expect(request.getRequestURI()).andReturn("/result/3b9a81d9-0ed3-48d2-84c6-6296aecc21e6");
+    expect(request.getInputStream()).andReturn(new ProtoInputStream(data));
+    replay(request);
+
+    DashRequest dashRequest = new DashRequest(request);
+    assertEquals(buildId, dashRequest.getBuildId());
+    Entity entity = dashRequest.getEntity();
+    assertEquals(buildId, entity.getProperty(DashRequest.BUILD_ID));
+    assertEquals("result", entity.getProperty(DashRequest.PAGE_NAME));
+    assertEquals(data, DashProtos.BuildData.parseFrom(
+        ((Blob) entity.getProperty(DashRequest.BUILD_DATA)).getBytes()));
+  }
+
+  private void uriError(String uri) {
+    expect(request.getRequestURI()).andReturn(uri).times(2);
+    replay(request);
+
+    try {
+      new DashRequest(request);
+      fail("Should have thrown");
+    } catch (DashRequest.DashRequestException e) {
+      assertThat(e.getMessage()).contains("Invalid URI pattern: " + uri);
+    }
+  }
+
+  @Test
+  public void testInvalidUuid() {
+    uriError("/result/3b9a81d9-0ed3-48d2");
+  }
+
+  @Test
+  public void testMissingPageName() {
+    uriError("/3b9a81d9-0ed3-48d2-84c6-6296aecc21e6");
+  }
+
+}
diff --git a/src/tools/dash/src/test/java/com/google/devtools/dash/ProtoInputStream.java b/src/tools/dash/src/test/java/com/google/devtools/dash/ProtoInputStream.java
new file mode 100644
index 0000000..35cc7c0
--- /dev/null
+++ b/src/tools/dash/src/test/java/com/google/devtools/dash/ProtoInputStream.java
@@ -0,0 +1,39 @@
+// Copyright 2015 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.dash;
+
+import com.google.protobuf.Message;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.servlet.ServletInputStream;
+
+/**
+ * Input stream for testing.
+ */
+public class ProtoInputStream extends ServletInputStream {
+  InputStream stream;
+
+  ProtoInputStream(Message message) {
+    stream = new ByteArrayInputStream(message.toByteArray());
+  }
+
+  @Override
+  public int read() throws IOException {
+    return stream.read();
+  }
+
+}