Repository: badges/shields (js)

Issue:
 MDN HTTP Observatory is launched, and Mozilla Observatory is now deprecated. 
### Are you experiencing an issue with...

shields.io

### 🐞 Description

Today I noticed our [Mozilla Observatory Grade](https://shields.io/badges/mozilla-http-observatory-grade) badge was no longer working. 

After revieing the [linked page](https://observatory.mozilla.org/analyze/docs.qlcplus.org) I noticed that the service has been moved.

![image](https://github.com/user-attachments/assets/462a8c09-ec1b-4f48-9b7a-4f77517550d9)

Please see link to the blog post: https://developer.mozilla.org/en-US/blog/mdn-http-observatory-launch/

> Today, we are delighted to announce that the HTTP Observatory's new home is MDN! This blog post explains a little bit of the history behind the tool, how we got to where we are now, what the migration means for Observatory and MDN, and what Observatory tests.

### 🔗 Link to the badge

https://shields.io/badges/mozilla-http-observatory-grade

### 💡 Possible Solution

I think we will need to embrace the new MDN HTTP observatory and change the badge accordingly.

https://developer.mozilla.org/en-US/observatory 

Pull request:
migrate [MozillaObservatory] to new API
Closes #10396

It seems like the old API is now back online and the badge is working again, so we don't have to merge this immediately. We can take a bit of time to wok out the details. But it is deprecated and due to be retired in September so we do need to migrate this soon-ish.

This is a bit tricky to work on as the API is currently undocumented (although we have been told we can use it and docs are coming). There are some behaviours that aren't exactly specified. I've made some assumptions. Some notes:

- The API seems to no longer explicitly issue a scan status. Dealing with a pending scan, for example, doesn't really seem to be a thing any more. I don't really have the examples to easily test what all the failure cases look like, although I did think of one obvious one. I think in lieu of better docs, we can say we either:
    - Get a scan result or
    - The validation fails and we reject the response with the generic "invalid" message
    We can always improve the error messages in future when we have docs to understand the failure cases a bit better.
- The concept of publishing (or not) the result seems to be gone.
- I guess this could be a service where the first request sometimes takes a long time, but once the first scan has happened we have a cached result. I haven't done it here, but I wonder if this would be a good place to use https://github.com/badges/shields/pull/9233
- This service had a bunch of tests which were done using mocks. I decided to bin them and switch those to testing the rendering function in unit tests in a `.spec` file


Patch:
diff --git a/core/base-service/base-static.js b/core/base-service/base-static.js
index d2523cce54..70c9969a17 100644
--- a/core/base-service/base-static.js
+++ b/core/base-service/base-static.js
@@ -47,7 +47,11 @@ export default class BaseStaticService extends BaseService {
       const format = (match.slice(-1)[0] || '.svg').replace(/^\./, '')
       badgeData.format = format
 
-      setCacheHeadersForStaticResource(ask.res)
+      let maxAge = 24 * 3600 // 1 day
+      if (!queryParams.logo && !badgeData.isError) {
+        maxAge = 5 * 24 * 3600 // 5 days
+      }
+      setCacheHeadersForStaticResource(ask.res, maxAge)
 
       const svg = makeBadge(badgeData)
       makeSend(format, ask.res, end)(svg)
diff --git a/core/base-service/cache-headers.js b/core/base-service/cache-headers.js
index 881af81d04..bbecf6a537 100644
--- a/core/base-service/cache-headers.js
+++ b/core/base-service/cache-headers.js
@@ -90,8 +90,11 @@ function setCacheHeaders({
   setHeadersForCacheLength(res, cacheLengthSeconds)
 }
 
-const staticCacheControlHeader = `max-age=${24 * 3600}, s-maxage=${24 * 3600}` // 1 day.
-function setCacheHeadersForStaticResource(res) {
+function setCacheHeadersForStaticResource(
+  res,
+  maxAge = 24 * 3600, // 1 day
+) {
+  const staticCacheControlHeader = `max-age=${maxAge}, s-maxage=${maxAge}`
   res.setHeader('Cache-Control', staticCacheControlHeader)
   res.setHeader('Last-Modified', serverStartTimeGMTString)
 }
diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js
index 00dabed077..2a1f6b0f98 100644
--- a/core/base-service/redirector.js
+++ b/core/base-service/redirector.js
@@ -111,8 +111,12 @@ export default function redirector(attrs) {
         ask.res.statusCode = 301
         ask.res.setHeader('Location', redirectUrl)
 
-        // To avoid caching mistakes for a long time, and to make this simpler
-        // to reason about, use the same cache semantics as the static badge.
+        /* To avoid caching mistakes forever
+           (in the absence of cache control directives that specify otherwise,
+           301 redirects are cached without any expiry date)
+           and to make this simpler to reason about,
+           use the same cache semantics as the static badge.
+        */
         setCacheHeadersForStaticResource(ask.res)
 
         ask.res.end()
diff --git a/core/server/server.spec.js b/core/server/server.spec.js
index 32c0e04bf8..741b2e92da 100644
--- a/core/server/server.spec.js
+++ b/core/server/server.spec.js
@@ -59,8 +59,17 @@ describe('The server', function () {
       expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
     })
 
-    it('should serve badges with custom maxAge', async function () {
+    it('should serve static badges without logo with maxAge=432000', async function () {
       const { headers } = await got(`${baseUrl}badge/foo-bar-blue`)
+      expect(headers['cache-control']).to.equal(
+        'max-age=432000, s-maxage=432000',
+      )
+    })
+
+    it('should serve badges with with logo with maxAge=86400', async function () {
+      const { headers } = await got(
+        `${baseUrl}badge/foo-bar-blue?logo=javascript`,
+      )
       expect(headers['cache-control']).to.equal('max-age=86400, s-maxage=86400')
     })
 
diff --git a/services/discord/discord.service.js b/services/discord/discord.service.js
index eecb9b653d..fa5acd4975 100644
--- a/services/discord/discord.service.js
+++ b/services/discord/discord.service.js
@@ -1,5 +1,6 @@
 import Joi from 'joi'
 import { nonNegativeInteger } from '../validators.js'
+import { metric } from '../text-formatters.js'
 import { BaseJsonService, pathParams } from '../index.js'
 
 const schema = Joi.object({
@@ -53,7 +54,7 @@ export default class Discord extends BaseJsonService {
 
   static render({ members }) {
     return {
-      message: `${members} online`,
+      message: `${metric(members)} online`,
       color: 'brightgreen',
     }
   }
diff --git a/services/mozilla-observatory/mozilla-observatory.service.js b/services/mozilla-observatory/mozilla-observatory.service.js
index f726df1037..3be791f642 100644
--- a/services/mozilla-observatory/mozilla-observatory.service.js
+++ b/services/mozilla-observatory/mozilla-observatory.service.js
@@ -1,46 +1,21 @@
 import Joi from 'joi'
-import { BaseJsonService, queryParam, pathParam } from '../index.js'
+import { BaseJsonService, pathParam } from '../index.js'
 
 const schema = Joi.object({
-  state: Joi.string()
-    .valid('ABORTED', 'FAILED', 'FINISHED', 'PENDING', 'STARTING', 'RUNNING')
-    .required(),
-  grade: Joi.alternatives()
-    .conditional('state', {
-      is: 'FINISHED',
-      then: Joi.string().regex(/^[ABCDEF][+-]?$/),
-      otherwise: Joi.valid(null),
-    })
-    .required(),
-  score: Joi.alternatives()
-    .conditional('state', {
-      is: 'FINISHED',
-      then: Joi.number().integer().min(0).max(200),
-      otherwise: Joi.valid(null),
-    })
-    .required(),
-}).required()
-
-const queryParamSchema = Joi.object({
-  publish: Joi.equal(''),
+  scan: Joi.object({
+    grade: Joi.string()
+      .regex(/^[ABCDEF][+-]?$/)
+      .required(),
+    score: Joi.number().integer().min(0).max(200).required(),
+  }).required(),
 }).required()
 
 const description = `
-The [Mozilla HTTP Observatory](https://observatory.mozilla.org)
+The [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory)
 is a set of security tools to analyze your website
 and inform you if you are utilizing the many available methods to secure it.
 `
 
-const publishDescription = `
-By default the scan result is hidden from the public result list.
-You can activate the publication of the scan result
-by setting the \`publish\` parameter.
-
-The badge returns a cached site result if the site has been scanned anytime in the previous 24 hours.
-If you need to force invalidating the cache,
-you can to do it manually through the [Mozilla Observatory Website](https://observatory.mozilla.org)
-`
-
 export default class MozillaObservatory extends BaseJsonService {
   // TODO: Once created, change to a more appropriate category,
   // see https://github.com/badges/shields/pull/2926#issuecomment-460777017
@@ -49,7 +24,6 @@ export default class MozillaObservatory extends BaseJsonService {
   static route = {
     base: 'mozilla-observatory',
     pattern: ':format(grade|grade-score)/:host',
-    queryParamSchema,
   }
 
   static openApi = {
@@ -67,12 +41,6 @@ export default class MozillaObservatory extends BaseJsonService {
             name: 'host',
             example: 'github.com',
           }),
-          queryParam({
-            name: 'publish',
-            schema: { type: 'boolean' },
-            example: null,
-            description: publishDescription,
-          }),
         ],
       },
     },
@@ -82,13 +50,7 @@ export default class MozillaObservatory extends BaseJsonService {
     label: 'observatory',
   }
 
-  static render({ format, state, grade, score }) {
-    if (state !== 'FINISHED') {
-      return {
-        message: state.toLowerCase(),
-        color: 'lightgrey',
-      }
-    }
+  static render({ format, grade, score }) {
     const letter = grade[0].toLowerCase()
     const colorMap = {
       a: 'brightgreen',
@@ -104,20 +66,22 @@ export default class MozillaObservatory extends BaseJsonService {
     }
   }
 
-  async fetch({ host, publish }) {
+  async fetch({ host }) {
     return this._requestJson({
       schema,
-      url: 'https://http-observatory.security.mozilla.org/api/v1/analyze',
+      url: 'https://observatory-api.mdn.mozilla.net/api/v2/analyze',
       options: {
-        method: 'POST',
         searchParams: { host },
-        form: { hidden: !publish },
       },
     })
   }
 
-  async handle({ format, host }, { publish }) {
-    const { state, grade, score } = await this.fetch({ host, publish })
-    return this.constructor.render({ format, state, grade, score })
+  async handle({ format, host }) {
+    const { scan } = await this.fetch({ host })
+    return this.constructor.render({
+      format,
+      grade: scan.grade,
+      score: scan.score,
+    })
   }
 }
diff --git a/services/mozilla-observatory/mozilla-observatory.spec.js b/services/mozilla-observatory/mozilla-observatory.spec.js
new file mode 100644
index 0000000000..a2e73c40bb
--- /dev/null
+++ b/services/mozilla-observatory/mozilla-observatory.spec.js
@@ -0,0 +1,93 @@
+import { test, given } from 'sazerac'
+import MozillaObservatory from './mozilla-observatory.service.js'
+
+describe('MozillaObservatory', function () {
+  test(MozillaObservatory.render, () => {
+    given({ format: 'grade', grade: 'A' }).expect({
+      message: 'A',
+      color: 'brightgreen',
+    })
+    given({ format: 'grade', grade: 'A+' }).expect({
+      message: 'A+',
+      color: 'brightgreen',
+    })
+    given({ format: 'grade', grade: 'A-' }).expect({
+      message: 'A-',
+      color: 'brightgreen',
+    })
+
+    given({ format: 'grade', grade: 'B' }).expect({
+      message: 'B',
+      color: 'green',
+    })
+    given({ format: 'grade', grade: 'B+' }).expect({
+      message: 'B+',
+      color: 'green',
+    })
+    given({ format: 'grade', grade: 'B-' }).expect({
+      message: 'B-',
+      color: 'green',
+    })
+
+    given({ format: 'grade', grade: 'C' }).expect({
+      message: 'C',
+      color: 'yellow',
+    })
+    given({ format: 'grade', grade: 'C+' }).expect({
+      message: 'C+',
+      color: 'yellow',
+    })
+    given({ format: 'grade', grade: 'C-' }).expect({
+      message: 'C-',
+      color: 'yellow',
+    })
+
+    given({ format: 'grade', grade: 'D' }).expect({
+      message: 'D',
+      color: 'orange',
+    })
+    given({ format: 'grade', grade: 'D+' }).expect({
+      message: 'D+',
+      color: 'orange',
+    })
+    given({ format: 'grade', grade: 'D-' }).expect({
+      message: 'D-',
+      color: 'orange',
+    })
+
+    given({ format: 'grade', grade: 'E' }).expect({
+      message: 'E',
+      color: 'orange',
+    })
+    given({ format: 'grade', grade: 'E+' }).expect({
+      message: 'E+',
+      color: 'orange',
+    })
+    given({ format: 'grade', grade: 'E-' }).expect({
+      message: 'E-',
+      color: 'orange',
+    })
+
+    given({ format: 'grade', grade: 'F' }).expect({
+      message: 'F',
+      color: 'red',
+    })
+    given({ format: 'grade', grade: 'F+' }).expect({
+      message: 'F+',
+      color: 'red',
+    })
+    given({ format: 'grade', grade: 'F-' }).expect({
+      message: 'F-',
+      color: 'red',
+    })
+
+    given({
+      format: 'grade-score',
+      grade: 'A',
+      score: '115',
+    }).expect({
+      message: 'A (115/100)',
+      color: 'brightgreen',
+    })
+  })
+})
diff --git a/services/revolt/revolt.service.js b/services/revolt/revolt.service.js
index e43763e5b7..328a5f28d1 100644
--- a/services/revolt/revolt.service.js
+++ b/services/revolt/revolt.service.js
@@ -1,5 +1,6 @@
 import Joi from 'joi'
 import { BaseJsonService, pathParam, queryParam } from '../index.js'
+import { metric } from '../text-formatters.js'
 import { nonNegativeInteger, optionalUrl } from '../validators.js'
 
 const schema = Joi.object({
@@ -52,7 +53,7 @@ export default class RevoltServerInvite extends BaseJsonService {
 
   static render({ memberCount }) {
     return {
-      message: `${memberCount} members`,
+      message: `${metric(memberCount)} members`,
       color: 'brightgreen',
     }
   }


Test patch:
diff --git a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
index 01aac066cc..28e62eaa92 100644
--- a/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
+++ b/.github/ISSUE_TEMPLATE/2_Failing_service_test.md
@@ -18,10 +18,6 @@ labels: 'keep-service-tests-green'
 
 <!-- Indicate whether or not the live badge is working. -->
 
-:link: **CircleCI link**
-
-<!-- Provide a link to the failing test in CircleCI. -->
-
 :lady_beetle: **Stack trace**
 
 ```
diff --git a/services/discord/discord.tester.js b/services/discord/discord.tester.js
index 4d027b24b7..d6466fd519 100644
--- a/services/discord/discord.tester.js
+++ b/services/discord/discord.tester.js
@@ -1,12 +1,13 @@
-import Joi from 'joi'
 import { createServiceTester } from '../tester.js'
+import { isMetricWithPattern } from '../test-validators.js'
+
 export const t = await createServiceTester()
 
 t.create('gets status for Reactiflux')
   .get('/102860784329052160.json')
   .expectBadge({
     label: 'chat',
-    message: Joi.string().regex(/^[0-9]+ online$/),
+    message: isMetricWithPattern(/ online/),
     color: 'brightgreen',
   })
 
diff --git a/services/mozilla-observatory/mozilla-observatory.tester.js b/services/mozilla-observatory/mozilla-observatory.tester.js
index e5e3b1e99d..92fd1cd103 100644
--- a/services/mozilla-observatory/mozilla-observatory.tester.js
+++ b/services/mozilla-observatory/mozilla-observatory.tester.js
@@ -3,323 +3,17 @@ import { createServiceTester } from '../tester.js'
 export const t = await createServiceTester()
 
 const isMessage = Joi.alternatives()
-  .try(
-    Joi.string().regex(/^[ABCDEF][+-]? \([0-9]{1,3}\/100\)$/),
-    Joi.string().allow('pending'),
-  )
+  .try(Joi.string().regex(/^[ABCDEF][+-]? \([0-9]{1,3}\/100\)$/))
   .required()
 
-t.create('request on observatory.mozilla.org')
-  .timeout(10000)
-  .get('/grade-score/observatory.mozilla.org.json')
-  .expectBadge({
-    label: 'observatory',
-    message: isMessage,
-  })
-
-t.create('request on observatory.mozilla.org with inclusion in public results')
-  .timeout(10000)
-  .get('/grade-score/observatory.mozilla.org.json?publish')
-  .expectBadge({
-    label: 'observatory',
-    message: isMessage,
-  })
-
-t.create('grade without score (mock)')
-  .get('/grade/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'A', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'A',
-    color: 'brightgreen',
-  })
-
-t.create('grade A with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'A', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'A (115/100)',
-    color: 'brightgreen',
-  })
-
-t.create('grade A+ with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'A+', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'A+ (115/100)',
-    color: 'brightgreen',
-  })
-
-t.create('grade A- with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'A-', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'A- (115/100)',
-    color: 'brightgreen',
-  })
-
-t.create('grade B with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'B', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'B (115/100)',
-    color: 'green',
-  })
-
-t.create('grade B+ with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'B+', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'B+ (115/100)',
-    color: 'green',
-  })
-
-t.create('grade B- with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'B-', score: 115 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'B- (115/100)',
-    color: 'green',
-  })
-
-t.create('grade C with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'C', score: 80 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'C (80/100)',
-    color: 'yellow',
-  })
-
-t.create('grade C+ with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'C+', score: 80 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'C+ (80/100)',
-    color: 'yellow',
-  })
-
-t.create('grade C- with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'C-', score: 80 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'C- (80/100)',
-    color: 'yellow',
-  })
-
-t.create('grade D with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'D', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'D (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade D+ with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'D+', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'D+ (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade D- with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'D-', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'D- (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade E with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'E', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'E (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade E+ with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'E+', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'E+ (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade E- with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'E-', score: 15 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'E- (15/100)',
-    color: 'orange',
-  })
-
-t.create('grade F with score (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FINISHED', grade: 'F', score: 0 }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'F (0/100)',
-    color: 'red',
-  })
-
-t.create('aborted (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'ABORTED', grade: null, score: null }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'aborted',
-    color: 'lightgrey',
-  })
-
-t.create('failed (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'FAILED', grade: null, score: null }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'failed',
-    color: 'lightgrey',
-  })
-
-t.create('pending (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'PENDING', grade: null, score: null }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'pending',
-    color: 'lightgrey',
-  })
-
-t.create('starting (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'STARTING', grade: null, score: null }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'starting',
-    color: 'lightgrey',
-  })
-
-t.create('running (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'RUNNING', grade: null, score: null }),
-  )
-  .expectBadge({
-    label: 'observatory',
-    message: 'running',
-    color: 'lightgrey',
-  })
+t.create('valid').get('/grade-score/observatory.mozilla.org.json').expectBadge({
+  label: 'observatory',
+  message: isMessage,
+})
 
-t.create('invalid response with grade and score but not finished (mock)')
-  .get('/grade-score/foo.bar.json')
-  .intercept(nock =>
-    nock('https://http-observatory.security.mozilla.org')
-      .post('/api/v1/analyze?host=foo.bar')
-      .reply(200, { state: 'RUNNING', grade: 'A+', score: 135 }),
-  )
+t.create('invalid')
+  .get('/grade-score/invalidsubdomain.shields.io.json')
   .expectBadge({
     label: 'observatory',
-    message: 'invalid response data',
-    color: 'lightgrey',
+    message: 'invalid',
   })
diff --git a/services/pub/pub-points.tester.js b/services/pub/pub-points.tester.js
index 667cc964d3..dec8bb8cdc 100644
--- a/services/pub/pub-points.tester.js
+++ b/services/pub/pub-points.tester.js
@@ -7,7 +7,7 @@ t.create('pub points (valid)')
   .get('/analysis_options.json')
   .expectBadge({
     label: 'points',
-    message: Joi.string().regex(/^\d+\/140$/),
+    message: Joi.string().regex(/^\d+\/\d+$/),
   })
 
 t.create('pub points (not found)').get('/analysisoptions.json').expectBadge({
diff --git a/services/revolt/revolt.tester.js b/services/revolt/revolt.tester.js
index aa7a046e95..3b2eb963eb 100644
--- a/services/revolt/revolt.tester.js
+++ b/services/revolt/revolt.tester.js
@@ -1,5 +1,5 @@
-import Joi from 'joi'
 import { createServiceTester } from '../tester.js'
+import { isMetricWithPattern } from '../test-validators.js'
 
 export const t = await createServiceTester()
 
@@ -7,7 +7,7 @@ t.create('get status of #revolt')
   .get('/01F7ZSBSFHQ8TA81725KQCSDDP.json')
   .expectBadge({
     label: 'chat',
-    message: Joi.string().regex(/^[0-9]+ members$/),
+    message: isMetricWithPattern(/ members/),
     color: 'brightgreen',
   })
 
@@ -17,7 +17,7 @@ t.create('custom api url')
   )
   .expectBadge({
     label: 'chat',
-    message: Joi.string().regex(/^[0-9]+ members$/),
+    message: isMetricWithPattern(/ members/),
     color: 'brightgreen',
   })