diff --git a/src/i8n.ts b/src/i8n.ts new file mode 100644 index 0000000..a1d2157 --- /dev/null +++ b/src/i8n.ts @@ -0,0 +1,134 @@ +import _ from "underscore"; + +export type LANG = "en-US" | "nl-NL"; +export type KEY = + | "AppLinkMessage" + | "artists" + | "albums" + | "playlists" + | "genres" + | "random" + | "starred" + | "recentlyAdded" + | "recentlyPlayed" + | "mostPlayed" + | "tracks" + | "success" + | "failure" + | "expectedConfig" + | "existingServiceConfig" + | "noExistingServiceRegistration" + | "register" + | "removeRegistration" + | "devices" + | "services" + | "login" + | "logInToBonob" + | "username" + | "password" + | "successfullyRegistered" + | "registrationFailed" + | "successfullyRemovedRegistration" + | "failedToRemoveRegistration" + | "invalidLinkCode" + | "loginSuccessful" + | "loginFailed"; + +const translations: Record> = { + "en-US": { + AppLinkMessage: "Linking sonos with $BONOB_SONOS_SERVICE_NAME", + artists: "Artists", + albums: "Albums", + tracks: "Tracks", + playlists: "Playlists", + genres: "Genres", + random: "Random", + starred: "Starred", + recentlyAdded: "Recently added", + recentlyPlayed: "Recently played", + mostPlayed: "Most played", + success: "Success", + failure: "Failure", + expectedConfig: "Expected configuration", + existingServiceConfig: "Existing service configuration", + noExistingServiceRegistration: "No existing service registration", + register: "Register", + removeRegistration: "Remove registration", + devices: "Devices", + services: "Services", + login: "Login", + logInToBonob: "Log in to $BONOB_SONOS_SERVICE_NAME", + username: "Username", + password: "Password", + successfullyRegistered: "Successfully registered", + registrationFailed: "Registration failed!", + successfullyRemovedRegistration: "Successfully removed registration", + failedToRemoveRegistration: "Failed to remove registration!", + invalidLinkCode: "Invalid linkCode!", + loginSuccessful: "Login successful!", + loginFailed: "Login failed!", + }, + "nl-NL": { + AppLinkMessage: "Sonos koppelen aan $BONOB_SONOS_SERVICE_NAME", + artists: "Artiesten", + albums: "Albums", + tracks: "Nummers", + playlists: "Afspeellijsten", + genres: "Genres", + random: "Willekeurig", + starred: "Favorieten", + recentlyAdded: "Onlangs toegevoegd", + recentlyPlayed: "Onlangs afgespeeld", + mostPlayed: "Meest afgespeeld", + success: "Gelukt", + failure: "Mislukt", + expectedConfig: "Verwachte configuratie", + existingServiceConfig: "Bestaande serviceconfiguratie", + noExistingServiceRegistration: "Geen bestaande serviceregistratie", + register: "Register", + removeRegistration: "Verwijder registratie", + devices: "Apparaten", + services: "Services", + login: "Inloggen", + logInToBonob: "Login op $BONOB_SONOS_SERVICE_NAME", + username: "Gebruikersnaam", + password: "Wachtwoord", + successfullyRegistered: "Registratie geslaagd", + registrationFailed: "Registratie mislukt!", + successfullyRemovedRegistration: "Registratie succesvol verwijderd", + failedToRemoveRegistration: "Kon registratie niet verwijderen!", + invalidLinkCode: "Ongeldige linkcode!", + loginSuccessful: "Inloggen gelukt!", + loginFailed: "Inloggen mislukt!", + }, +}; + +export const randomLang = () => _.shuffle(["en-US", "nl-NL"])[0]!; + +export const asLANGs = (acceptLanguageHeader: string | undefined) => { + const z = acceptLanguageHeader?.split(";")[0]; + return z && z != "" ? z.split(",") : []; +}; + +export type I8N = (...langs: string[]) => Lang; + +export type Lang = (key: KEY) => string; + +export const langs = () => Object.keys(translations); + +export const keys = (lang: LANG = "en-US") => Object.keys(translations[lang]); + +export default (serviceName: string): I8N => + (...langs: string[]): Lang => { + const langToUse = + langs.map((l) => translations[l as LANG]).find((it) => it) || + translations["en-US"]; + return (key: KEY) => { + const value = langToUse[key]?.replace( + "$BONOB_SONOS_SERVICE_NAME", + serviceName + ); + if (value) return value; + else throw `No translation found for ${langs}:${key}`; + }; + }; diff --git a/src/server.ts b/src/server.ts index 19a19f8..fd046f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,11 @@ import { option as O } from "fp-ts"; -import express, { Express } from "express"; +import express, { Express, Request } from "express"; import * as Eta from "eta"; import morgan from "morgan"; import { PassThrough, Transform, TransformCallback } from "stream"; -import { Sonos, Service } from "./sonos"; +import { Sonos, Service, SONOS_LANG } from "./sonos"; import { SOAP_PATH, STRINGS_ROUTE, @@ -23,6 +23,7 @@ import logger from "./logger"; import { Clock, SystemClock } from "./clock"; import { pipe } from "fp-ts/lib/function"; import { URLBuilder } from "./url_builder"; +import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -74,6 +75,7 @@ function server( applyContextPath = true ): Express { const app = express(); + const i8n = makeI8N(service.name); app.use(morgan("combined")); app.use(express.urlencoded({ extended: false })); @@ -85,13 +87,17 @@ function server( app.set("view engine", "eta"); app.set("views", "./web/views"); - app.get("/", (_, res) => { + const langFor = (req: Request) => i8n(...asLANGs(req.headers["accept-language"])) + + app.get("/", (req, res) => { + const lang = langFor(req); Promise.all([sonos.devices(), sonos.services()]).then( ([devices, services]) => { const registeredBonobService = services.find( (it) => it.sid == service.sid ); res.render("index", { + lang, devices, services, bonobService: service, @@ -112,47 +118,56 @@ function server( }); }); - app.post(CREATE_REGISTRATION_ROUTE, (_, res) => { + app.post(CREATE_REGISTRATION_ROUTE, (req, res) => { + const lang = langFor(req); sonos.register(service).then((success) => { if (success) { res.render("success", { - message: `Successfully registered`, + lang, + message: lang("successfullyRegistered"), }); } else { res.status(500).render("failure", { - message: `Registration failed!`, + lang, + message: lang("registrationFailed"), }); } }); }); - app.post(REMOVE_REGISTRATION_ROUTE, (_, res) => { + app.post(REMOVE_REGISTRATION_ROUTE, (req, res) => { + const lang = langFor(req); sonos.remove(service.sid).then((success) => { if (success) { res.render("success", { - message: `Successfully removed registration`, + lang, + message: lang("successfullyRemovedRegistration"), }); } else { res.status(500).render("failure", { - message: `Failed to remove registration!`, + lang, + message: lang("failedToRemoveRegistration"), }); } }); }); app.get(LOGIN_ROUTE, (req, res) => { + const lang = langFor(req); res.render("login", { - bonobService: service, + lang, linkCode: req.query.linkCode, loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }); }); app.post(LOGIN_ROUTE, async (req, res) => { + const lang = langFor(req); const { username, password, linkCode } = req.body; if (!linkCodes.has(linkCode)) { res.status(400).render("failure", { - message: `Invalid linkCode!`, + lang, + message: lang("invalidLinkCode"), }); } else { const authResult = await musicService.generateToken({ @@ -162,25 +177,26 @@ function server( if (isSuccess(authResult)) { linkCodes.associate(linkCode, authResult); res.render("success", { - message: `Login successful!`, + lang, + message: lang("loginSuccessful"), }); } else { res.status(403).render("failure", { - message: `Login failed! ${authResult.message}!`, + lang, + message: lang("loginFailed"), + cause: authResult.message }); } } }); app.get(STRINGS_ROUTE, (_, res) => { + const stringNode = (id: string, value: string) => `` + const stringtableNode = (langName: string) => `${i8nKeys().map(key => stringNode(key, i8n(langName as LANG)(key as KEY))).join("")}` + res.type("application/xml").send(` - - Linking sonos with ${service.name} - - - Lier les sonos à la ${service.name} - + ${SONOS_LANG.map(stringtableNode).join("")} `); }); @@ -192,9 +208,9 @@ function server( ${SONOS_RECOMMENDED_IMAGE_SIZES.map( - (size) => - `` - ).join("")} + (size) => + `` + ).join("")} @@ -236,8 +252,7 @@ function server( ) .then(({ musicLibrary, stream }) => { logger.info( - `stream response from music service for ${id}, status=${ - stream.status + `stream response from music service for ${id}, status=${stream.status }, headers=(${JSON.stringify(stream.headers)})` ); @@ -351,7 +366,8 @@ function server( linkCodes, musicService, accessTokens, - clock + clock, + i8n ); if (applyContextPath) { diff --git a/src/smapi.ts b/src/smapi.ts index 75ba96c..f13182b 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -1,10 +1,11 @@ import crypto from "crypto"; -import { Express } from "express"; +import { Express, Request } from "express"; import { listen } from "soap"; import { readFileSync } from "fs"; import path from "path"; import logger from "./logger"; + import { LinkCodes } from "./link_codes"; import { Album, @@ -21,6 +22,7 @@ import { AccessTokens } from "./access_tokens"; import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; +import { I8N, LANG } from "./i8n"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -277,9 +279,9 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ const auth = async ( musicService: MusicService, accessTokens: AccessTokens, - headers?: SoapyHeaders + credentials?: Credentials ) => { - if (!headers?.credentials) { + if (!credentials) { throw { Fault: { faultcode: "Client.LoginUnsupported", @@ -287,7 +289,7 @@ const auth = async ( }, }; } - const authToken = headers.credentials.loginToken.token; + const authToken = credentials.loginToken.token; const accessToken = accessTokens.mint(authToken); return musicService @@ -327,9 +329,11 @@ function bindSmapiSoapServiceToExpress( linkCodes: LinkCodes, musicService: MusicService, accessTokens: AccessTokens, - clock: Clock + clock: Clock, + i8n: I8N ) { const sonosSoap = new SonosSoap(bonobUrl, linkCodes); + const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { @@ -357,9 +361,9 @@ function bindSmapiSoapServiceToExpress( getMediaURI: async ( { id }: { id: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ accessToken, type, typeId }) => ({ getMediaURIResult: bonobUrl @@ -377,9 +381,9 @@ function bindSmapiSoapServiceToExpress( getMediaMetadata: async ( { id }: { id: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ @@ -392,9 +396,9 @@ function bindSmapiSoapServiceToExpress( search: async ( { id, term }: { id: string; term: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken }) => { switch (id) { @@ -437,9 +441,9 @@ function bindSmapiSoapServiceToExpress( }: // recursive, { id: string; index: number; count: number; recursive: boolean }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(async ({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; @@ -525,14 +529,16 @@ function bindSmapiSoapServiceToExpress( }: // recursive, { id: string; index: number; count: number; recursive: boolean }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, accessToken, type, typeId }) => { const paging = { _index: index, _count: count }; + const lang = i8n((headers["accept-language"] || "en-US") as LANG); logger.debug( - `Fetching metadata type=${type}, typeId=${typeId}` + `Fetching metadata type=${type}, typeId=${typeId}, lang=${lang}` ); const albums = (q: AlbumQuery): Promise => @@ -553,17 +559,17 @@ function bindSmapiSoapServiceToExpress( { itemType: "container", id: "artists", - title: "Artists", + title: lang("artists"), }, { itemType: "albumList", id: "albums", - title: "Albums", + title: lang("albums"), }, { itemType: "playlist", id: "playlists", - title: "Playlists", + title: lang("playlists"), attributes: { readOnly: false, userContent: true, @@ -573,32 +579,32 @@ function bindSmapiSoapServiceToExpress( { itemType: "container", id: "genres", - title: "Genres", + title: lang("genres"), }, { itemType: "albumList", id: "randomAlbums", - title: "Random", + title: lang("random"), }, { itemType: "albumList", id: "starredAlbums", - title: "Starred", + title: lang("starred"), }, { itemType: "albumList", id: "recentlyAdded", - title: "Recently Added", + title: lang("recentlyAdded"), }, { itemType: "albumList", id: "recentlyPlayed", - title: "Recently Played", + title: lang("recentlyPlayed"), }, { itemType: "albumList", id: "mostPlayed", - title: "Most Played", + title: lang("mostPlayed"), }, ], index: 0, @@ -607,9 +613,9 @@ function bindSmapiSoapServiceToExpress( case "search": return getMetadataResult({ mediaCollection: [ - { itemType: "search", id: "artists", title: "Artists" }, - { itemType: "search", id: "albums", title: "Albums" }, - { itemType: "search", id: "tracks", title: "Tracks" }, + { itemType: "search", id: "artists", title: lang("artists") }, + { itemType: "search", id: "albums", title: lang("albums") }, + { itemType: "search", id: "tracks", title: lang("tracks") }, ], index: 0, total: 3, @@ -746,9 +752,9 @@ function bindSmapiSoapServiceToExpress( createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary .createPlaylist(title) @@ -772,17 +778,17 @@ function bindSmapiSoapServiceToExpress( deleteContainer: async ( { id }: { id: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then((_) => ({ deleteContainerResult: {} })), addToContainer: async ( { id, parentId }: { id: string; parentId: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) @@ -791,9 +797,9 @@ function bindSmapiSoapServiceToExpress( removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then((it) => ({ ...it, @@ -814,9 +820,9 @@ function bindSmapiSoapServiceToExpress( setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, - headers?: SoapyHeaders + soapyHeaders: SoapyHeaders, ) => - auth(musicService, accessTokens, headers) + auth(musicService, accessTokens, soapyHeaders?.credentials) .then(splitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { diff --git a/src/sonos.ts b/src/sonos.ts index 455b43d..3d99c99 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -8,7 +8,9 @@ import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; import qs from "querystring"; import { URLBuilder } from "./url_builder"; -export const PRESENTATION_AND_STRINGS_VERSION = "18"; +export const SONOS_LANG = ["en-US", "da-DK", "de-DE", "es-ES", "fr-FR", "it-IT", "ja-JP", "nb-NO", "nl-NL", "pt-BR", "sv-SE", "zh-CN"] + +export const PRESENTATION_AND_STRINGS_VERSION = "19"; // NOTE: manifest requires https for the URL, // otherwise you will get an error trying to register diff --git a/tests/i8n.test.ts b/tests/i8n.test.ts new file mode 100644 index 0000000..39a5789 --- /dev/null +++ b/tests/i8n.test.ts @@ -0,0 +1,149 @@ +import i8n, { langs, LANG, KEY, keys, asLANGs } from "../src/i8n"; + +describe("i8n", () => { + describe("asLANGs", () => { + describe("when the value is empty string", () => { + it("should return an empty array", () => { + expect(asLANGs("")).toEqual([]); + expect(asLANGs(";q=0.9,en;q=0.8")).toEqual([]); + }); + }); + describe("when the value is undefined", () => { + it("should return an empty array", () => { + expect(asLANGs(undefined)).toEqual([]); + }); + }); + describe("when there are multiple in the accept-langauge header", () => { + it("should split them out and return them", () => { + expect(asLANGs("en-GB,en-US;q=0.9,en;q=0.8")).toEqual([ + "en-GB", + "en-US", + ]); + }); + }); + }); + + describe("langs", () => { + it("should be all langs that are explicitly defined", () => { + expect(langs()).toEqual(["en-US", "nl-NL"]); + }); + }); + + describe("validity of translations", () => { + it("all langs should have same keys as US", () => { + langs().forEach((l) => { + expect(keys(l as LANG)).toEqual(keys("en-US")); + }); + }); + }); + + describe("keys", () => { + it("should equal the keys of en-US", () => { + expect(keys()).toEqual(keys("en-US")); + }); + }); + + describe("fetching translations", () => { + describe("with a single lang", () => { + describe("and there is no templating", () => { + it("should return the value", () => { + expect(i8n("foo")("en-US")("artists")).toEqual("Artists"); + expect(i8n("foo")("nl-NL")("artists")).toEqual("Artiesten"); + }); + }); + + describe("and there is templating of the service name", () => { + it("should return the value", () => { + expect(i8n("service123")("en-US")("AppLinkMessage")).toEqual( + "Linking sonos with service123" + ); + expect(i8n("service456")("nl-NL")("AppLinkMessage")).toEqual( + "Sonos koppelen aan service456" + ); + }); + }); + }); + + describe("with multiple langs", () => { + describe("and the first lang is a match", () => { + describe("and there is no templating", () => { + it("should return the value for the first lang", () => { + expect(i8n("foo")("en-US", "nl-NL")("artists")).toEqual("Artists"); + expect(i8n("foo")("nl-NL", "en-US")("artists")).toEqual("Artiesten"); + }); + }); + + describe("and there is templating of the service name", () => { + it("should return the value for the firt lang", () => { + expect(i8n("service123")("en-US", "nl-NL")("AppLinkMessage")).toEqual( + "Linking sonos with service123" + ); + expect(i8n("service456")("nl-NL", "en-US")("AppLinkMessage")).toEqual( + "Sonos koppelen aan service456" + ); + }); + }); + }); + + describe("and the first lang is not a match, however there is a match in the provided langs", () => { + describe("and there is no templating", () => { + it("should return the value for the first lang", () => { + expect(i8n("foo")("something", "en-US", "nl-NL")("artists")).toEqual("Artists"); + expect(i8n("foo")("something", "nl-NL", "en-US")("artists")).toEqual("Artiesten"); + }); + }); + + describe("and there is templating of the service name", () => { + it("should return the value for the firt lang", () => { + expect(i8n("service123")("something", "en-US", "nl-NL")("AppLinkMessage")).toEqual( + "Linking sonos with service123" + ); + expect(i8n("service456")("something", "nl-NL", "en-US")("AppLinkMessage")).toEqual( + "Sonos koppelen aan service456" + ); + }); + }); + }); + + describe("and no lang is a match", () => { + describe("and there is no templating", () => { + it("should return the value for the first lang", () => { + expect(i8n("foo")("something", "something2")("artists")).toEqual("Artists"); + }); + }); + + describe("and there is templating of the service name", () => { + it("should return the value for the firt lang", () => { + expect(i8n("service123")("something", "something2")("AppLinkMessage")).toEqual( + "Linking sonos with service123" + ); + }); + }); + }); + }); + + describe("when the lang exists but the KEY doesnt", () => { + it("should blow up", () => { + expect(() => i8n("foo")("en-US")("foobar123" as KEY)).toThrowError( + "No translation found for en-US:foobar123" + ); + }); + }); + + describe("when the lang is not represented", () => { + describe("and there is no templating", () => { + it("should return the en-US value", () => { + expect(i8n("foo")("en-AU" as LANG)("artists")).toEqual("Artists"); + }); + }); + + describe("and there is templating of the service name", () => { + it("should return the en-US value templated", () => { + expect(i8n("service123")("en-AU" as LANG)("AppLinkMessage")).toEqual( + "Linking sonos with service123" + ); + }); + }); + }); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 108a294..55da5a4 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -11,11 +11,12 @@ import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; import { aDevice, aService } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; -import { ExpiringAccessTokens } from "../src/access_tokens"; -import { InMemoryLinkCodes } from "../src/link_codes"; +import { AccessTokens, ExpiringAccessTokens } from "../src/access_tokens"; +import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import { Response } from "express"; import { Transform } from "stream"; import url from "../src/url_builder"; +import i8n, { randomLang } from "../src/i8n"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -163,6 +164,11 @@ describe("server", () => { const bonobUrlWithNoContextPath = url("http://bonob.localhost:1234"); const bonobUrlWithContextPath = url("http://bonob.localhost:1234/aContext"); + const langName = randomLang(); + const acceptLanguage = `le-ET,${langName};q=0.9,en;q=0.8`; + const serviceNameForLang = "Foo Service"; + const lang = i8n(serviceNameForLang)(langName); + [bonobUrlWithNoContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { describe(`a bonobUrl of ${bonobUrl}`, () => { describe("/", () => { @@ -239,9 +245,11 @@ describe("server", () => { it("should contain the devices returned from sonos", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); + expect(res.text).toMatch(`

${lang("devices")} \(2\)

`); expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/); expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/); }); @@ -251,10 +259,11 @@ describe("server", () => { it("should contain a list of services returned from sonos", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); - expect(res.text).toMatch(/Services\s+4/); + expect(res.text).toMatch(`

${lang("services")} \(4\)

`); expect(res.text).toMatch(/s1\s+\(1\)/); expect(res.text).toMatch(/s2\s+\(2\)/); expect(res.text).toMatch(/s3\s+\(3\)/); @@ -266,9 +275,13 @@ describe("server", () => { it("should be not-registered", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); - expect(res.text).toMatch(/No existing service registration/); + expect(res.text).toMatch(``); + expect(res.text).toMatch(`

${lang("expectedConfig")}

`); + expect(res.text).toMatch(`

${lang("noExistingServiceRegistration")}

`); + expect(res.text).not.toMatch(``); }); }); }); @@ -301,9 +314,13 @@ describe("server", () => { it("should be registered", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); - expect(res.text).toMatch(/Existing service config/); + expect(res.text).toMatch(``); + expect(res.text).toMatch(`

${lang("expectedConfig")}

`); + expect(res.text).toMatch(`

${lang("existingServiceConfig")}

`); + expect(res.text).toMatch(``); }); }); }); @@ -334,8 +351,7 @@ describe("server", () => { service: { name: theService.name, sid: theService.sid - } - }); + }}); }); }); @@ -362,10 +378,12 @@ describe("server", () => { const res = await request(server) .post(bonobUrl.append({ pathname: "/registration/add" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); - expect(res.text).toMatch("Successfully registered"); + expect(res.text).toMatch(`${lang("success")}`); + expect(res.text).toMatch(lang("successfullyRegistered")); expect(sonos.register.mock.calls.length).toEqual(1); expect(sonos.register.mock.calls[0][0]).toBe(theService); @@ -375,13 +393,15 @@ describe("server", () => { describe("when is unsuccessful", () => { it("should return a failure message", async () => { sonos.register.mockResolvedValue(false); - + const res = await request(server) .post(bonobUrl.append({ pathname: "/registration/add" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(500); - expect(res.text).toMatch("Registration failed!"); + expect(res.text).toMatch(`${lang("failure")}`); + expect(res.text).toMatch(lang("registrationFailed")); expect(sonos.register.mock.calls.length).toEqual(1); expect(sonos.register.mock.calls[0][0]).toBe(theService); @@ -396,10 +416,12 @@ describe("server", () => { const res = await request(server) .post(bonobUrl.append({ pathname: "/registration/remove" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); - expect(res.text).toMatch("Successfully removed registration"); + expect(res.text).toMatch(`${lang("success")}`); + expect(res.text).toMatch(lang("successfullyRemovedRegistration")); expect(sonos.remove.mock.calls.length).toEqual(1); expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid); @@ -412,10 +434,12 @@ describe("server", () => { const res = await request(server) .post(bonobUrl.append({ pathname: "/registration/remove" }).path()) + .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(500); - expect(res.text).toMatch("Failed to remove registration!"); + expect(res.text).toMatch(`${lang("failure")}`); + expect(res.text).toMatch(lang("failedToRemoveRegistration")); expect(sonos.remove.mock.calls.length).toEqual(1); expect(sonos.remove.mock.calls[0][0]).toBe(theService.sid); @@ -424,6 +448,138 @@ describe("server", () => { }); }); + describe("/login", () => { + const sonos = { + register: jest.fn(), + remove: jest.fn(), + }; + const theService = aService({ + name: serviceNameForLang + }); + + const musicService = { + generateToken: jest.fn(), + login: jest.fn(), + }; + const linkCodes = { + mint: jest.fn(), + has: jest.fn(), + associate: jest.fn(), + associationFor: jest.fn(), + }; + const accessTokens = { + mint: jest.fn(), + authTokenFor: jest.fn(), + }; + const clock = { + now: jest.fn(), + }; + + + const server = makeServer( + sonos as unknown as Sonos, + theService, + bonobUrl, + musicService as unknown as MusicService, + linkCodes as unknown as LinkCodes, + accessTokens as unknown as AccessTokens, + clock + ); + + it("should return the login page", async () => { + sonos.register.mockResolvedValue(true); + + const res = await request(server) + .get(bonobUrl.append({ pathname: "/login" }).path()) + .set("accept-language", acceptLanguage) + .send(); + + expect(res.status).toEqual(200); + expect(res.text).toMatch(`${lang("login")}`); + expect(res.text).toMatch(`

${lang("logInToBonob")}

`); + expect(res.text).toMatch(``); + expect(res.text).toMatch(``); + expect(res.text).toMatch(``); + }); + + describe("when the credentials are valid", () => { + it("should return 200 ok and have associated linkCode with user", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = `linkCode-${uuid()}`; + const authToken = { + authToken: `authtoken-${uuid()}`, + userId: `${username}-uid`, + nickname: `${username}-nickname`, + }; + + linkCodes.has.mockReturnValue(true); + musicService.generateToken.mockResolvedValue(authToken); + linkCodes.associate.mockReturnValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(200); + + expect(res.text).toContain(lang("loginSuccessful")); + + expect(musicService.generateToken).toHaveBeenCalledWith({ + username, + password, + }); + expect(linkCodes.has).toHaveBeenCalledWith(linkCode); + expect(linkCodes.associate).toHaveBeenCalledWith( + linkCode, + authToken + ); + }); + }); + + describe("when credentials are invalid", () => { + it("should return 403 with message", async () => { + const username = "userDoesntExist"; + const password = "password"; + const linkCode = uuid(); + const message = `Invalid user:${username}`; + + linkCodes.has.mockReturnValue(true); + musicService.generateToken.mockResolvedValue({ message }); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(403); + + expect(res.text).toContain(lang("loginFailed")); + expect(res.text).toContain(message); + }); + }); + + describe("when linkCode is invalid", () => { + it("should return 400 with message", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = "someLinkCodeThatDoesntExist"; + + linkCodes.has.mockReturnValue(false); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(400); + + expect(res.text).toContain(lang("invalidLinkCode")); + }); + }); + }); + describe("/stream", () => { const musicService = { login: jest.fn(), diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 3afe9e3..ad1e17a 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -9,10 +9,9 @@ import { randomInt } from "crypto"; import { LinkCodes } from "../src/link_codes"; import makeServer from "../src/server"; -import { bonobService, SONOS_DISABLED } from "../src/sonos"; +import { bonobService, SONOS_DISABLED, SONOS_LANG } from "../src/sonos"; import { STRINGS_ROUTE, - LOGIN_ROUTE, getMetadataResult, PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, @@ -68,15 +67,19 @@ describe("service config", () => { }); describe(`${stringsUrl}`, () => { - it("should return xml for the strings", async () => { + async function fetchStringsXml() { const res = await request(server).get(stringsUrl.path()).send(); expect(res.status).toEqual(200); // removing the sonos xml ns as makes xpath queries with xpath-ts painful - const xml = parseXML( + return parseXML( res.text.replace('xmlns="http://sonos.com/sonosapi"', "") ); + } + + it("should return xml for the strings", async () => { + const xml = await fetchStringsXml(); const sonosString = (id: string, lang: string) => xpath.select( @@ -87,9 +90,24 @@ describe("service config", () => { expect(sonosString("AppLinkMessage", "en-US")).toEqual( "Linking sonos with music land" ); - expect(sonosString("AppLinkMessage", "fr-FR")).toEqual( - "Lier les sonos à la music land" + expect(sonosString("AppLinkMessage", "nl-NL")).toEqual( + "Sonos koppelen aan music land" ); + + // no fr-FR translation, so use en-US + expect(sonosString("AppLinkMessage", "fr-FR")).toEqual( + "Linking sonos with music land" + ); + }); + + it("should return a section for all sonos supported languages", async () => { + const xml = await fetchStringsXml(); + SONOS_LANG.forEach(lang => { + expect(xpath.select( + `string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="AppLinkMessage"])`, + xml + )).toBeDefined(); + }); }); }); @@ -345,83 +363,6 @@ describe("api", () => { jest.resetAllMocks(); }); - describe("pages", () => { - describe(bonobUrl.append({ pathname: LOGIN_ROUTE }).href(), () => { - describe("when the credentials are valid", () => { - it("should return 200 ok and have associated linkCode with user", async () => { - const username = "jane"; - const password = "password100"; - const linkCode = `linkCode-${uuid()}`; - const authToken = { - authToken: `authtoken-${uuid()}`, - userId: `${username}-uid`, - nickname: `${username}-nickname`, - }; - - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockResolvedValue(authToken); - linkCodes.associate.mockReturnValue(true); - - const res = await request(server) - .post(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) - .type("form") - .send({ username, password, linkCode }) - .expect(200); - - expect(res.text).toContain("Login successful"); - - expect(musicService.generateToken).toHaveBeenCalledWith({ - username, - password, - }); - expect(linkCodes.has).toHaveBeenCalledWith(linkCode); - expect(linkCodes.associate).toHaveBeenCalledWith( - linkCode, - authToken - ); - }); - }); - - describe("when credentials are invalid", () => { - it("should return 403 with message", async () => { - const username = "userDoesntExist"; - const password = "password"; - const linkCode = uuid(); - const message = `Invalid user:${username}`; - - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockResolvedValue({ message }); - - const res = await request(server) - .post(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) - .type("form") - .send({ username, password, linkCode }) - .expect(403); - - expect(res.text).toContain(`Login failed! ${message}`); - }); - }); - - describe("when linkCode is invalid", () => { - it("should return 400 with message", async () => { - const username = "jane"; - const password = "password100"; - const linkCode = "someLinkCodeThatDoesntExist"; - - linkCodes.has.mockReturnValue(false); - - const res = await request(server) - .post(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) - .type("form") - .send({ username, password, linkCode }) - .expect(400); - - expect(res.text).toContain("Invalid linkCode!"); - }); - }); - }); - }); - describe("soap api", () => { describe("getAppLink", () => { it("should do something", async () => { @@ -755,62 +696,125 @@ describe("api", () => { }); describe("asking for the root container", () => { - it("should return it", async () => { - const root = await ws.getMetadataAsync({ - id: "root", - index: 0, - count: 100, - }); - expect(root[0]).toEqual( - getMetadataResult({ - mediaCollection: [ - { - itemType: "container", - id: "artists", - title: "Artists", - }, - { itemType: "albumList", id: "albums", title: "Albums" }, - { - itemType: "playlist", - id: "playlists", - title: "Playlists", - attributes: { - readOnly: "false", - renameable: "false", - userContent: "true", - }, - }, - { itemType: "container", id: "genres", title: "Genres" }, - { - itemType: "albumList", - id: "randomAlbums", - title: "Random", - }, - { - itemType: "albumList", - id: "starredAlbums", - title: "Starred", - }, - { - itemType: "albumList", - id: "recentlyAdded", - title: "Recently Added", - }, - { - itemType: "albumList", - id: "recentlyPlayed", - title: "Recently Played", - }, - { - itemType: "albumList", - id: "mostPlayed", - title: "Most Played", - }, - ], + describe("when no accept-language header is present", () => { + it("should return en-US", async () => { + const root = await ws.getMetadataAsync({ + id: "root", index: 0, - total: 9, - }) - ); + count: 100, + }); + expect(root[0]).toEqual( + getMetadataResult({ + mediaCollection: [ + { + itemType: "container", + id: "artists", + title: "Artists", + }, + { itemType: "albumList", id: "albums", title: "Albums" }, + { + itemType: "playlist", + id: "playlists", + title: "Playlists", + attributes: { + readOnly: "false", + renameable: "false", + userContent: "true", + }, + }, + { itemType: "container", id: "genres", title: "Genres" }, + { + itemType: "albumList", + id: "randomAlbums", + title: "Random", + }, + { + itemType: "albumList", + id: "starredAlbums", + title: "Starred", + }, + { + itemType: "albumList", + id: "recentlyAdded", + title: "Recently added", + }, + { + itemType: "albumList", + id: "recentlyPlayed", + title: "Recently played", + }, + { + itemType: "albumList", + id: "mostPlayed", + title: "Most played", + }, + ], + index: 0, + total: 9, + }) + ); + }); + }); + + describe("when an accept-language header is present with value nl-NL", () => { + it("should return nl-NL", async () => { + ws.addHttpHeader("accept-language", "nl-NL") + const root = await ws.getMetadataAsync({ + id: "root", + index: 0, + count: 100, + }); + expect(root[0]).toEqual( + getMetadataResult({ + mediaCollection: [ + { + itemType: "container", + id: "artists", + title: "Artiesten", + }, + { itemType: "albumList", id: "albums", title: "Albums" }, + { + itemType: "playlist", + id: "playlists", + title: "Afspeellijsten", + attributes: { + readOnly: "false", + renameable: "false", + userContent: "true", + }, + }, + { itemType: "container", id: "genres", title: "Genres" }, + { + itemType: "albumList", + id: "randomAlbums", + title: "Willekeurig", + }, + { + itemType: "albumList", + id: "starredAlbums", + title: "Favorieten", + }, + { + itemType: "albumList", + id: "recentlyAdded", + title: "Onlangs toegevoegd", + }, + { + itemType: "albumList", + id: "recentlyPlayed", + title: "Onlangs afgespeeld", + }, + { + itemType: "albumList", + id: "mostPlayed", + title: "Meest afgespeeld", + }, + ], + index: 0, + total: 9, + }) + ); + }); }); }); diff --git a/web/views/failure.eta b/web/views/failure.eta index e6eae31..af5f358 100644 --- a/web/views/failure.eta +++ b/web/views/failure.eta @@ -1,5 +1,6 @@ -<% layout('./layout', { title: "Failure" }) %> +<% layout('./layout', { title: it.lang("failure") }) %>

<%= it.message %>

+

<%= it.cause || "" %>

\ No newline at end of file diff --git a/web/views/index.eta b/web/views/index.eta index 33afb17..615e37e 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -2,23 +2,35 @@

<%= it.bonobService.name %> (<%= it.bonobService.sid %>)

-

Expected config

+

<%= it.lang("expectedConfig") %>

<%= JSON.stringify(it.bonobService) %>
+
+
+ "> +
+
+ <% if(it.registeredBonobService) { %> -

Existing service config

-
<%= JSON.stringify(it.registeredBonobService) %>
+

<%= it.lang("existingServiceConfig") %>

+
<%= JSON.stringify(it.registeredBonobService) %>
<% } else { %> -

No existing service registration

+

<%= it.lang("noExistingServiceRegistration") %>

<% } %> -
-
-

Devices

+ <% if(it.registeredBonobService) { %> +
+
+ "> +
+ <% } %> + +
+

<%= it.lang("devices") %> (<%= it.devices.length %>)

    <% it.devices.forEach(function(d){ %>
  • <%= d.name %> (<%= d.ip %>:<%= d.port %>)
  • <% }) %>
-

Services <%= it.services.length %>

+

<%= it.lang("services") %> (<%= it.services.length %>)

    <% it.services.forEach(function(s){ %>
  • <%= s.name %> (<%= s.sid %>)
  • diff --git a/web/views/layout.eta b/web/views/layout.eta index 21d5827..3b3f650 100644 --- a/web/views/layout.eta +++ b/web/views/layout.eta @@ -28,6 +28,20 @@ input { input#submit { margin-top: 100px } + +.one-word-per-line { + word-spacing: 100000px; +} + +.login{ + width: min-intrinsic; + width: -webkit-min-content; + width: -moz-min-content; + width: min-content; + display: table-caption; + display: -ms-grid; + -ms-grid-columns: min-content; +} diff --git a/web/views/login.eta b/web/views/login.eta index eee592f..12ee98a 100644 --- a/web/views/login.eta +++ b/web/views/login.eta @@ -1,13 +1,13 @@ -<% layout('./layout', { title: "Login" }) %> +<% layout('./layout', { title: it.lang("login") }) %>
    -

    Log
    in
    to
    <%= it.bonobService.name %>

    +

    <%= it.lang("logInToBonob") %>

    -
    +


    -
    +

    - + " id="submit">
    \ No newline at end of file diff --git a/web/views/success.eta b/web/views/success.eta index 579ac21..431d432 100644 --- a/web/views/success.eta +++ b/web/views/success.eta @@ -1,4 +1,4 @@ -<% layout('./layout', { title: "Yippee" }) %> +<% layout('./layout', { title: it.lang("success") }) %>

    <%= it.message %>