Repository: paularmstrong/build-tracker (ts)

Issue:
Indicate unexpected hash changes in Markdown table
<!--
When trying to solve more solutions with Build Tracker, please keep in mind some of the following goals of the project:
* Be lightweight: small project size (single-digit KiBs, gzipped)
* Be easy: too many options in an API can become confusing
* Be clear: the intended purpose of every method should be as obvious as possible
* Is it easy to do this in "userland"? Would it be better off done there?
-->

# Problem

In you inspect [this build comparison](https://unsplash-web-build-tracker.herokuapp.com/builds/c98c0fd1da8cac603b42c918cb8be70deb1b7e81/94334b2697dd7787e7b285ad6e07c10888840658?sizeKey=gzip&disabledArtifactsVisible=true&graphType=STACKED_BAR&comparedRevisions=c98c0fd1da8cac603b42c918cb8be70deb1b7e81&comparedRevisions=94334b2697dd7787e7b285ad6e07c10888840658), you will see that several artifacts have unexpected hash changes. This is clearly indicated in the UI:

![image](https://user-images.githubusercontent.com/921609/75457878-f170c780-5974-11ea-884a-093d35dad251.png)

Here is the same comparison in the form of a Markdown table (after using an artifact filter to remove rows that didn't change). As you can see, many rows are included but the table doesn't make it clear why they are included. We can see from the UI that they are included because of unexpected hash changes, or because there was a delta but it was too insignificant for any difference to be visible when viewed in KiBs.

|                                                                                                               |    30607b0 |    0a7b976 |              Δ1 |
| :------------------------------------------------------------------------------------------------------------ | ---------: | ---------: | --------------: |
| All                                                                                                           | 935.24 KiB | 936.17 KiB | 0.93 KiB (0.1%) |
| vendors~main                                                                                                  | 229.32 KiB |  230.2 KiB | 0.88 KiB (0.4%) |
| main                                                                                                          |  89.34 KiB |  89.33 KiB |  -0 KiB (-0.0%) |
| explore-old-route                                                                                             |  59.95 KiB |  59.95 KiB |    0 KiB (0.0%) |
| user-stats-sub-route                                                                                          |  19.31 KiB |  19.31 KiB |    0 KiB (0.0%) |
| uploader                                                                                                      |  15.55 KiB |   15.6 KiB | 0.05 KiB (0.3%) |
| photos-route                                                                                                  |  15.54 KiB |  15.54 KiB |  -0 KiB (-0.0%) |
| collection-route~editorial-route~explore-route~following-route~photos-route~search-photos-route~topi~50ad0392 |  12.23 KiB |  12.23 KiB |    0 KiB (0.0%) |
| user-route                                                                                                    |  11.36 KiB |  11.36 KiB |    0 KiB (0.0%) |
| collection-route                                                                                              |  11.31 KiB |  11.31 KiB |    0 KiB (0.0%) |
| edit-modal                                                                                                    |   9.01 KiB |   9.01 KiB |  -0 KiB (-0.0%) |
| editorial-route                                                                                               |   8.47 KiB |   8.47 KiB |    0 KiB (0.0%) |
| campaign-proposal-route                                                                                       |   7.85 KiB |   7.85 KiB |    0 KiB (0.0%) |
| explore-route                                                                                                 |   7.34 KiB |   7.35 KiB |    0 KiB (0.0%) |
| following-route                                                                                               |   6.11 KiB |   6.11 KiB |    0 KiB (0.0%) |
| explore-route~topics-route                                                                                    |   5.57 KiB |   5.57 KiB |    0 KiB (0.0%) |
| collections-feed-route                                                                                        |   5.27 KiB |   5.27 KiB |    0 KiB (0.0%) |
| search-photos-route                                                                                           |   5.01 KiB |   5.01 KiB |    0 KiB (0.0%) |
| collection-route~photos-route~user-feed-sub-route                                                             |   4.32 KiB |   4.32 KiB |    0 KiB (0.0%) |
| search-collections-route                                                                                      |   3.88 KiB |   3.89 KiB |    0 KiB (0.0%) |
| feed                                                                                                          |   1.34 KiB |   1.34 KiB |    0 KiB (0.0%) |

# Solution

Add some sort of indication to rows in the Markdown table which are included because of unexpected hash changes.

Pull request description:
Unify emoji
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

* Unexpected hash changes are not denoted in markdown tables
* They share the same icon in the UI as regular budget warnings

# Solution

Fixes #151

* Unify the "error" emoji to 🚨
* Use #️⃣ for unexpected hash changes in markdown table and summaries
* Add an icon the looks like #️⃣ to the app UI, use that instead of warning icon

# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [x] 📖 Update relevant documentation


Patch:
diff --git a/docs/docs/packages/api-errors.md b/docs/docs/packages/api-errors.md
index b74b192..bf8ec95 100644
--- a/docs/docs/packages/api-errors.md
+++ b/docs/docs/packages/api-errors.md
@@ -6,6 +6,10 @@ sidebar_label: '@build-tracker/api-errors'
 
 This is a shared package for creating and comparing API errors returned from the Build Tracker server's API.
 
+## 400 `ValidationError`
+
+The build that you are trying to insert into the database does not meet requirements. See the specific error message for more information.
+
 ## 401 `AuthError`
 
 If your server's API is protected with an API key and you do not provide it with requests requiring authentication, a 401 unauthorized response will be returned.
diff --git a/docs/docs/packages/comparator.md b/docs/docs/packages/comparator.md
index 3feec46..8e5fb86 100644
--- a/docs/docs/packages/comparator.md
+++ b/docs/docs/packages/comparator.md
@@ -86,17 +86,19 @@ Returns an array Markdown-formatted summaries of the overall comparison and any
 [
   '⚠️: `Group \\"All\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB',
   '⚠️: `Group \\"warning\\"` failed the gzip budget size limit of 0 KiB by 0.12 KiB',
-  '🚫: `Group \\"error\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB'
+  '🚨: `Group \\"error\\"` failed the gzip budget size limit of 0 KiB by 0.16 KiB',
+  '#️⃣: `burritos` hash changed without any file size change'
 ];
 ```
 
 If `useEmoji` is set to `false`, emoji will be replaced with words:
 
-| Emoji        | Word    |
-| ------------ | ------- |
-| ✅ (U+2705)  | Success |
-| ⚠️ (U+26A0)  | Warning |
-| 🚫 (U+1F6AB) | Error   |
+| Emoji            | Word    |
+| ---------------- | ------- |
+| ✅ (U+2705)      | Success |
+| ⚠️ (U+26A0)      | Warning |
+| 🚨 (U+1F6A9)     | Error   |
+| #️⃣ (U+0023-20E3) | Hash    |
 
 ## Types
 
diff --git a/src/api-errors/src/index.ts b/src/api-errors/src/index.ts
index dd05c4b..2e122af 100644
--- a/src/api-errors/src/index.ts
+++ b/src/api-errors/src/index.ts
@@ -28,3 +28,12 @@ export class UnimplementedError extends Error {
     Object.setPrototypeOf(this, UnimplementedError.prototype);
   }
 }
+
+export class ValidationError extends Error {
+  public readonly status = 400;
+
+  public constructor(field: string, expected: string, received: string | number | void) {
+    super(`"${field}" expected to receive "${expected}", but value was "${received}"`);
+    Object.setPrototypeOf(this, ValidationError.prototype);
+  }
+}
diff --git a/src/app/config/webpack-server.config.js b/src/app/config/webpack-server.config.js
index b96c44e..b3315d2 100644
--- a/src/app/config/webpack-server.config.js
+++ b/src/app/config/webpack-server.config.js
@@ -23,7 +23,7 @@ module.exports = (env, reporter) => ({
       { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
       {
         test: /\.(png)$/i,
-        loader: 'file-loader',
+        loader: require.resolve('file-loader'),
         options: { outputPath: '../client/static', publicPath: '/client/static', name: '[name].[hash:8].[ext]' }
       }
     ]
diff --git a/src/app/src/components/ComparisonTable/DeltaCell.tsx b/src/app/src/components/ComparisonTable/DeltaCell.tsx
index 9d349d1..ad00eaa 100644
--- a/src/app/src/components/ComparisonTable/DeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/DeltaCell.tsx
@@ -3,13 +3,14 @@
  */
 import { BudgetLevel } from '@build-tracker/types';
 import ErrorIcon from '../../icons/Error';
+import HashIcon from '../../icons/Hash';
 import Hoverable from '../Hoverable';
 import React from 'react';
 import { Td } from '../Table';
 import Tooltip from '../Tooltip';
 import WarningIcon from '../../icons/Warning';
 import { DeltaCell as Cell, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
-import { formatBudgetResult, formatBytes } from '@build-tracker/formatting';
+import { formatBudgetResult, formatBytes, formatUnexpectedHashChange } from '@build-tracker/formatting';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -69,26 +70,35 @@ export const DeltaCell = (props: Props): React.ReactElement => {
   }
 
   const stringChange = `${sizeDelta} bytes (${(percentDelta * 100).toFixed(3)}%)`;
-  const title = cell.hashChanged && sizeDelta === 0 ? `Unexpected hash change! ${stringChange}` : stringChange;
 
-  const text = sizeDelta === 0 ? cell.hashChanged ? <WarningIcon /> : '' : formatBytes(sizeDelta);
+  const text = formatBytes(sizeDelta);
+  const hashChangedOnly = sizeDelta === 0 && cell.hashChanged;
   const tooltipText = failingBudgets.length
     ? failingBudgets.map(budget => formatBudgetResult(budget, cell.name)).join(', ')
-    : sizeDelta === 0 && cell.hashChanged
-    ? `The hash for "${cell.name}" unexpectedly changed`
+    : hashChangedOnly
+    ? formatUnexpectedHashChange(cell.name, false)
     : `"${cell.name}" changed by ${stringChange}`;
 
   return (
-    <Td accessibilityLabel={title} style={[style, { backgroundColor }]}>
+    <Td accessibilityLabel={tooltipText} style={[style, { backgroundColor }]}>
       {text ? (
         <Hoverable>
           {isHovered => (
             <View ref={viewRef} style={styles.textWrapper} testID="delta">
-              <Text>
-                {errorBudgets.length ? <ErrorIcon /> : warningBudgets.length ? <WarningIcon /> : null}
-                {failingBudgets.length ? ' ' : null}
-              </Text>
-              <Text>{text}</Text>
+              {sizeDelta !== 0 || hashChangedOnly ? (
+                <Text>
+                  {errorBudgets.length ? (
+                    <ErrorIcon />
+                  ) : warningBudgets.length ? (
+                    <WarningIcon />
+                  ) : hashChangedOnly ? (
+                    <HashIcon />
+                  ) : (
+                    ''
+                  )}{' '}
+                </Text>
+              ) : null}
+              {sizeDelta !== 0 ? <Text>{text}</Text> : null}
               {isHovered ? <Tooltip relativeTo={viewRef} text={tooltipText} /> : null}
             </View>
           )}
diff --git a/src/app/src/icons/Hash.tsx b/src/app/src/icons/Hash.tsx
new file mode 100644
index 0000000..3d1f703
--- /dev/null
+++ b/src/app/src/icons/Hash.tsx
@@ -0,0 +1,29 @@
+/**
+ * Material design redistributed from https://github.com/Templarian/MaterialDesign-JS
+ *
+ * SVG contents redistributed under MIT License
+ * https://github.com/Templarian/MaterialDesign-JS/blob/master/LICENSE
+ * Copyright 2018 Austin Andrews
+ */
+import React from 'react';
+import styles from './styles';
+import { StyleProp, TextStyle, unstable_createElement, ViewProps } from 'react-native';
+
+interface Props extends ViewProps {
+  style?: StyleProp<TextStyle>;
+}
+
+const Error = (props: Props): React.ReactElement<Props> =>
+  unstable_createElement(
+    'svg',
+    {
+      ...props,
+      style: [styles.root, props.style],
+      viewBox: '0 0 24 24'
+    },
+    <path d="M3,5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5M7,18H9L9.35,16H13.35L13,18H15L15.35,16H17.35L17.71,14H15.71L16.41,10H18.41L18.76,8H16.76L17.12,6H15.12L14.76,8H10.76L11.12,6H9.12L8.76,8H6.76L6.41,10H8.41L7.71,14H5.71L5.35,16H7.35L7,18M10.41,10H14.41L13.71,14H9.71L10.41,10Z" />
+  );
+
+Error.metadata = { height: 24, width: 24 };
+
+export default Error;
diff --git a/src/comparator/src/index.ts b/src/comparator/src/index.ts
index 924b15d..33f4e84 100644
--- a/src/comparator/src/index.ts
+++ b/src/comparator/src/index.ts
@@ -5,7 +5,13 @@ import Build from '@build-tracker/build';
 import BuildDelta from './BuildDelta';
 import markdownTable from 'markdown-table';
 import { ArtifactBudgets, ArtifactFilters, Budget, BudgetLevel, BudgetResult, Group } from '@build-tracker/types';
-import { formatBudgetResult, formatBytes, formatSha } from '@build-tracker/formatting';
+import {
+  formatBudgetResult,
+  formatBytes,
+  formatSha,
+  formatUnexpectedHashChange,
+  levelToEmoji
+} from '@build-tracker/formatting';
 
 export interface ArtifactSizes {
   [key: string]: number;
@@ -112,9 +118,17 @@ const defaultFormatTotal = (cell: TotalCell, sizeKey: string): string => formatB
 const defaultFormatDelta = (cell: DeltaCell | TotalDeltaCell, sizeKey: string): string => {
   const errorFailingBudgets = cell.failingBudgets.some(result => result.level === BudgetLevel.ERROR);
   const warningFailingBudgets = cell.failingBudgets.some(result => result.level === BudgetLevel.WARN);
-  return `${errorFailingBudgets ? '🚨 ' : warningFailingBudgets ? '⚠️ ' : ''}${formatBytes(
-    cell.sizes[sizeKey] || 0
-  )} (${((cell.percents[sizeKey] || 0) * 100).toFixed(1)}%)`;
+  const warningHashChanged =
+    cell.hashChanged && cell.failingBudgets.length === 0 && Object.values(cell.sizes).every(size => size === 0);
+  return `${
+    errorFailingBudgets
+      ? `${levelToEmoji[BudgetLevel.ERROR]} `
+      : warningFailingBudgets
+      ? `${levelToEmoji[BudgetLevel.WARN]} `
+      : warningHashChanged
+      ? `${levelToEmoji['hash']} `
+      : ''
+  }${formatBytes(cell.sizes[sizeKey] || 0)} (${((cell.percents[sizeKey] || 0) * 100).toFixed(1)}%)`;
 };
 const defaultArtifactFilter = (): boolean => true;
 
@@ -470,6 +484,10 @@ export default class BuildComparator {
         cell.failingBudgets.forEach(budget => {
           memo.push(formatBudgetResult(budget, row[0].text, useEmoji));
         });
+
+        if (cell.failingBudgets.length === 0 && Object.values(cell.sizes).every(size => size === 0)) {
+          memo.push(formatUnexpectedHashChange(row[0].text, useEmoji));
+        }
       });
       return memo;
     }, []);
diff --git a/src/formatting/src/index.ts b/src/formatting/src/index.ts
index fd231b8..a235405 100644
--- a/src/formatting/src/index.ts
+++ b/src/formatting/src/index.ts
@@ -35,22 +35,28 @@ function formatPercent(value: number): string {
 
 const levelToString = {
   [BudgetLevel.WARN]: 'Warning',
-  [BudgetLevel.ERROR]: 'Error'
+  [BudgetLevel.ERROR]: 'Error',
+  hash: 'Hash'
 };
 
-const levelToEmoji = {
+export const levelToEmoji = {
   [BudgetLevel.WARN]: '⚠️',
-  [BudgetLevel.ERROR]: '🚫'
+  [BudgetLevel.ERROR]: '🚨',
+  hash: '#️⃣'
 };
 
-export function formatBudgetResult(budgetResult: BudgetResult, itemName: string, useEmoji = false): string {
+function getPrefix(artifactName: string, level: string, useEmoji = false): string {
+  return `${(useEmoji ? levelToEmoji : levelToString)[level]}: \`${artifactName}\``;
+}
+
+export function formatBudgetResult(budgetResult: BudgetResult, artifactName: string, useEmoji = false): string {
   const { actual, expected, level, sizeKey, type } = budgetResult;
   const actualFormatted = type === BudgetType.PERCENT_DELTA ? formatPercent(actual) : formatBytes(actual);
   const expectedFormatted = type === BudgetType.PERCENT_DELTA ? formatPercent(expected) : formatBytes(expected);
   const diffFormatted =
     type === BudgetType.PERCENT_DELTA ? formatPercent(actual - expected) : formatBytes(actual - expected);
 
-  const prefix = `${(useEmoji ? levelToEmoji : levelToString)[level]}: \`${itemName}\``;
+  const prefix = getPrefix(artifactName, level, useEmoji);
 
   switch (type) {
     case BudgetType.DELTA:
@@ -61,3 +67,8 @@ export function formatBudgetResult(budgetResult: BudgetResult, itemName: string,
       return `${prefix} failed the ${sizeKey} budget size limit of ${expectedFormatted} by ${diffFormatted}`;
   }
 }
+
+export function formatUnexpectedHashChange(artifactName: string, useEmoji = false): string {
+  const prefix = getPrefix(artifactName, 'hash', useEmoji);
+  return `${prefix} hash changed without any file size change`;
+}
diff --git a/src/server/src/api/insert.ts b/src/server/src/api/insert.ts
index feeb2bd..b104c85 100644
--- a/src/server/src/api/insert.ts
+++ b/src/server/src/api/insert.ts
@@ -3,9 +3,9 @@
  */
 import Build from '@build-tracker/build';
 import Comparator from '@build-tracker/comparator';
-import { NotFoundError } from '@build-tracker/api-errors';
 import { Queries } from '../types';
 import { ServerConfig } from '../server';
+import { NotFoundError, ValidationError } from '@build-tracker/api-errors';
 import { Request, RequestHandler, Response } from 'express';
 
 export const insertBuild = (
@@ -15,7 +15,25 @@ export const insertBuild = (
 ): RequestHandler => (req: Request, res: Response): void => {
   const { artifacts, meta } = req.body;
   const { artifacts: artifactConfig = {}, budgets } = config;
+
   const build = new Build(meta, artifacts);
+
+  let validationErrors = [];
+  const revision = build.getMetaValue('revision');
+  if (!revision) {
+    validationErrors.push(new ValidationError('revision', 'string', revision));
+  }
+
+  if (isNaN(build.timestamp.valueOf())) {
+    validationErrors.push(new ValidationError('timestamp', 'timestamp', build.meta.timestamp));
+  }
+
+  if (validationErrors.length) {
+    res.status(400);
+    res.send({ errors: validationErrors.map(v => v.message) });
+    return;
+  }
+
   queries
     .insert(build)
     .then(() =>
@@ -54,7 +72,7 @@ export const insertBuild = (
         })
     )
     .catch(error => {
-      res.status(500);
+      res.status(error.status || 500);
       res.send({ error: error.message });
     });
 };


Test patch:
diff --git a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
index 9add2a6..be9e901 100644
--- a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
@@ -4,6 +4,7 @@
 import { CellType } from '@build-tracker/comparator';
 import { DeltaCell } from '../DeltaCell';
 import ErrorIcon from '../../../icons/Error';
+import HashIcon from '../../../icons/Hash';
 import React from 'react';
 import { Td } from '../../Table';
 import WarningIcon from '../../../icons/Warning';
@@ -65,7 +66,7 @@ describe('DeltaCell', () => {
         />
       );
 
-      expect(getByType(Td).props.accessibilityLabel).toEqual('-134 bytes (-50.000%)');
+      expect(getByType(Td).props.accessibilityLabel).toEqual('"tacos" changed by -134 bytes (-50.000%)');
     });
 
     test('shows a warning label if no change, but hash changed', () => {
@@ -83,8 +84,8 @@ describe('DeltaCell', () => {
           sizeKey="stat"
         />
       );
-      expect(queryAllByType(WarningIcon)).toHaveLength(1);
-      expect(getByType(Td).props.accessibilityLabel).toEqual('Unexpected hash change! 0 bytes (0.000%)');
+      expect(queryAllByType(HashIcon)).toHaveLength(1);
+      expect(getByType(Td).props.accessibilityLabel).toEqual('Hash: `tacos` hash changed without any file size change');
     });
 
     test('shows a warning icon if warning budget fails', () => {
diff --git a/src/comparator/src/__tests__/index.test.ts b/src/comparator/src/__tests__/index.test.ts
index eb57707..9da6dfe 100644
--- a/src/comparator/src/__tests__/index.test.ts
+++ b/src/comparator/src/__tests__/index.test.ts
@@ -36,6 +36,14 @@ const build2 = new Build(
     { name: 'churros', hash: 'def', sizes: { stat: 469, gzip: 120 } }
   ]
 );
+// Alternative to build2 with burritos
+const build2b = new Build(
+  { branch: 'master', revision: '8901234abcdef', parentRevision: 'abcdef', timestamp: 8901234 },
+  [
+    { name: 'tacos', hash: 'abc', sizes: { stat: 123, gzip: 45 } },
+    { name: 'burritos', hash: 'def', sizes: { stat: 456, gzip: 90 } }
+  ]
+);
 
 const artifactFilters = [/burritos/, /churros/];
 
@@ -368,9 +376,9 @@ describe('BuildComparator', () => {
     test('handles 0 artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build1b] });
       expect(comparator.toMarkdown({ artifactFilter: () => false })).toMatchInlineSnapshot(`
-      "|     |  1234567 |  1234567 |           Δ1 |
-      | :-- | -------: | -------: | -----------: |
-      | All | 0.13 KiB | 0.13 KiB | 0 KiB (0.0%) |"
+        "|     |  1234567 |  1234567 |           Δ1 |
+        | :-- | -------: | -------: | -----------: |
+        | All | 0.13 KiB | 0.13 KiB | 0 KiB (0.0%) |"
       `);
     });
 
@@ -489,6 +497,19 @@ describe('BuildComparator', () => {
       `);
     });
 
+    test('includes hash emoji for unexpected hash changes', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2b]
+      });
+      expect(comparator.toMarkdown()).toMatchInlineSnapshot(`
+"|          |  1234567 |  8901234 |               Δ1 |
+| :------- | -------: | -------: | ---------------: |
+| All      | 0.13 KiB | 0.13 KiB | #️⃣ 0 KiB (0.0%) |
+| burritos | 0.09 KiB | 0.09 KiB | #️⃣ 0 KiB (0.0%) |
+| tacos    | 0.04 KiB | 0.04 KiB |     0 KiB (0.0%) |"
+`);
+    });
+
     test('includes emoji for failing group budgets', () => {
       const comparator = new BuildComparator({
         builds: [build1, build2],
@@ -562,10 +583,10 @@ describe('BuildComparator', () => {
     test('does not include filtered artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2], artifactFilters });
       expect(comparator.toCsv()).toMatchInlineSnapshot(`
-        ",1234567,8901234,Δ1
-        All,0.04 KiB,0.04 KiB,0 KiB (-4.4%)
-        tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
-      `);
+",1234567,8901234,Δ1
+All,0.04 KiB,0.04 KiB,0 KiB (-4.4%)
+tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
+`);
     });
 
     test('accepts formatting and filtering options', () => {
@@ -591,7 +612,7 @@ describe('BuildComparator', () => {
   describe('summary', () => {
     test('returns a list of errors and warnings for artifacts', () => {
       const comparator = new BuildComparator({
-        builds: [build1, build2],
+        builds: [build1, build2b],
         artifactBudgets: {
           churros: [{ level: BudgetLevel.WARN, type: BudgetType.SIZE, maximum: 100, sizeKey: 'gzip' }],
           tacos: [
@@ -601,10 +622,10 @@ describe('BuildComparator', () => {
         }
       });
       expect(comparator.toSummary().join('\n')).toMatchInlineSnapshot(`
-        "⚠️: \`churros\` failed the gzip budget size limit of 0.1 KiB by 0.02 KiB
-        ⚠️: \`tacos\` failed the gzip budget size limit of 0 KiB by 0.04 KiB
-        🚫: \`tacos\` failed the gzip budget size limit of 0.03 KiB by 0.01 KiB"
-      `);
+"#️⃣: \`burritos\` hash changed without any file size change
+⚠️: \`tacos\` failed the gzip budget size limit of 0 KiB by 0.04 KiB
+🚨: \`tacos\` failed the gzip budget size limit of 0.03 KiB by 0.01 KiB"
+`);
     });
 
     test('returns a list of errors and warnings for groups', () => {
@@ -627,7 +648,7 @@ describe('BuildComparator', () => {
       expect(comparator.toSummary().join('\n')).toMatchInlineSnapshot(`
         "⚠️: \`Group \\"All\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB
         ⚠️: \`Group \\"warning\\"\` failed the gzip budget size limit of 0 KiB by 0.12 KiB
-        🚫: \`Group \\"error\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB"
+        🚨: \`Group \\"error\\"\` failed the gzip budget size limit of 0 KiB by 0.16 KiB"
       `);
     });
 
diff --git a/src/formatting/src/__tests__/index.test.ts b/src/formatting/src/__tests__/index.test.ts
index 3a85527..5b1a84d 100644
--- a/src/formatting/src/__tests__/index.test.ts
+++ b/src/formatting/src/__tests__/index.test.ts
@@ -60,7 +60,7 @@ describe('formatBudgetResult', () => {
           'tacos',
           true
         )
-      ).toEqual('🚫: `tacos` failed the stat budget size limit of 2 KiB by 2 KiB');
+      ).toEqual('🚨: `tacos` failed the stat budget size limit of 2 KiB by 2 KiB');
     });
 
     test('size', () => {
diff --git a/src/server/src/api/__tests__/insert.test.ts b/src/server/src/api/__tests__/insert.test.ts
index c38a6f7..350ebce 100644
--- a/src/server/src/api/__tests__/insert.test.ts
+++ b/src/server/src/api/__tests__/insert.test.ts
@@ -84,6 +84,24 @@ describe('insert build handler', () => {
           expect(res.body.summary).toEqual(comparator.toSummary());
         });
     });
+
+    test('returns an array of validation errors', () => {
+      const handler = insertBuild(queries, config);
+      app.post('/test', handler);
+
+      return request(app)
+        .post('/test')
+        .send({ meta: { timestamp: 'foobar' }, artifacts: [] })
+        .set('Content-Type', 'application/json')
+        .set('Accept', 'application/json')
+        .then(res => {
+          expect(res.status).toEqual(400);
+          expect(res.body.errors).toEqual([
+            '"revision" expected to receive "string", but value was "undefined"',
+            '"timestamp" expected to receive "timestamp", but value was "foobar"'
+          ]);
+        });
+    });
   });
 
   describe('parent revision', () => {