From d83cbd98bfd9ab079b842323032973bc7406f620 Mon Sep 17 00:00:00 2001 From: Robin van Boven <497556+Beanow@users.noreply.github.com> Date: Tue, 14 Apr 2020 12:18:22 +0200 Subject: [PATCH] Create util for TimestampMs and TimestampISO (#1746) We have a convention of using TimestampMs as our default representation. However TimestampISO has the benefit of being human readable / writable, so it's used for serialization and display as well. We'll validate types at runtime, as there's a fair chance we'll use these functions to parse data that came from a Flow `any` type (like JSON). Fixes #1653 --- src/util/timestamp.js | 69 ++++++++++++++++++++ src/util/timestamp.test.js | 129 +++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/util/timestamp.js create mode 100644 src/util/timestamp.test.js diff --git a/src/util/timestamp.js b/src/util/timestamp.js new file mode 100644 index 0000000..527492b --- /dev/null +++ b/src/util/timestamp.js @@ -0,0 +1,69 @@ +// @flow + +/** + * We have a convention of using TimestampMs as our default representation. + * However TimestampISO has the benefit of being human readable / writable, + * so it's used for serialization and display as well. + * We'll validate types at runtime, as there's a fair chance we'll use these + * functions to parse data that came from a Flow `any` type (like JSON). + */ + +// A timestamp representation in ms since epoch. +export opaque type TimestampMs: number = number; + +// A timestamp representation in ISO 8601 format. +export opaque type TimestampISO: string = string; + +/** + * Creates a TimestampISO from a TimestampMs-like input. + * + * Since much of the previous types have used `number` as a type instead of + * TimestampMs. Accepting `number` will give an easier upgrade path, rather + * than a forced refactor across the codebase. + */ +export function toISO(timestampLike: TimestampMs | number): TimestampISO { + const timestampMs: TimestampMs = fromNumber(timestampLike); + return new Date(timestampMs).toISOString(); +} + +/** + * Creates a TimestampMs from a TimestampISO. + */ +export function fromISO(timestampISO: TimestampISO): TimestampMs { + if (typeof timestampISO !== "string") { + throw new TypeError( + `TimestampISO values must be strings, ` + + `received: ${String(timestampISO)}` + ); + } + const parsed = Date.parse(timestampISO); + if (Number.isNaN(parsed)) { + throw new RangeError( + `Could not parse TimestampISO, are you sure it's a valid ISO format? ` + + `received: ${String(timestampISO)}` + ); + } + return parsed; +} + +/** + * Creates a TimestampMs from a number input. + * + * Since much of the previous types have used `number` as a type instead of + * TimestampMs. Accepting `number` will give an easier upgrade path, rather + * than a forced refactor across the codebase. + */ +export function fromNumber(timestampMs: number): TimestampMs { + const asNumber = Number(timestampMs); + if ( + timestampMs === null || + timestampMs === undefined || + !Number.isInteger(asNumber) + ) { + throw new TypeError( + `Numbers representing TimestampMs values must be finite integers, ` + + `received: ${String(timestampMs)}` + ); + } + return new Date(asNumber).valueOf(); +} diff --git a/src/util/timestamp.test.js b/src/util/timestamp.test.js new file mode 100644 index 0000000..edfc7cb --- /dev/null +++ b/src/util/timestamp.test.js @@ -0,0 +1,129 @@ +// @flow + +import type {TimestampMs, TimestampISO} from "./timestamp"; +import {fromISO, toISO, fromNumber} from "./timestamp"; + +/** + * Helper function to write readable "toThrow" tests. This intentionally + * ignores Flow types, as we want to test runtime input validation. + * + * Use by replacing: + * expect(() => fn(...args)).toThrow(); + * With: + * given(...args).expect(fn).toThrow(); + */ +function given(...args: mixed[]) { + return { + expect: (fn: Function) => expect(() => fn(...args)), + }; +} + +describe("util/timestamp", () => { + const fullExample = { + ISO: (("2020-01-01T12:34:56.789Z": any): TimestampISO), + ms: ((1577882096789: any): TimestampMs), + }; + const partialExample = { + ISO: (("2020-01-01": any): TimestampISO), + fullISO: (("2020-01-01T00:00:00.000Z": any): TimestampISO), + ms: ((1577836800000: any): TimestampMs), + }; + + // These double as a Flow sanity check. + describe("roundtrips", () => { + it("should handle number roundtrips", () => { + const origin: number = fullExample.ms; + expect(fromNumber(origin)).toBe(origin); + expect(fromISO(toISO(origin))).toBe(origin); + expect(fromNumber(fromISO(toISO(origin)))).toBe(origin); + }); + it("should handle ISO roundtrips", () => { + const origin: TimestampISO = fullExample.ISO; + expect(toISO(fromISO(origin))).toBe(origin); + expect(toISO(fromNumber(fromISO(origin)))).toBe(origin); + }); + }); + + describe("toISO", () => { + it("should throw on null", () => { + given(null).expect(toISO).toThrow(TypeError); + }); + it("should throw on undefined", () => { + given(undefined).expect(toISO).toThrow(TypeError); + }); + it("should throw on NaN", () => { + given(NaN).expect(toISO).toThrow(TypeError); + }); + it("should throw on Infinity", () => { + given(Infinity).expect(toISO).toThrow(TypeError); + }); + it("should throw on ISO strings", () => { + given("2020-01-01T12:34:56.789Z").expect(toISO).toThrow(TypeError); + }); + it("should handle 0 correctly", () => { + expect(toISO(0)).toBe("1970-01-01T00:00:00.000Z"); + }); + it("should handle Date.now correctly", () => { + const now = Date.now(); + const nowISO = new Date(now).toISOString(); + expect(toISO(now)).toBe(nowISO); + }); + it("should handle examples correctly", () => { + expect(toISO(fullExample.ms)).toBe(fullExample.ISO); + expect(toISO(partialExample.ms)).toBe(partialExample.fullISO); + }); + }); + + describe("fromISO", () => { + it("should throw on null", () => { + given(null).expect(fromISO).toThrow(TypeError); + }); + it("should throw on undefined", () => { + given(undefined).expect(fromISO).toThrow(TypeError); + }); + it("should throw on numbers", () => { + given(123).expect(fromISO).toThrow(TypeError); + }); + it("should handle examples correctly", () => { + expect(fromISO(fullExample.ISO)).toBe(fullExample.ms); + expect(fromISO(partialExample.ISO)).toBe(partialExample.ms); + }); + it("should throw on invalid formatting", () => { + given("04/31/2016 12:46pm").expect(fromISO).toThrow(RangeError); + }); + it("should throw on illegal date", () => { + given("2000-02-32T00:00:00.000Z").expect(fromISO).toThrow(RangeError); + }); + }); + + describe("fromNumber", () => { + it("should throw on null", () => { + given(null).expect(fromNumber).toThrow(TypeError); + }); + it("should throw on undefined", () => { + given(undefined).expect(fromNumber).toThrow(TypeError); + }); + it("should throw on NaN", () => { + given(NaN).expect(fromNumber).toThrow(TypeError); + }); + it("should throw on Infinity", () => { + given(Infinity).expect(fromNumber).toThrow(TypeError); + }); + it("should throw on floating point numbers", () => { + given(1 / 3) + .expect(fromNumber) + .toThrow(TypeError); + }); + it("should handle 0 correctly", () => { + expect(fromNumber(0)).toBe(0); + }); + it("should handle Date.now correctly", () => { + const now = Date.now(); + expect(fromNumber(now)).toBe(now); + }); + it("should handle examples correctly", () => { + expect(fromNumber(fullExample.ms)).toBe(fullExample.ms); + expect(fromNumber(partialExample.ms)).toBe(partialExample.ms); + }); + }); +});