diff --git a/CHANGELOG.md b/CHANGELOG.md index 4632ab9..784e639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- added: A marketing push endpoint, `POST /marketing/push`. One call sends a message to a single device or login (a test), to an explicit list of device ids, or to the audience a location filter resolves to: include/exclude lists of countries, cities and regions, where the lines within a list are alternatives, the lists narrow each other, and an empty country include means every country. Region and city rules are accepted only with exactly one included country, since those values are stored without their country. With `dryRun: true` it resolves the audience and reports it without sending, so the caller can review the list and then send exactly those ids. Sends to a list stream a progress log, and each send may carry an optional Edge deep link (`url`) that the app opens after login, accepted only from an allow-list of buy, sell, swap, exchange, plugin (without a query), promotion, fund-account and earn links since the app follows any link it can parse, and an optional `label` that the server logs beside the campaign id. Tests obey the marketing opt-out like any other send. +- added: `GET /marketing/countries`, the countries devices are located in under the caller's targeted keys, with counts, so a caller can offer exactly the names an audience filter matches. +- added: A `marketer` flag and a `targetApiKeys` list on API keys. The marketing endpoint requires the flag (or `admin`) and only reaches devices registered under the listed keys, so the api keys baked into the apps cannot call it. +- fixed: Recognize Firebase's current "token not registered" error, so devices whose app has been uninstalled get their tokens cleared instead of being retried on every send forever. +- fixed: Skip devices registered with an empty token instead of sending them to Firebase, which rejected every one with "Exactly one of topic, token or condition is required". +- fixed: Log the device and error when a push fails. The details were being passed to Pino in the wrong argument order, so every failure logged as a bare "Unknown error". + ## 2.5.0 (2025-05-08) - changed: Move API keys to a separate settings document. diff --git a/pushServerConfig.sample.json b/pushServerConfig.sample.json new file mode 100644 index 0000000..79359b4 --- /dev/null +++ b/pushServerConfig.sample.json @@ -0,0 +1,7 @@ +{ + "listenHost": "127.0.0.1", + "listenPort": 8008, + "amqpUri": "amqp://username:password@localhost:5672", + "couchUri": "http://username:password@localhost:5984", + "currentCluster": "" +} diff --git a/src/daemons/publishDaemon.ts b/src/daemons/publishDaemon.ts index d20ea00..1e4f03c 100644 --- a/src/daemons/publishDaemon.ts +++ b/src/daemons/publishDaemon.ts @@ -9,6 +9,7 @@ import { getDevicesByLoginId } from '../db/couchDevices' import { DbConnections } from '../db/dbConnections' +import { isUnregisteredToken } from '../util/firebaseErrors' import { logger } from '../util/logger' import { asRabbitMessage, SendableMessage } from '../util/pushSender' import { runDaemon } from './runDaemon' @@ -73,7 +74,10 @@ async function sendToDevice( if (message.isMarketing && ignoreMarketing) return if (message.isPriceChange && ignorePriceChanges) return - if (apiKey == null || deviceToken == null) return + // Some devices register with an empty string for a token rather than none + // at all. Firebase rejects those with "Exactly one of topic, token or + // condition is required", so treat them as missing: + if (apiKey == null || deviceToken == null || deviceToken === '') return const sender = await getSender(connections, apiKey) if (sender == null) return @@ -88,12 +92,14 @@ async function sendToDevice( data: message.data ?? {} }) } catch (error) { - if (String(error).includes('not a valid FCM registration token')) { + if (isUnregisteredToken(error)) { logger.info(`Disabling device: ${deviceId}`) deviceRow.device.deviceToken = undefined await deviceRow.save() } else { - logger.info('Unknown error', { deviceId, error }) + // Pino takes the details first and the message second. Passing them the + // other way around silently drops them: + logger.info({ deviceId, error: String(error) }, 'Unknown error') } } } diff --git a/src/db/couchApiKeys.ts b/src/db/couchApiKeys.ts index 4283021..f0dba62 100644 --- a/src/db/couchApiKeys.ts +++ b/src/db/couchApiKeys.ts @@ -1,4 +1,4 @@ -import { asBoolean, asObject, asOptional, asString } from 'cleaners' +import { asArray, asBoolean, asObject, asOptional, asString } from 'cleaners' import { asCouchDoc, asMaybeNotFoundError, @@ -32,7 +32,9 @@ export const asCouchApiKey = asCouchDoc( asObject({ appId: asString, admin: asBoolean, - adminsdk: asOptional(asFirebaseAdminKey) + adminsdk: asOptional(asFirebaseAdminKey), + marketer: asOptional(asBoolean, false), + targetApiKeys: asOptional(asArray(asString), () => []) }) ) diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts index 16c2709..52ba066 100644 --- a/src/db/couchDevices.ts +++ b/src/db/couchDevices.ts @@ -25,6 +25,10 @@ import { asBase64 } from '../types/pushCleaners' import { Device } from '../types/pushTypes' import { DbConnections } from './dbConnections' +// Couch rejects an `_all_docs` request with no keys, and very large key lists +// make for unwieldy requests, so batch lookups at this size: +const FETCH_BATCH_SIZE = 500 + /** * A device returned from the database. * To make changes, edit the `device` object, then call `save`. @@ -117,12 +121,45 @@ const locationByRegionDesign = makeJsDesign('locationByRegion', ({ emit }) => ({ reduce: '_count' })) +/** + * Looks up devices by API key, then by IP location. + * + * Between them the key and the row value carry everything an audience query + * needs: the location in the key, and the fields that decide whether the + * device can be sent to in the value, so the query never has to fetch the + * documents themselves. Devices with no location are absent from this view, + * and are therefore unreachable by location targeting. + * + * The map runs inside Couch's view server, which has no `??` helper (sucrase + * compiles it to one), so the null checks are spelled out as ternaries. + */ +const apiKeyLocationDesign = makeJsDesign('apiKeyLocation', ({ emit }) => ({ + map: function (doc) { + if (doc.apiKey == null || doc.location == null) return + emit( + [ + doc.apiKey, + doc.location.country != null ? doc.location.country : '', + doc.location.region != null ? doc.location.region : '', + doc.location.city != null ? doc.location.city : '' + ], + { + deviceToken: doc.deviceToken, + ignoreMarketing: doc.ignoreMarketing === true, + visited: doc.visited + } + ) + }, + reduce: '_count' +})) + export const couchDevicesSetup: DatabaseSetup = { name: 'push-devices', documents: { '_design/loginId': loginIdDesign, '_design/locationByCity': locationByCityDesign, - '_design/locationByRegion': locationByRegionDesign + '_design/locationByRegion': locationByRegionDesign, + '_design/apiKeyLocation': apiKeyLocationDesign } } @@ -183,6 +220,32 @@ export const countDevicesByLocation = async ( }) } +/** + * Counts the located devices under an API key, by country, straight from the + * view's reduce, so it answers in milliseconds however large the key is. The + * names are exactly what ip-api stored, which is what an audience filter + * matches on. These are registration counts before the token and opt-out + * checks, so they run above what an audience query returns. + */ +export async function countDevicesByCountry( + connections: DbConnections, + apiKey: string +): Promise> { + const db = connections.couch.use(couchDevicesSetup.name) + const response = await db.view('apiKeyLocation', 'apiKeyLocation', { + reduce: true, + group_level: 2, + start_key: [apiKey], + end_key: [apiKey, 'zzzzzz'] + }) + return response.rows.flatMap(row => { + const key = asMaybe(asArray(asString))(row.key) + const count = asMaybe(asNumber)(row.value) + if (key == null || count == null) return [] + return [{ country: key[1] ?? '', count }] + }) +} + /** * Looks up a device by its id. * If the device does not exist in the database, creates a fresh row. @@ -217,6 +280,45 @@ export async function getDeviceById( return makeDeviceRow(db, asCouchDevice(raw)) } +/** One batch of a by-id lookup, along with how far through the ids we are. */ +export interface DeviceBatch { + deviceRows: DeviceRow[] + /** How many of the requested ids have been read so far. */ + idsRead: number +} + +/** + * Looks up many devices at once, skipping any ids that are missing, deleted, or + * unreadable. Ids are de-duplicated, and the lookup runs in batches to keep + * individual Couch requests small. + * + * This yields per batch rather than returning everything at once, so a caller + * working through hundreds of thousands of ids can report progress instead of + * going quiet for the whole lookup. + */ +export async function* streamDeviceBatchesByIds( + connections: DbConnections, + deviceIds: string[] +): AsyncIterableIterator { + const db = connections.couch.use(couchDevicesSetup.name) + const unique = [...new Set(deviceIds)] + + for (let i = 0; i < unique.length; i += FETCH_BATCH_SIZE) { + const keys = unique.slice(i, i + FETCH_BATCH_SIZE) + const response = await db.fetch({ keys }) + + const deviceRows: DeviceRow[] = [] + for (const row of response.rows) { + // Missing and deleted ids come back as rows without a document: + if ('error' in row || row.doc == null) continue + const couchDevice = asMaybe(asCouchDevice)(row.doc) + if (couchDevice == null) continue + deviceRows.push(makeDeviceRow(db, couchDevice)) + } + yield { deviceRows, idsRead: Math.min(i + keys.length, unique.length) } + } +} + /** * Finds all the devices that have logged into this account. */ @@ -343,3 +445,100 @@ export async function* streamDevicesByLocation( yield makeDeviceRow(db, couchDevice) } } + +/** + * Builds a CouchDB start key of [apiKey, country, region, city], stopping at the + * first missing location field so the range stays a valid prefix. + */ +function makeApiKeyLocationStartKey( + apiKey: string, + location: { country?: string; region?: string; city?: string } +): string[] { + const key = [apiKey] + for (const part of [location.country, location.region, location.city]) { + if (part == null) break + key.push(part) + } + return key +} + +/** + * What the `apiKeyLocation` view stores alongside each device, so that an + * audience query can work from the index alone. + */ +export interface DeviceSummary { + deviceId: string + apiKey: string + country: string + region: string + city: string + deviceToken: string | undefined + ignoreMarketing: boolean + visited: Date +} + +const asViewValue = asObject({ + deviceToken: asOptional(asString), + ignoreMarketing: asOptional(asBoolean, false), + visited: asOptional(asDate, () => new Date(0)) +}) + +// Rows to pull per request while paging through the view. Measured against +// the production index: pages of 2,048 rows scanned at about 9,000 rows a +// second and pages of 20,000 at about 18,000, with the bytes per row the +// same, so the larger page halves a whole-country audience query. Each page +// is about 6 MB of JSON, which the server parses comfortably. +const VIEW_PAGE_SIZE = 20_000 + +/** + * Streams a summary of every device registered under an API key, optionally + * narrowed by location. With no country at all this walks the key's whole + * slice of the index, every country included. + * + * This reads the view rows only. Fetching the documents would mean pulling + * hundreds of megabytes to read a handful of fields, since the view spans a + * whole country and the location filters are applied afterwards. + */ +export async function* streamDeviceSummariesByApiKeyLocation( + connections: DbConnections, + apiKey: string, + location: { country?: string; region?: string; city?: string } +): AsyncIterableIterator { + const db = connections.couch.use(couchDevicesSetup.name) + const startKey = makeApiKeyLocationStartKey(apiKey, location) + const endKey = [...startKey, 'zzzzzz'] + + let params: object = { start_key: startKey } + while (true) { + const response = await db.view('apiKeyLocation', 'apiKeyLocation', { + reduce: false, + include_docs: false, + limit: VIEW_PAGE_SIZE, + end_key: endKey, + ...params + }) + const { rows } = response + if (rows.length === 0) return + + for (const row of rows) { + const value = asMaybe(asViewValue)(row.value) + if (value == null) continue + // The location lives in the key, not the value: + const key = asMaybe(asArray(asString))(row.key) ?? [] + yield { + ...value, + deviceId: row.id, + apiKey, + country: key[1] ?? '', + region: key[2] ?? '', + city: key[3] ?? '' + } + } + + if (rows.length < VIEW_PAGE_SIZE) return + // Resume after the last row, which needs the doc id to break ties between + // devices sharing a location: + const last = rows[rows.length - 1] + params = { start_key: last.key, start_key_doc_id: last.id, skip: 1 } + } +} diff --git a/src/server/index.ts b/src/server/index.ts index bf34736..e0f6147 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import { makeExpressRoute } from 'serverlet/express' import { setupDatabases } from '../db/couchSetup' import { makeConnections, serverConfig } from '../serverConfig' import { logger } from '../util/logger' +import { makeMarketingToolRouter } from './marketing/marketingTool' import { withLogging } from './middleware/withLogging' import { allRoutes } from './urls' @@ -23,6 +24,11 @@ async function main(): Promise { // Set up Express: const app = express() app.enable('trust proxy') + // The marketing push API, mounted before the Serverlet catch-all. It comes + // ahead of the body parser below because it installs its own with a larger + // limit, and whichever parser runs first wins: + app.use('/marketing', makeMarketingToolRouter(connections)) + app.use(express.json({ limit: '1mb' })) app.use('/', makeExpressRoute(server)) diff --git a/src/server/marketing/locationFilter.ts b/src/server/marketing/locationFilter.ts new file mode 100644 index 0000000..2586ee6 --- /dev/null +++ b/src/server/marketing/locationFilter.ts @@ -0,0 +1,138 @@ +import { Device } from '../../types/pushTypes' + +/** + * An include/exclude filter over the city and region a device is located in. + * The lists hold normalized values (see `parseLocationList`). + */ +export interface LocationFilter { + countryInclude: string[] + countryExclude: string[] + cityInclude: string[] + cityExclude: string[] + regionInclude: string[] + regionExclude: string[] +} + +/** + * Why a device cannot receive a marketing push. + */ +export type DeviceSkipReason = + | 'ignore-marketing' + | 'unregistered' + | 'invalid-token' + +// Firebase tokens are alphanumerics plus these separators. Anything else is a +// corrupt row that would only fail at delivery time: +const VALID_TOKEN = /^[a-zA-Z0-9_\-:]+$/ + +/** + * Normalizes one include/exclude box: each line is a separate value, blank + * lines are dropped, and matching is case-insensitive. + */ +export function parseLocationList(lines: string[]): string[] { + const out = new Set() + for (const line of lines) { + const value = line.trim().toLowerCase() + if (value !== '') out.add(value) + } + return [...out] +} + +/** + * The stored spellings that one normalized filter value matches. The filter is + * case-insensitive but Couch keys are not, so an index scan narrowed to a + * country has to use the name as ip-api stored it, not as typed. Several + * spellings match when the stored data disagrees with itself; none means no + * device under that key is located there. + */ +export function findStoredNames(stored: string[], value: string): string[] { + const out = new Set() + for (const name of stored) { + if (name.trim().toLowerCase() === value) out.add(name) + } + return [...out] +} + +/** + * Decides whether a device's location passes the filter. Values within one box + * are OR'd together, and the boxes are AND'ed with each other. An empty include + * box means "no restriction", while an empty exclude box excludes nothing. So + * an empty country include with two country excludes reaches every country + * but those two. + */ +export function matchesLocationFilter( + location: Device['location'], + filter: LocationFilter +): boolean { + if (location == null) return false + const country = location.country.trim().toLowerCase() + const city = location.city.trim().toLowerCase() + const region = location.region.trim().toLowerCase() + + return ( + passes(country, filter.countryInclude, filter.countryExclude) && + passes(city, filter.cityInclude, filter.cityExclude) && + passes(region, filter.regionInclude, filter.regionExclude) + ) +} + +/** + * Why a filter cannot be run as written, or undefined if it can. Region and + * city values are stored without their country, so a rule on them only means + * one thing inside a single country: "LA" is Lagos in Nigeria and Louisiana in + * the United States, and three quarters of all devices carry a region code + * that some other country also uses. + */ +export function getFilterProblem(filter: LocationFilter): string | undefined { + const subnational = + filter.cityInclude.length + + filter.cityExclude.length + + filter.regionInclude.length + + filter.regionExclude.length + if (subnational > 0 && filter.countryInclude.length !== 1) { + return ( + 'Region and city filters apply within one country. ' + + 'Include exactly one country to use them.' + ) + } +} + +function passes(value: string, include: string[], exclude: string[]): boolean { + if (include.length > 0 && !include.includes(value)) return false + return !exclude.includes(value) +} + +/** The parts of a device that decide whether it can be sent to. */ +export type SendableFields = Pick< + Device, + 'apiKey' | 'deviceToken' | 'ignoreMarketing' +> + +/** + * Reports why a device cannot be sent to, or undefined if it can. + * + * The `targetApiKeys` are the app keys the marketing key may send to. Location + * queries get their scoping from the Couch view, but a send driven by a + * caller-supplied list of device ids does not, and the publish daemon resolves + * Firebase credentials from whatever key the device itself carries. + * + * This takes only the fields it reads so that queries can apply it to view + * rows, and sends to whole documents, without the two drifting apart. + */ +export function getDeviceSkipReason( + device: SendableFields, + targetApiKeys: ReadonlySet +): DeviceSkipReason | undefined { + const { apiKey, deviceToken, ignoreMarketing } = device + + if (ignoreMarketing) return 'ignore-marketing' + if ( + apiKey == null || + !targetApiKeys.has(apiKey) || + deviceToken == null || + deviceToken.trim() === '' + ) { + return 'unregistered' + } + if (!VALID_TOKEN.test(deviceToken)) return 'invalid-token' +} diff --git a/src/server/marketing/marketingData.ts b/src/server/marketing/marketingData.ts new file mode 100644 index 0000000..13eb137 --- /dev/null +++ b/src/server/marketing/marketingData.ts @@ -0,0 +1,17 @@ +/** + * Builds the data payload attached to every marketing push. + * + * This is the contract the app parses (`parsePushMessage` in edge-react-gui): + * `type` selects the marketing branch, `campaignId` ties opens back to a + * campaign, and the optional `url` is an Edge deep link the app follows once + * the user is logged in. FCM only carries string values in `data`, so the + * `url` key is left out entirely rather than set to `undefined`. + */ +export function makeMarketingData( + campaignId: string, + url?: string +): { [key: string]: string } { + const data: { [key: string]: string } = { type: 'marketing', campaignId } + if (url != null) data.url = url + return data +} diff --git a/src/server/marketing/marketingLink.ts b/src/server/marketing/marketingLink.ts new file mode 100644 index 0000000..7e42d5d --- /dev/null +++ b/src/server/marketing/marketingLink.ts @@ -0,0 +1,192 @@ +/** + * The deep links a marketing push may carry. + * + * The app follows any link it can parse: edge-react-gui's PushMessageParser + * hands the payload `url` to parseDeepLink and launches whatever comes back, + * and the apps already in the field have no allow-list of their own. So the + * server keeps one. A push may open the scenes that lead to a buy, sell, swap + * or promotion, and nothing that can spend, sign, log in, recover a password, + * or hand an address to a third party. The grammar below mirrors the app's + * parser, so a link that would not parse there is refused here instead of + * being sent as a dud. + */ + +// The two spellings the app treats as equivalent. The https form may also +// carry `?af=`, which the app turns into a promotion activation +// wrapped around the link. The `edge://` form silently drops `af`, so a link +// that relies on it there is refused rather than sent. +const EDGE_PREFIX = 'edge://' +const HTTPS_PREFIX = 'https://deep.edge.app/' + +// FCM caps the whole data payload at 4 KB, and no real link comes near this: +const MAX_LINK_LENGTH = 1024 + +const EXCHANGE_DIRECTIONS = new Set(['buy', 'sell', 'swap']) +const EXCHANGE_QUERY_KEYS = new Set(['buyAsset', 'sellAsset', 'promoId']) + +// Path segments are plugin, provider and payment-type ids, installer ids, +// scene names. Query values add asset specs (`ethereum_0xa0b8...`), which +// carry the same characters: +const SEGMENT = /^[A-Za-z0-9_.-]+$/ +const DOTS = /^\.+$/ +const QUERY_KEY = /^[A-Za-z]+$/ +const QUERY_VALUE = /^[A-Za-z0-9_.-]+$/ + +/** The link kinds a push may carry, for the refusal message and the docs. */ +export const ALLOWED_LINK_KINDS = + 'buy, sell, swap, exchange, plugin, promotion, modal/fundAccount, ' + + 'scene/earnScene' + +/** + * Why a link cannot go out in a marketing push, or undefined if it can. The + * message is meant for the operator building the send. + */ +export function getLinkProblem(url: string): string | undefined { + if (url.length > MAX_LINK_LENGTH) { + return `The url is longer than ${MAX_LINK_LENGTH} characters.` + } + + let rest: string + let isHttps: boolean + if (url.startsWith(EDGE_PREFIX)) { + rest = url.slice(EDGE_PREFIX.length) + isHttps = false + } else if (url.startsWith(HTTPS_PREFIX)) { + rest = url.slice(HTTPS_PREFIX.length) + isHttps = true + } else { + return `The url must start with ${EDGE_PREFIX} or ${HTTPS_PREFIX}.` + } + if (rest.includes('#')) return 'The url cannot have a fragment.' + + const [path, ...queryParts] = rest.split('?') + if (queryParts.length > 1) return 'The url has more than one "?".' + const query = new Map() + if (queryParts.length === 1) { + const problem = parseQueryInto(query, queryParts[0]) + if (problem != null) return problem + } + + const [kind, ...segments] = path.split('/') + for (const segment of segments) { + if (segment === '') return 'The url has an empty path segment.' + if (!SEGMENT.test(segment)) + return `"${segment}" is not a valid path segment.` + // The app's own URL parser keeps dot segments as typed, but any + // WHATWG-style parser upstream would collapse `buy/../pay` into `pay`, + // so they are refused rather than relied on: + if (DOTS.test(segment)) + return 'The url cannot contain "." or ".." segments.' + } + + // The promotion wrapper is allowed around any allowed link: + if (query.has('af')) { + if (!isHttps) { + return ( + 'A promotion id (af) only works on the https://deep.edge.app/ form; ' + + 'the edge:// form drops it.' + ) + } + query.delete('af') + } + + switch (kind) { + case 'buy': + case 'sell': + // edge://buy[/[/]] + if (segments.length > 2) { + return `${kind} links take at most a provider id and a payment type.` + } + return refuseQuery(kind, query) + + case 'swap': + // edge://swap + if (segments.length > 0) return 'swap links take no path.' + return refuseQuery(kind, query) + + case 'exchange': { + // edge://exchange/?buyAsset=&sellAsset=&promoId= + const [direction] = segments + if (segments.length !== 1 || !EXCHANGE_DIRECTIONS.has(direction)) { + return 'exchange links are exchange/buy, exchange/sell or exchange/swap.' + } + for (const key of query.keys()) { + if (!EXCHANGE_QUERY_KEYS.has(key)) { + return `exchange links do not take a "${key}" parameter.` + } + } + return undefined + } + + case 'promotion': + // edge://promotion/ + if (segments.length !== 1) { + return 'promotion links name exactly one installer id.' + } + return refuseQuery(kind, query) + + case 'plugin': { + // edge://plugin/[/], never a query. + // Any plugin id may be opened: the fiat plugins read the path as + // direction/provider/payment type, and a webview plugin appends it to + // its partner URL. A query is another matter. The app merges a link's + // query over the plugin's own parameters (makePluginUri in + // edge-react-gui: `{ ...baseQuery, ...deepQuery }`), api keys and + // referral codes included, and hands it to the partner page as well, so + // a link could point a partner widget at someone else's account or + // refund address. Nothing a campaign needs rides in a plugin query. + if (segments.length === 0 || segments.length > 4) { + return 'plugin links are plugin/ with up to three path segments.' + } + return refuseQuery(kind, query) + } + + case 'modal': + if (segments.length !== 1 || segments[0] !== 'fundAccount') { + return 'The only modal a marketing push may open is modal/fundAccount.' + } + return refuseQuery(kind, query) + + case 'scene': + if (segments.length !== 1 || segments[0] !== 'earnScene') { + return 'The only scene a marketing push may open is scene/earnScene.' + } + return refuseQuery(kind, query) + + default: + return ( + `"${kind}" links are not allowed in marketing pushes. ` + + `Allowed: ${ALLOWED_LINK_KINDS}.` + ) + } +} + +/** Reads `a=b&c=d` into the map, refusing anything irregular. */ +function parseQueryInto( + query: Map, + raw: string +): string | undefined { + if (raw === '') return 'The url has an empty query.' + for (const pair of raw.split('&')) { + const index = pair.indexOf('=') + const key = index < 0 ? pair : pair.slice(0, index) + const value = index < 0 ? '' : pair.slice(index + 1) + if (!QUERY_KEY.test(key)) return `"${key}" is not a valid parameter name.` + if (!QUERY_VALUE.test(value)) { + return `Parameter "${key}" needs a plain value.` + } + if (query.has(key)) return `Parameter "${key}" is repeated.` + query.set(key, value) + } + return undefined +} + +function refuseQuery( + kind: string, + query: Map +): string | undefined { + const [key] = query.keys() + return key == null + ? undefined + : `${kind} links do not take a "${key}" parameter.` +} diff --git a/src/server/marketing/marketingTool.ts b/src/server/marketing/marketingTool.ts new file mode 100644 index 0000000..65d065f --- /dev/null +++ b/src/server/marketing/marketingTool.ts @@ -0,0 +1,699 @@ +import { + asArray, + asBoolean, + asMaybe, + asNumber, + asObject, + asOptional, + asString +} from 'cleaners' +import { randomUUID } from 'crypto' +import express, { NextFunction, Request, Response, Router } from 'express' + +import { getApiKeyByKey } from '../../db/couchApiKeys' +import { + countDevicesByCountry, + getDeviceById, + getDevicesByLoginId, + streamDeviceBatchesByIds, + streamDeviceSummariesByApiKeyLocation +} from '../../db/couchDevices' +import { DbConnections } from '../../db/dbConnections' +import { asBase64 } from '../../types/pushCleaners' +import { ApiKey, Device } from '../../types/pushTypes' +import { logger } from '../../util/logger' +import { makePushSender, SendableMessage } from '../../util/pushSender' +import { + DeviceSkipReason, + findStoredNames, + getDeviceSkipReason, + getFilterProblem, + LocationFilter, + matchesLocationFilter, + parseLocationList +} from './locationFilter' +import { makeMarketingData } from './marketingData' +import { getLinkProblem } from './marketingLink' +import { makeProgressLog } from './progress' + +const asStringList = asOptional(asArray(asString), () => []) + +const asFilter = asObject({ + countryInclude: asStringList, // Empty means every country + countryExclude: asStringList, + cityInclude: asStringList, + cityExclude: asStringList, + regionInclude: asStringList, + regionExclude: asStringList +}) + +export const asPushBody = asObject({ + // The message: + title: asOptional(asString), + body: asOptional(asString), + // An Edge deep link the app opens after login. Only the navigation targets + // allowed by marketingLink.ts are accepted: + url: asOptional(asString), + + // A name for the send, kept in the server log beside the campaign id so + // the id can be found again later. Never sent to devices. + label: asOptional(asString), + + // Who receives it, exactly one of: + deviceId: asOptional(asString), + loginId: asOptional(asBase64), + deviceIds: asOptional(asArray(asString)), + filter: asOptional(asFilter), + + // Required, with no default. A dry run resolves the audience and reports it + // without sending anything, so a body that forgets the flag is refused + // rather than sent. + dryRun: asBoolean +}) +export type PushBody = ReturnType + +/** The four ways a body can name its audience. */ +export type PushTarget = 'device' | 'login' | 'list' | 'filter' + +/** + * Which way this body names its audience. Exactly one is allowed, so a body + * that names two, or none, gets undefined and is refused rather than guessed. + */ +export function getPushTarget(body: PushBody): PushTarget | undefined { + const targets: PushTarget[] = [] + if (body.deviceId != null) targets.push('device') + if (body.loginId != null) targets.push('login') + if (body.deviceIds != null) targets.push('list') + if (body.filter != null) targets.push('filter') + return targets.length === 1 ? targets[0] : undefined +} + +/** A device as a dry run reports it. */ +interface AudienceDevice { + deviceId: string + country: string + region: string + city: string + visited: Date +} + +// Test pushes carry this fixed campaign id (rather than a generated one) so the +// app treats them as marketing notifications and navigates, while their opens +// stay distinguishable from real campaigns in analytics. +const TEST_CAMPAIGN_ID = 'test' + +// A send carries one device id per targeted device, so its body dwarfs the 1mb +// the public endpoints allow. A whole-country audience currently runs to a few +// hundred thousand ids at ~26 characters each, so leave generous headroom: +const BODY_LIMIT = '64mb' + +// A backstop against a runaway audience, not a product limit: this sits above +// the entire targetable population, so ordinary country-wide sends never meet +// it. Raise it before it starts rejecting real audiences. +const MAX_AUDIENCE_DEVICES = 2_000_000 + +// How often a long send reports how far along it is: +const PROGRESS_INTERVAL_MS = 30_000 + +// A publish that fails this many times in a row means the queue itself is +// unreachable, not that a device is bad, so the send stops rather than +// printing a failure line for every remaining device: +const MAX_CONSECUTIVE_FAILURES = 10 + +/** + * Builds an Express router with the marketing push endpoint, consumed by the + * web UI in the internal tools project, which supplies the API key. + * + * `POST /push` sends one message to an audience named in one of four ways: + * a single `deviceId` or `loginId` (a test), an explicit `deviceIds` list, or + * a `filter` of include/exclude lists of countries, cities and regions, where + * an empty country include means every country. With `dryRun: true` it + * resolves that audience and answers with the list + * instead of sending, so the usual flow is: test to your own device, dry-run + * the filter to review the audience, then send exactly the ids the dry run + * returned. Tests and dry runs answer JSON; a send to a list or filter streams + * a text progress log, since a large audience takes minutes to queue. + * A send may carry an Edge deep link (`url`) that the app opens after login; + * only the navigation targets allowed by marketingLink.ts are accepted. + * + * `GET /countries` lists the countries devices are located in under the + * caller's targeted keys, with counts, so a caller can offer exactly the + * names an audience filter will match. + * + * Every call requires an `x-api-key` header naming a key with the `marketer` + * (or `admin`) flag, and only reaches devices registered under the keys in + * that key's `targetApiKeys` list. The apps' own keys deliberately cannot + * call this: they ship inside the apps, so anyone holding one could otherwise + * push to that app's whole audience. + */ +export function makeMarketingToolRouter(connections: DbConnections): Router { + const router = Router() + + // The API key travels in a header, so check it before parsing any body. + // Otherwise anonymous callers could make the server chew through + // BODY_LIMIT-sized bodies just to be told 401: + router.use((req: Request, res: Response, next: NextFunction): void => { + authenticate(connections, req, res) + .then(apiKeyRow => { + if (apiKeyRow == null) return // authenticate already responded + res.locals.apiKeyRow = apiKeyRow + next() + }) + .catch((error: unknown) => { + if (!res.headersSent) { + res.status(500).json({ error: errorText(error) }) + } + }) + }) + + // This router parses its own bodies, since a device-id list is far larger + // than anything the public endpoints accept: + router.use(express.json({ limit: BODY_LIMIT })) + + router.get( + '/countries', + asyncRoute(async (req: Request, res: Response): Promise => { + const apiKeyRow: ApiKey = res.locals.apiKeyRow + const targets = getTargets(apiKeyRow, res) + if (targets == null) return + + const totals = new Map() + for (const target of targets) { + for (const { country, count } of await countDevicesByCountry( + connections, + target + )) { + if (country === '') continue + totals.set(country, (totals.get(country) ?? 0) + count) + } + } + const countries = [...totals] + .map(([name, devices]) => ({ name, devices })) + .sort((a, b) => a.name.localeCompare(b.name)) + res.status(200).json({ countries }) + }) + ) + + router.post( + '/push', + asyncRoute(async (req: Request, res: Response): Promise => { + const apiKeyRow: ApiKey = res.locals.apiKeyRow + const targets = getTargets(apiKeyRow, res) + if (targets == null) return + + let parsed: PushBody + try { + parsed = asPushBody(req.body) + } catch (error: unknown) { + res.status(400).json({ error: errorText(error) }) + return + } + const target = getPushTarget(parsed) + if (target == null) { + res.status(400).json({ + error: + 'Name the audience with exactly one of deviceId, loginId, ' + + 'deviceIds, or filter.' + }) + return + } + // The app follows any link it can parse, so refuse everything but the + // navigation targets a campaign may use. Dry runs are checked too, so + // the operator hears about it before the audience is resolved: + if (parsed.url != null) { + const linkProblem = getLinkProblem(parsed.url) + if (linkProblem != null) { + res.status(400).json({ error: linkProblem }) + return + } + } + + const { dryRun } = parsed + const isTest = target === 'device' || target === 'login' + + // The message is only needed when something actually sends: + const campaignId = isTest ? TEST_CAMPAIGN_ID : randomUUID() + let message: SendableMessage | undefined + if (!dryRun) { + const title = parsed.title ?? (isTest ? 'Test Message' : undefined) + const { body, url } = parsed + if ( + title == null || + title.trim() === '' || + body == null || + body.trim() === '' + ) { + res.status(400).json({ error: 'A title and body are required.' }) + return + } + message = { + title, + body, + data: makeMarketingData(campaignId, url), + // Tests obey the opt-out like any other send, so the daemon may + // apply its own check to every message from here: + isMarketing: true, + isPriceChange: false + } + } + + // A test goes to one device, or to every targeted device of one login: + if (isTest) { + const devices = await resolveTestDevices( + connections, + targets, + parsed, + res + ) + if (devices == null) return + if (dryRun || message == null) { + res.status(200).json({ + dryRun: true, + total: devices.length, + devices: devices.map(summarizeDevice) + }) + return + } + const sender = makePushSender(connections) + for (const device of devices) { + await sender.sendToDevice(device, message) + } + res.status(200).json({ success: true, sent: devices.length }) + return + } + + // A filter resolves to a list from the location view: + let deviceIds = parsed.deviceIds ?? [] + if (target === 'filter' && parsed.filter != null) { + const audience = await resolveFilter( + connections, + targets, + parsed.filter, + res + ) + if (audience == null) return + if (dryRun) { + res + .status(200) + .json({ dryRun: true, total: audience.length, devices: audience }) + return + } + deviceIds = audience.map(device => device.deviceId) + } + + // A dry run over an explicit list re-reads the documents and reports + // which of them can still be sent to: + if (dryRun || message == null) { + const devices: AudienceDevice[] = [] + for await (const batch of streamDeviceBatchesByIds( + connections, + deviceIds + )) { + for (const { device } of batch.deviceRows) { + if (getDeviceSkipReason(device, targets) != null) continue + devices.push(summarizeDevice(device)) + } + } + res.status(200).json({ dryRun: true, total: devices.length, devices }) + return + } + + if (deviceIds.length === 0) { + res.status(400).json({ error: 'The audience is empty.' }) + return + } + await streamSend(connections, res, targets, deviceIds, message, { + campaignId, + label: parsed.label + }) + }) + ) + + // The body parser rejects malformed JSON and oversized bodies by throwing + // an error with a `status` (400 or 413), which Express would otherwise + // answer with its HTML error page, stack trace included outside production. + // Keep the JSON shape every other failure here uses: + router.use(handleRouterError) + + return router +} + +/** + * The router's error handler: answers with the `{ error }` shape and the + * error's own HTTP status, or 500 when it carries none. Express recognizes it + * by its four parameters. + */ +export function handleRouterError( + error: unknown, + req: Request, + res: Response, + next: NextFunction +): void { + if (res.headersSent) { + next(error) + return + } + const status = asMaybe(asObject({ status: asNumber }))(error)?.status ?? 500 + res.status(status).json({ error: errorText(error) }) +} + +/** + * The devices a test push reaches. A login can span apps, so its devices are + * resolved here and narrowed to the targeted ones, rather than letting the + * publish daemon fan out to every app the login has touched. + * + * A test obeys the same rules as a campaign, the marketing opt-out included. + * The opt-out is a promise to the user, and a test path that ignored it would + * let any marketer key reach an opted-out device, one request at a time. + * Responds with a 400 and returns undefined when there is nothing to send to. + */ +async function resolveTestDevices( + connections: DbConnections, + targets: ReadonlySet, + body: PushBody, + res: Response +): Promise { + const candidates: Device[] = + body.loginId != null + ? (await getDevicesByLoginId(connections, body.loginId)).map( + row => row.device + ) + : [ + (await getDeviceById(connections, body.deviceId ?? '', new Date())) + .device + ] + + const devices: Device[] = [] + const reasons = new Set() + for (const device of candidates) { + const reason = getDeviceSkipReason(device, targets) + if (reason == null) devices.push(device) + else reasons.add(reason) + } + if (devices.length === 0) { + res.status(400).json({ error: explainEmptyTest(body, reasons) }) + return undefined + } + return devices +} + +/** Why a test has nobody to send to, in the caller's terms. */ +function explainEmptyTest( + body: PushBody, + reasons: ReadonlySet +): string { + if (body.loginId != null) { + return ( + 'Login has no devices that can receive a marketing push ' + + 'under a targeted api key.' + ) + } + if (reasons.has('ignore-marketing')) { + return 'Device has opted out of marketing pushes.' + } + if (reasons.has('invalid-token')) { + return 'Device has an unusable push token.' + } + return ( + 'Device is not registered under a targeted api key, ' + + 'or has no push token.' + ) +} + +/** + * The devices a location filter reaches, read from the view alone. Responds + * with a 413 and returns undefined past the audience backstop. + */ +async function resolveFilter( + connections: DbConnections, + targets: ReadonlySet, + raw: ReturnType, + res: Response +): Promise { + const filter: LocationFilter = { + countryInclude: parseLocationList(raw.countryInclude), + countryExclude: parseLocationList(raw.countryExclude), + cityInclude: parseLocationList(raw.cityInclude), + cityExclude: parseLocationList(raw.cityExclude), + regionInclude: parseLocationList(raw.regionInclude), + regionExclude: parseLocationList(raw.regionExclude) + } + const problem = getFilterProblem(filter) + if (problem != null) { + res.status(400).json({ error: problem }) + return undefined + } + + // A single included country narrows the scan to that country's slice of + // the index, found under the spelling the index stores. Anything else + // walks every country the key has and lets the filter decide: + const devices: AudienceDevice[] = [] + for (const target of targets) { + for (const prefix of await getScanPrefixes(connections, target, filter)) { + for await (const summary of streamDeviceSummariesByApiKeyLocation( + connections, + target, + prefix + )) { + if (getDeviceSkipReason(summary, targets) != null) continue + const { country, region, city } = summary + if (!matchesLocationFilter({ country, region, city }, filter)) continue + + if (devices.length >= MAX_AUDIENCE_DEVICES) { + res.status(413).json({ + error: + `More than ${MAX_AUDIENCE_DEVICES} devices match. ` + + 'Narrow the audience and try again.' + }) + return undefined + } + devices.push({ + deviceId: summary.deviceId, + country, + region, + city, + visited: summary.visited + }) + } + } + } + return devices +} + +/** + * The slices of an api key's index that a filter has to scan. A single + * included country narrows the scan to that country, but the filter matches + * names case-insensitively while Couch keys do not, so the slice is found + * under the name as ip-api stored it, read from the view's reduce in + * milliseconds. A spelling no device carries scans nothing, which is what the + * filter would have matched. Any other country rule walks the whole key. + */ +async function getScanPrefixes( + connections: DbConnections, + apiKey: string, + filter: LocationFilter +): Promise> { + if (filter.countryInclude.length !== 1) return [{}] + const stored = await countDevicesByCountry(connections, apiKey) + const names = findStoredNames( + stored.map(row => row.country), + filter.countryInclude[0] + ) + return names.map(country => ({ country })) +} + +/** + * Sends to a list of devices, streaming a text progress log back to the + * caller, since a large audience takes minutes to queue. Failures after the + * first byte cannot change the status code, so they are reported as `[ERROR]` + * lines, and a finished send ends with a `[DONE]` line that says how many + * devices were actually handed to the queue. A run of consecutive publish + * failures means the queue itself is gone, so the send stops with `[ERROR]` + * instead of walking the rest of the audience. + */ +async function streamSend( + connections: DbConnections, + res: Response, + targets: ReadonlySet, + deviceIds: string[], + message: SendableMessage, + campaign: { campaignId: string; label: string | undefined } +): Promise { + const { campaignId, label } = campaign + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.flushHeaders() + const write = (chunk: string): void => { + res.write(chunk) + } + + try { + // Echoed in each notification so the app can report opens back to our + // analytics. The stream is the only place the caller sees it, so it goes + // in the server log too, where it can be found after the fact: + write(`[campaign] ${campaignId}\n`) + // The by-id lookup de-duplicates, so count unique ids from the start, or + // a repeated id would be reported as a device "no longer found": + const uniqueIds = [...new Set(deviceIds)] + logger.info( + { campaignId, label, devices: uniqueIds.length, title: message.title }, + 'Marketing send started' + ) + write(`Loading ${uniqueIds.length} devices...\n`) + + // Re-read the documents, since a device may have opted out or been + // deleted since the caller resolved the list. The publish daemon repeats + // these checks when it drains the queue, so this is about reporting an + // honest count rather than about enforcement. + const loading = makeProgressLog(write, 'Loaded', uniqueIds.length, { + intervalMs: PROGRESS_INTERVAL_MS + }) + const devices = new Map() + let skipped = 0 + let invalidTokens = 0 + for await (const batch of streamDeviceBatchesByIds( + connections, + uniqueIds + )) { + for (const { device } of batch.deviceRows) { + const reason = getDeviceSkipReason(device, targets) + if (reason != null) { + // One line per bad token would bury the progress log, so count + // them and report the total instead: + if (reason === 'invalid-token') ++invalidTokens + ++skipped + continue + } + devices.set(device.deviceId, device) + } + loading.update(batch.idsRead) + } + loading.finish(uniqueIds.length) + if (invalidTokens > 0) { + write(` ${invalidTokens} devices had an unusable token\n`) + } + + const missing = uniqueIds.length - devices.size - skipped + write( + `Queueing ${devices.size} devices` + + ` (${skipped} skipped, ${missing} no longer found)...\n` + ) + const sender = makePushSender(connections) + const queueing = makeProgressLog(write, 'Queued', devices.size, { + intervalMs: PROGRESS_INTERVAL_MS + }) + let queued = 0 + let failed = 0 + let consecutiveFailures = 0 + for (const device of devices.values()) { + const { deviceId } = device + try { + await sender.sendToDevice(device, message) + ++queued + consecutiveFailures = 0 + } catch (error: unknown) { + ++failed + write(`Device ${deviceId} failed: ${String(error)}\n`) + if (++consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + throw new Error( + `${MAX_CONSECUTIVE_FAILURES} publishes failed in a row, so the ` + + `queue is unreachable; ${queued} of ${devices.size} devices ` + + 'were queued before it stopped' + ) + } + } + queueing.update(queued + failed) + } + queueing.finish(queued + failed) + + // Deliberately not "sent": this hands the messages to the queue, and + // the publish daemon delivers them afterwards, which for a large + // audience runs long after this response ends. + const failures = failed > 0 ? ` (${failed} failed)` : '' + write(`\n[DONE] ${queued} devices queued for delivery${failures}.\n`) + logger.info( + { campaignId, label, queued, failed, skipped, missing }, + 'Marketing send queued' + ) + } catch (error: unknown) { + write(`\n[ERROR] ${errorText(error)}\n`) + logger.error( + { campaignId, label, error: errorText(error) }, + 'Marketing send stopped' + ) + } finally { + res.end() + } +} + +function summarizeDevice(device: Device): AudienceDevice { + return { + deviceId: device.deviceId, + country: device.location?.country ?? '', + region: device.location?.region ?? '', + city: device.location?.city ?? '', + visited: device.visited + } +} + +/** + * Validates the `x-api-key` header against the API-key database, and requires + * the `marketer` (or `admin`) flag. On failure it responds with a 401 or 403 + * and returns undefined, so callers should bail out. + */ +async function authenticate( + connections: DbConnections, + req: Request, + res: Response +): Promise { + const key = req.header('x-api-key') + const apiKeyRow = + key == null ? undefined : await getApiKeyByKey(connections, key) + if (apiKeyRow == null) { + res.status(401).json({ error: 'Invalid or missing API key.' }) + return undefined + } + if (!apiKeyRow.marketer && !apiKeyRow.admin) { + res.status(403).json({ error: 'API key is not allowed to send marketing.' }) + return undefined + } + return apiKeyRow +} + +/** + * Reads the app keys a marketing key may send to. A key with none configured + * cannot target anything, which fails closed: responds with a 400 and returns + * undefined, so callers should bail out. + */ +function getTargets(apiKeyRow: ApiKey, res: Response): Set | undefined { + const targets = new Set(apiKeyRow.targetApiKeys) + if (targets.size === 0) { + res.status(400).json({ + error: 'API key has no targetApiKeys configured.' + }) + return undefined + } + return targets +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Wraps an async Express handler so the router receives a synchronous + * void-returning function (satisfying the handler type) while still reporting + * any unexpected rejection back to the client. + */ +function asyncRoute( + handler: (req: Request, res: Response) => Promise +): (req: Request, res: Response) => void { + return (req, res) => { + handler(req, res).catch((error: unknown) => { + if (!res.headersSent) { + res.status(500).json({ error: errorText(error) }) + } else { + res.end() + } + }) + } +} diff --git a/src/server/marketing/progress.ts b/src/server/marketing/progress.ts new file mode 100644 index 0000000..3620b2a --- /dev/null +++ b/src/server/marketing/progress.ts @@ -0,0 +1,72 @@ +interface ProgressOptions { + /** How often to emit a line, in milliseconds. */ + intervalMs?: number + /** Injectable clock, for tests. */ + now?: () => number +} + +export interface ProgressLog { + /** Reports how far along we are, emitting at most once per interval. */ + update: (done: number) => void + /** Emits a closing line regardless of when the last one went out. */ + finish: (done: number) => void +} + +/** + * Reports progress through a long job on a fixed schedule, rather than once + * per item. A marketing send walks hundreds of thousands of devices, so the + * caller needs to know how far along it is and roughly how long is left. + */ +export function makeProgressLog( + write: (chunk: string) => void, + label: string, + total: number, + options: ProgressOptions = {} +): ProgressLog { + const { intervalMs = 30_000, now = () => Date.now() } = options + + const start = now() + let nextAt = intervalMs + + const emit = (done: number, final: boolean): void => { + const elapsedMs = now() - start + const percent = total <= 0 ? 100 : Math.floor((100 * done) / total) + const perSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0 + + let line = ` ${label}: ${percent}%, ${commas(done)} of ${commas(total)}` + if (!final && perSecond > 0) { + const remaining = (total - done) / perSecond + line += ` (${commas(Math.round(perSecond))}/s, ~${duration( + remaining + )} left)` + } else if (final) { + line += ` in ${duration(elapsedMs / 1000)}` + } + write(`${line}\n`) + } + + return { + update(done: number): void { + const elapsedMs = now() - start + if (elapsedMs < nextAt) return + // Skip whole intervals rather than emitting a burst after a long stall: + while (nextAt <= elapsedMs) nextAt += intervalMs + emit(done, false) + }, + finish(done: number): void { + emit(done, true) + } + } +} + +function commas(value: number): string { + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +function duration(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '?' + if (seconds < 90) return `${Math.round(seconds)}s` + const minutes = seconds / 60 + if (minutes < 90) return `${Math.round(minutes)}m` + return `${(minutes / 60).toFixed(1)}h` +} diff --git a/src/types/pushTypes.ts b/src/types/pushTypes.ts index 2713fb6..fc2f0c0 100644 --- a/src/types/pushTypes.ts +++ b/src/types/pushTypes.ts @@ -27,6 +27,16 @@ export interface ApiKey { admin: boolean adminsdk?: FirebaseAdminKey + + /** May this key call the marketing endpoints? */ + marketer: boolean + + /** + * The api keys whose devices this key may send marketing pushes to. + * Devices are registered under the key baked into each app, so a separate + * marketing key needs this list to say which apps it is allowed to target. + */ + targetApiKeys: string[] } /** diff --git a/src/util/firebaseErrors.ts b/src/util/firebaseErrors.ts new file mode 100644 index 0000000..2f045c3 --- /dev/null +++ b/src/util/firebaseErrors.ts @@ -0,0 +1,32 @@ +import { asMaybe, asObject, asOptional, asString } from 'cleaners' + +// The two places Firebase puts its error code, depending on the API version: +const asFirebaseError = asObject({ + code: asOptional(asString), + errorInfo: asOptional(asObject({ code: asOptional(asString) })) +}) + +/** + * True when Firebase reports that a token no longer belongs to an installed + * app, so the device can never receive anything until it registers again. + * + * How this arrives has changed between Firebase versions: the newer API sets a + * `messaging/registration-token-not-registered` code, the legacy API stringifies + * to `Error: NotRegistered`, and older releases used a sentence. Match all + * three, since missing it means the dead token is retried on every send + * forever, and never gets cleaned up. + */ +export function isUnregisteredToken(error: unknown): boolean { + const parsed = asMaybe(asFirebaseError)(error) + if ( + (parsed?.errorInfo?.code ?? parsed?.code) === + 'messaging/registration-token-not-registered' + ) { + return true + } + const text = String(error) + return ( + text.includes('NotRegistered') || + text.includes('not a valid FCM registration token') + ) +} diff --git a/test/firebaseErrors.test.ts b/test/firebaseErrors.test.ts new file mode 100644 index 0000000..5110d22 --- /dev/null +++ b/test/firebaseErrors.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { isUnregisteredToken } from '../src/util/firebaseErrors' + +describe('isUnregisteredToken', function () { + it('matches the error code the current Firebase SDK reports', function () { + const error = Object.assign(new Error('Requested entity was not found.'), { + errorInfo: { code: 'messaging/registration-token-not-registered' } + }) + expect(isUnregisteredToken(error)).equals(true) + }) + + it('matches a bare code property', function () { + const error = Object.assign(new Error('nope'), { + code: 'messaging/registration-token-not-registered' + }) + expect(isUnregisteredToken(error)).equals(true) + }) + + it('matches what the legacy send API stringifies to', function () { + // This is the exact shape seen in production against firebase-admin 8: + expect(isUnregisteredToken(new Error('NotRegistered'))).equals(true) + }) + + it('still matches the original wording', function () { + expect( + isUnregisteredToken( + new Error('abc is not a valid FCM registration token') + ) + ).equals(true) + }) + + it('leaves other failures alone, so a device is never disabled by mistake', function () { + const cases: unknown[] = [ + new Error('Requested entity was not found'), // no code: too vague to act on + Object.assign(new Error('quota'), { + errorInfo: { code: 'messaging/quota-exceeded' } + }), + Object.assign(new Error('unavailable'), { + errorInfo: { code: 'messaging/server-unavailable' } + }), + new Error('socket hang up'), + 'a plain string', + undefined, + null + ] + for (const error of cases) { + expect(isUnregisteredToken(error), String(error)).equals(false) + } + }) +}) diff --git a/test/locationFilter.test.ts b/test/locationFilter.test.ts new file mode 100644 index 0000000..f5eb3c8 --- /dev/null +++ b/test/locationFilter.test.ts @@ -0,0 +1,307 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + findStoredNames, + getDeviceSkipReason, + getFilterProblem, + LocationFilter, + matchesLocationFilter, + parseLocationList +} from '../src/server/marketing/locationFilter' +import { Device } from '../src/types/pushTypes' + +const emptyFilter: LocationFilter = { + countryInclude: [], + countryExclude: [], + cityInclude: [], + cityExclude: [], + regionInclude: [], + regionExclude: [] +} + +const makeFilter = (parts: Partial): LocationFilter => ({ + ...emptyFilter, + ...parts +}) + +const makeLocation = (city: string, region: string): Device['location'] => ({ + country: 'United States', + city, + region +}) + +const nyc = makeLocation('New York', 'NY') +const buffalo = makeLocation('Buffalo', 'NY') +const sanDiego = makeLocation('San Diego', 'CA') + +describe('parseLocationList', function () { + it('trims, lowercases, and drops blank lines', function () { + expect(parseLocationList([' New York ', '', ' ', 'BUFFALO'])).deep.equals( + ['new york', 'buffalo'] + ) + }) + + it('removes duplicates', function () { + expect(parseLocationList(['NY', 'ny', ' Ny '])).deep.equals(['ny']) + }) +}) + +describe('matchesLocationFilter', function () { + it('passes everything when no filters are set', function () { + expect(matchesLocationFilter(nyc, emptyFilter)).equals(true) + }) + + it('rejects devices with no location', function () { + expect(matchesLocationFilter(undefined, emptyFilter)).equals(false) + }) + + it('treats each include line as an alternative', function () { + const filter = makeFilter({ cityInclude: ['buffalo', 'san diego'] }) + expect(matchesLocationFilter(buffalo, filter)).equals(true) + expect(matchesLocationFilter(sanDiego, filter)).equals(true) + expect(matchesLocationFilter(nyc, filter)).equals(false) + }) + + it('treats each exclude line as an alternative', function () { + const filter = makeFilter({ cityExclude: ['buffalo', 'san diego'] }) + expect(matchesLocationFilter(buffalo, filter)).equals(false) + expect(matchesLocationFilter(sanDiego, filter)).equals(false) + expect(matchesLocationFilter(nyc, filter)).equals(true) + }) + + it('lets an exclude override an include', function () { + const filter = makeFilter({ + cityInclude: ['new york', 'buffalo'], + cityExclude: ['buffalo'] + }) + expect(matchesLocationFilter(nyc, filter)).equals(true) + expect(matchesLocationFilter(buffalo, filter)).equals(false) + }) + + it('ANDs the city and region filters together', function () { + const filter = makeFilter({ + cityInclude: ['buffalo'], + regionExclude: ['ny'] + }) + expect(matchesLocationFilter(buffalo, filter)).equals(false) + expect(matchesLocationFilter(makeLocation('Buffalo', 'WY'), filter)).equals( + true + ) + }) + + it('matches regions by their stored two-letter code', function () { + const filter = makeFilter({ regionInclude: ['ny'] }) + expect(matchesLocationFilter(nyc, filter)).equals(true) + expect(matchesLocationFilter(sanDiego, filter)).equals(false) + + // Region names are not aliased to codes: + expect( + matchesLocationFilter(nyc, makeFilter({ regionInclude: ['new york'] })) + ).equals(false) + }) + + it('ignores case and surrounding whitespace on both sides', function () { + const filter = makeFilter({ cityExclude: ['new york'] }) + expect( + matchesLocationFilter(makeLocation(' NEW YORK ', 'NY'), filter) + ).equals(false) + }) + + it('handles devices with a blank region', function () { + const blank = makeLocation('Buffalo', '') + expect( + matchesLocationFilter(blank, makeFilter({ regionInclude: ['ny'] })) + ).equals(false) + expect( + matchesLocationFilter(blank, makeFilter({ regionExclude: ['ny'] })) + ).equals(true) + }) + + it('excludes New York from a nationwide send', function () { + // "United States, exclude New York, empty Region boxes" + const filter = makeFilter({ cityExclude: ['new york'] }) + expect(matchesLocationFilter(nyc, filter)).equals(false) + expect(matchesLocationFilter(buffalo, filter)).equals(true) + expect(matchesLocationFilter(sanDiego, filter)).equals(true) + }) + + it('reaches every country but the excluded ones', function () { + const filter = makeFilter({ + countryExclude: parseLocationList(['United States', 'United Kingdom']) + }) + const lagos = { country: 'Nigeria', region: 'LA', city: 'Lagos' } + const london = { country: 'United Kingdom', region: 'ENG', city: 'London' } + expect(matchesLocationFilter(lagos, filter)).equals(true) + expect(matchesLocationFilter(london, filter)).equals(false) + expect(matchesLocationFilter(nyc, filter)).equals(false) + }) + + it('narrows to the included countries', function () { + const filter = makeFilter({ + countryInclude: parseLocationList(['nigeria']) + }) + const lagos = { country: 'Nigeria', region: 'LA', city: 'Lagos' } + expect(matchesLocationFilter(lagos, filter)).equals(true) + expect(matchesLocationFilter(nyc, filter)).equals(false) + }) + + it('matches nothing when the city and region filters conflict', function () { + // "include New York City, Region exclude NY". Devices store the city as + // "New York", so the include alone already matches nothing. + const filter = makeFilter({ + cityInclude: ['new york city'], + regionExclude: ['ny'] + }) + for (const location of [nyc, buffalo, sanDiego]) { + expect(matchesLocationFilter(location, filter)).equals(false) + } + }) +}) + +describe('getFilterProblem', function () { + it('allows region and city rules inside one country', function () { + const filter = makeFilter({ + countryInclude: ['united states'], + regionExclude: ['ny'] + }) + expect(getFilterProblem(filter)).equals(undefined) + }) + + it('allows any country-only filter', function () { + expect(getFilterProblem(emptyFilter)).equals(undefined) + expect( + getFilterProblem( + makeFilter({ countryExclude: ['united states', 'united kingdom'] }) + ) + ).equals(undefined) + expect( + getFilterProblem(makeFilter({ countryInclude: ['nigeria', 'ghana'] })) + ).equals(undefined) + }) + + it('refuses region or city rules across several countries', function () { + const two = makeFilter({ + countryInclude: ['nigeria', 'ghana'], + regionExclude: ['la'] + }) + expect(getFilterProblem(two)).contains('exactly one country') + const all = makeFilter({ cityInclude: ['santa cruz'] }) + expect(getFilterProblem(all)).contains('exactly one country') + const excludeOnly = makeFilter({ + countryExclude: ['united states'], + regionInclude: ['la'] + }) + expect(getFilterProblem(excludeOnly)).contains('exactly one country') + }) +}) + +describe('getDeviceSkipReason', function () { + const targets = new Set(['edge-app-key', 'white-label-key']) + const makeDevice = (parts: Partial = {}): Device => ({ + created: new Date(), + deviceId: 'device-1', + apiKey: 'edge-app-key', + deviceToken: 'valid-token:123', + ignoreMarketing: false, + ignorePriceChanges: false, + loginIds: [], + visited: new Date(), + ...parts + }) + + it('accepts a sendable device under any targeted key', function () { + expect(getDeviceSkipReason(makeDevice(), targets)).equals(undefined) + expect( + getDeviceSkipReason(makeDevice({ apiKey: 'white-label-key' }), targets) + ).equals(undefined) + }) + + it('skips devices that opted out of marketing', function () { + expect( + getDeviceSkipReason(makeDevice({ ignoreMarketing: true }), targets) + ).equals('ignore-marketing') + }) + + it('skips devices outside the targeted API keys', function () { + expect( + getDeviceSkipReason(makeDevice({ apiKey: 'other-key' }), targets) + ).equals('unregistered') + expect( + getDeviceSkipReason(makeDevice({ apiKey: undefined }), targets) + ).equals('unregistered') + expect(getDeviceSkipReason(makeDevice(), new Set())).equals('unregistered') + }) + + it('skips devices with a missing or blank token', function () { + expect( + getDeviceSkipReason(makeDevice({ deviceToken: undefined }), targets) + ).equals('unregistered') + expect( + getDeviceSkipReason(makeDevice({ deviceToken: ' ' }), targets) + ).equals('unregistered') + }) + + it('skips devices with a malformed token', function () { + expect( + getDeviceSkipReason(makeDevice({ deviceToken: 'has spaces' }), targets) + ).equals('invalid-token') + expect( + getDeviceSkipReason(makeDevice({ deviceToken: 'back\\slash' }), targets) + ).equals('invalid-token') + }) + + it('judges a view summary the same as a whole document', function () { + // The audience query applies this to `apiKeyLocation` view rows while the + // send applies it to documents, so both shapes must reach the same answer: + const cases: Array> = [ + {}, + { ignoreMarketing: true }, + { apiKey: 'other-key' }, + { deviceToken: undefined }, + { deviceToken: 'has spaces' } + ] + for (const parts of cases) { + const device = makeDevice(parts) + const summary = { + apiKey: device.apiKey, + deviceToken: device.deviceToken, + ignoreMarketing: device.ignoreMarketing + } + expect(getDeviceSkipReason(summary, targets)).equals( + getDeviceSkipReason(device, targets) + ) + } + }) +}) + +describe('findStoredNames', function () { + const stored = ['Nigeria', 'United States', 'United Kingdom'] + + it('finds the stored spelling however the country was typed', function () { + for (const typed of ['Nigeria', 'nigeria', 'NIGERIA', ' nigeria ']) { + const [value] = parseLocationList([typed]) + expect(findStoredNames(stored, value)).deep.equals(['Nigeria']) + } + }) + + it('returns every stored spelling when the data disagrees with itself', function () { + const messy = [...stored, 'NIGERIA', ' Nigeria'] + expect(findStoredNames(messy, 'nigeria')).deep.equals([ + 'Nigeria', + 'NIGERIA', + ' Nigeria' + ]) + }) + + it('finds nothing for a name no device carries, as the filter would', function () { + expect(findStoredNames(stored, 'france')).deep.equals([]) + expect(findStoredNames([], 'nigeria')).deep.equals([]) + }) + + it('matches the stored spelling exactly, not as a prefix', function () { + expect(findStoredNames(['Niger', 'Nigeria'], 'niger')).deep.equals([ + 'Niger' + ]) + }) +}) diff --git a/test/marketingData.test.ts b/test/marketingData.test.ts new file mode 100644 index 0000000..e7031ab --- /dev/null +++ b/test/marketingData.test.ts @@ -0,0 +1,29 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { makeMarketingData } from '../src/server/marketing/marketingData' + +// The app's push parser (`parsePushMessage` in edge-react-gui) matches on this +// exact shape, so these pin the contract from the sending side. +describe('makeMarketingData', function () { + it('marks the push as marketing and carries the campaign id', function () { + expect(makeMarketingData('abc123')).deep.equals({ + type: 'marketing', + campaignId: 'abc123' + }) + }) + + it('adds the deep link when one is given', function () { + expect(makeMarketingData('abc123', 'edge://swap')).deep.equals({ + type: 'marketing', + campaignId: 'abc123', + url: 'edge://swap' + }) + }) + + it('omits the url key entirely when there is no link', function () { + // FCM rejects non-string data values, so `url: undefined` would fail the + // whole send rather than just skip navigation: + expect(makeMarketingData('abc123', undefined)).not.has.property('url') + }) +}) diff --git a/test/marketingLink.test.ts b/test/marketingLink.test.ts new file mode 100644 index 0000000..a09b580 --- /dev/null +++ b/test/marketingLink.test.ts @@ -0,0 +1,147 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { + ALLOWED_LINK_KINDS, + getLinkProblem +} from '../src/server/marketing/marketingLink' + +describe('getLinkProblem', function () { + it('allows the links marketing sends', function () { + const allowed = [ + 'edge://buy', + 'edge://buy/moonpay', + 'edge://buy/moonpay/credit', + 'edge://sell/paybis/sepa', + 'edge://swap', + 'edge://exchange/swap', + 'edge://exchange/swap?buyAsset=monero&sellAsset=bitcoin', + 'edge://exchange/buy?buyAsset=ethereum_0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&promoId=xmr-challenge', + 'edge://exchange/sell?sellAsset=bitcoin', + 'edge://promotion/nexchange', + 'edge://plugin/creditcard', + 'edge://plugin/creditcard/buy/moonpay/credit', + 'edge://plugin/sepa/sell', + 'edge://modal/fundAccount', + 'edge://scene/earnScene', + 'https://deep.edge.app/swap?af=nexchange', + 'https://deep.edge.app/buy/moonpay/credit?af=onezetty', + 'https://deep.edge.app/exchange/swap?af=qaswapgate&buyAsset=monero', + 'https://deep.edge.app/promotion/nexchange' + ] + for (const url of allowed) { + expect(getLinkProblem(url), url).equals(undefined) + } + }) + + it('refuses links that spend, sign, log in, recover, or leak addresses', function () { + const refused = [ + 'edge://wc?uri=wc%3Aabc', + 'edge://pay/bitcoin/1BoatSLRHtKNngkdXEeobR76b53LETtpyT?amount=1', + 'bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT?amount=1', + 'edge://https/bitpay.com/i/abc', + 'edge://redirect/payment?baseCurrencyCode=btc&depositWalletAddress=1abc', + 'https://edge.app/redirect/payment?baseCurrencyCode=btc&depositWalletAddress=1abc', + 'edge://edge/lobby123', + 'edge://recovery?token=abc', + 'edge://recovery#abc', + 'edge://reqaddr?codes=BTC&post=https://evil.example', + 'reqaddr://?codes=BTC&redir=https://evil.example', + 'edge://scene/send2?walletId=abc', + 'edge://fiatprovider/buy/simplex', + 'edge://ramp/buy/paybis', + 'https://edge.app/rewards?data=x' + ] + for (const url of refused) { + expect(getLinkProblem(url), url).a('string') + } + }) + + it('allows any plugin id, but never a query', function () { + for (const id of [ + 'creditcard', + 'bitrefill', + 'moonpay', + 'libertyx', + 'xanpool' + ]) { + expect(getLinkProblem(`edge://plugin/${id}`), id).equals(undefined) + } + expect(getLinkProblem('edge://plugin/libertyx/some/partner/page')).equals( + undefined + ) + expect( + getLinkProblem('https://deep.edge.app/plugin/bitrefill?af=x') + ).equals(undefined) + // The app merges a link's query over the plugin's own baseQuery and hands + // it to the partner page, api key and referral code included: + expect(getLinkProblem('edge://plugin/moonpay?apiKey=pk_live_x')).a('string') + expect(getLinkProblem('edge://plugin/bitrefill?ref=other')).a('string') + expect(getLinkProblem('edge://plugin/libertyx?address=1abc')).a('string') + expect(getLinkProblem('edge://plugin')).a('string') + expect( + getLinkProblem('edge://plugin/creditcard/buy/moonpay/credit/extra') + ).a('string') + }) + + it('refuses spellings the app would not follow from a push', function () { + const refused = [ + 'airbitz://buy', + 'https://dp.edge.app/buy', + 'https://dl.edge.app/?af=nexchange', + 'http://deep.edge.app/buy', + 'EDGE://buy', + 'edge://', + 'edge://buy/', + 'edge://swap/', + 'edge://swap?', + 'edge://swap#top', + 'edge://buy/moonpay/credit/extra', + 'edge://exchange', + 'edge://exchange/transfer', + 'edge://exchange/swap?buyAsset=', + 'edge://exchange/swap?buyAsset=a&buyAsset=b', + 'edge://exchange/swap?buyAsset=a%20b', + 'edge://exchange/swap?buyAsset=a?b', + 'edge://buy?foo=bar', + 'edge://promotion', + 'edge://promotion/a/b', + 'edge://modal/test', + 'edge://scene/earnScene?x=1', + 'edge://scene/walletList', + 'edge://' + 'x'.repeat(2000) + ] + for (const url of refused) { + expect(getLinkProblem(url), url).a('string') + } + }) + + it('refuses dot segments, which a WHATWG parser would collapse into another kind', function () { + const traversals = [ + 'https://deep.edge.app/buy/../pay/bitcoin/1abc?af=x', + 'https://deep.edge.app/plugin/../edge/lobby?af=x', + 'https://deep.edge.app/plugin/../scene/send2?af=x', + 'edge://buy/../recovery', + 'edge://plugin/x/./y', + 'edge://plugin/..', + 'edge://buy/.' + ] + for (const url of traversals) { + expect(getLinkProblem(url), url).contains('"."') + } + }) + + it('refuses af on the edge:// form, which the app drops silently', function () { + const problem = getLinkProblem('edge://swap?af=nexchange') + expect(problem).contains('https://deep.edge.app/') + expect(getLinkProblem('https://deep.edge.app/swap?af=nexchange')).equals( + undefined + ) + }) + + it('names the allowed kinds when it refuses an unknown one', function () { + const problem = getLinkProblem('edge://wc?uri=x') + expect(problem).contains('"wc"') + expect(problem).contains(ALLOWED_LINK_KINDS) + }) +}) diff --git a/test/marketingTool.test.ts b/test/marketingTool.test.ts new file mode 100644 index 0000000..70b2d42 --- /dev/null +++ b/test/marketingTool.test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai' +import { NextFunction, Request, Response } from 'express' +import { describe, it } from 'mocha' + +import { + asPushBody, + getPushTarget, + handleRouterError +} from '../src/server/marketing/marketingTool' + +const filter = { + countryInclude: ['United States'], + countryExclude: [], + cityInclude: [], + cityExclude: [], + regionInclude: [], + regionExclude: ['NY'] +} + +describe('asPushBody', function () { + it('requires the dryRun flag, so a forgotten flag never sends', function () { + expect(() => asPushBody({ body: 'hi', deviceId: 'abc' })).throws() + }) + + it('accepts a filter with no countries at all, meaning every country', function () { + const parsed = asPushBody({ + filter: { countryExclude: ['United States', 'United Kingdom'] }, + dryRun: true + }) + expect(parsed.filter?.countryInclude).deep.equals([]) + expect(parsed.filter?.countryExclude).deep.equals([ + 'United States', + 'United Kingdom' + ]) + }) + + it('accepts a bare dry run of a filter, with no message', function () { + const parsed = asPushBody({ filter, dryRun: true }) + expect(parsed.filter?.regionExclude).deep.equals(['NY']) + expect(parsed.title).equals(undefined) + }) +}) + +describe('getPushTarget', function () { + const base = { dryRun: true } + + it('recognizes each way of naming the audience', function () { + expect(getPushTarget(asPushBody({ ...base, deviceId: 'abc' }))).equals( + 'device' + ) + expect(getPushTarget(asPushBody({ ...base, loginId: 'aGVsbG8=' }))).equals( + 'login' + ) + expect(getPushTarget(asPushBody({ ...base, deviceIds: ['a'] }))).equals( + 'list' + ) + expect(getPushTarget(asPushBody({ ...base, filter }))).equals('filter') + }) + + it('refuses a body that names none', function () { + expect(getPushTarget(asPushBody(base))).equals(undefined) + }) + + it('refuses a body that names two, rather than guessing', function () { + const both = asPushBody({ ...base, deviceIds: ['a'], filter }) + expect(getPushTarget(both)).equals(undefined) + const test = asPushBody({ ...base, deviceId: 'abc', loginId: 'aGVsbG8=' }) + expect(getPushTarget(test)).equals(undefined) + }) + + it('treats an empty list as a list, so it can be refused as empty', function () { + expect(getPushTarget(asPushBody({ ...base, deviceIds: [] }))).equals('list') + }) +}) + +describe('handleRouterError', function () { + interface Sent { + status?: number + body?: unknown + } + function fakeResponse(headersSent = false): { res: Response; sent: Sent } { + const sent: Sent = {} + const res: { + headersSent: boolean + status: (code: number) => unknown + json: (body: unknown) => unknown + } = { + headersSent, + status: code => { + sent.status = code + return res + }, + json: body => { + sent.body = body + return res + } + } + return { res: res as unknown as Response, sent } + } + const req = {} as unknown as Request + const next: NextFunction = () => undefined + + it('answers a body-parser error with its status and the JSON shape', function () { + const { res, sent } = fakeResponse() + const error = Object.assign(new Error('request entity too large'), { + status: 413 + }) + handleRouterError(error, req, res, next) + expect(sent).deep.equals({ + status: 413, + body: { error: 'request entity too large' } + }) + }) + + it('falls back to 500 when the error carries no status', function () { + const { res, sent } = fakeResponse() + handleRouterError(new Error('boom'), req, res, next) + expect(sent).deep.equals({ status: 500, body: { error: 'boom' } }) + }) + + it('defers to Express once the headers are out', function () { + const { res, sent } = fakeResponse(true) + let passed: unknown + handleRouterError(new Error('late'), req, res, error => { + passed = error + }) + expect(sent).deep.equals({}) + expect(passed).instanceOf(Error) + }) +}) diff --git a/test/progress.test.ts b/test/progress.test.ts new file mode 100644 index 0000000..0a877fd --- /dev/null +++ b/test/progress.test.ts @@ -0,0 +1,102 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { makeProgressLog, ProgressLog } from '../src/server/marketing/progress' + +interface Harness { + lines: string[] + log: ProgressLog + tick: (ms: number) => number +} + +/** Collects written lines, with a clock the test drives by hand. */ +const setup = (total: number, intervalMs = 30_000): Harness => { + const lines: string[] = [] + let clock = 0 + const log = makeProgressLog( + chunk => lines.push(chunk.replace(/\n$/, '')), + 'Queued', + total, + { intervalMs, now: () => clock } + ) + return { lines, log, tick: (ms: number) => (clock += ms) } +} + +describe('makeProgressLog', function () { + it('says nothing before the first interval elapses', function () { + const { lines, log, tick } = setup(1000) + log.update(100) + tick(29_000) + log.update(500) + expect(lines).deep.equals([]) + }) + + it('reports a percentage once the interval passes', function () { + const { lines, log, tick } = setup(1000) + tick(30_000) + log.update(250) + expect(lines).lengthOf(1) + expect(lines[0]).contains('Queued: 25%') + expect(lines[0]).contains('250 of 1,000') + }) + + it('emits at most one line per interval', function () { + const { lines, log, tick } = setup(1000) + tick(30_000) + log.update(250) + log.update(300) + log.update(400) + expect(lines).lengthOf(1) + tick(30_000) + log.update(600) + expect(lines).lengthOf(2) + expect(lines[1]).contains('60%') + }) + + it('does not emit a burst after a long stall', function () { + const { lines, log, tick } = setup(1000) + // Five intervals pass with no updates at all: + tick(150_000) + log.update(900) + expect(lines).lengthOf(1) + }) + + it('includes a rate and an estimate of the time left', function () { + const { lines, log, tick } = setup(1000) + tick(30_000) // 300 done in 30s = 10/s, 700 left = 70s + log.update(300) + expect(lines[0]).contains('10/s') + expect(lines[0]).contains('70s left') + }) + + it('closes with a total and elapsed time, and no estimate', function () { + const { lines, log, tick } = setup(1000) + tick(50_000) + log.finish(1000) + expect(lines).lengthOf(1) + expect(lines[0]).contains('100%') + expect(lines[0]).contains('1,000 of 1,000') + expect(lines[0]).contains('in 50s') + expect(lines[0]).does.not.contain('left') + }) + + it('formats long estimates in minutes and hours', function () { + const a = setup(1_000_000) + a.tick(30_000) // 10k in 30s = 333/s -> ~2970s left + a.log.update(10_000) + expect(a.lines[0]).contains('m left') + + const b = setup(100_000_000) + b.tick(30_000) + b.log.update(10_000) + expect(b.lines[0]).contains('h left') + }) + + it('survives an empty job without dividing by zero', function () { + const { lines, log } = setup(0) + log.finish(0) + expect(lines[0]).contains('100%') + expect(lines[0]).does.not.contain('NaN') + expect(lines[0]).does.not.contain('Infinity') + }) +})