mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Add i8n support for nl-NL (#19)
This commit is contained in:
134
src/i8n.ts
Normal file
134
src/i8n.ts
Normal file
@@ -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<LANG, Record<KEY, string>> = {
|
||||
"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}`;
|
||||
};
|
||||
};
|
||||
@@ -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) => `<string stringId="${id}"><![CDATA[${value}]]></string>`
|
||||
const stringtableNode = (langName: string) => `<stringtable rev="1" xml:lang="${langName}">${i8nKeys().map(key => stringNode(key, i8n(langName as LANG)(key as KEY))).join("")}</stringtable>`
|
||||
|
||||
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
|
||||
<stringtables xmlns="http://sonos.com/sonosapi">
|
||||
<stringtable rev="1" xml:lang="en-US">
|
||||
<string stringId="AppLinkMessage">Linking sonos with ${service.name}</string>
|
||||
</stringtable>
|
||||
<stringtable rev="1" xml:lang="fr-FR">
|
||||
<string stringId="AppLinkMessage">Lier les sonos à la ${service.name}</string>
|
||||
</stringtable>
|
||||
${SONOS_LANG.map(stringtableNode).join("")}
|
||||
</stringtables>
|
||||
`);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
82
src/smapi.ts
82
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<Request, 'headers'>
|
||||
) =>
|
||||
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<GetMetadataResponse> =>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
149
tests/i8n.test.ts
Normal file
149
tests/i8n.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(`<h2>${lang("devices")} \(2\)</h2>`);
|
||||
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(`<h2>${lang("services")} \(4\)</h2>`);
|
||||
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(`<input type="submit" value="${lang("register")}">`);
|
||||
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
|
||||
expect(res.text).toMatch(`<h3>${lang("noExistingServiceRegistration")}</h3>`);
|
||||
expect(res.text).not.toMatch(`<input type="submit" value="${lang("removeRegistration")}">`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(`<input type="submit" value="${lang("register")}">`);
|
||||
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
|
||||
expect(res.text).toMatch(`<h3>${lang("existingServiceConfig")}</h3>`);
|
||||
expect(res.text).toMatch(`<input type="submit" value="${lang("removeRegistration")}">`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(`<title>${lang("success")}</title>`);
|
||||
expect(res.text).toMatch(lang("successfullyRegistered"));
|
||||
|
||||
expect(sonos.register.mock.calls.length).toEqual(1);
|
||||
expect(sonos.register.mock.calls[0][0]).toBe(theService);
|
||||
@@ -378,10 +396,12 @@ describe("server", () => {
|
||||
|
||||
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(`<title>${lang("failure")}</title>`);
|
||||
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(`<title>${lang("success")}</title>`);
|
||||
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(`<title>${lang("failure")}</title>`);
|
||||
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(`<title>${lang("login")}</title>`);
|
||||
expect(res.text).toMatch(`<h1 class="login one-word-per-line">${lang("logInToBonob")}</h1>`);
|
||||
expect(res.text).toMatch(`<label for="username">${lang("username")}:</label>`);
|
||||
expect(res.text).toMatch(`<label for="password">${lang("password")}:</label>`);
|
||||
expect(res.text).toMatch(`<input type="submit" value="${lang("login")}" id="submit">`);
|
||||
});
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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,7 +696,8 @@ describe("api", () => {
|
||||
});
|
||||
|
||||
describe("asking for the root container", () => {
|
||||
it("should return it", async () => {
|
||||
describe("when no accept-language header is present", () => {
|
||||
it("should return en-US", async () => {
|
||||
const root = await ws.getMetadataAsync({
|
||||
id: "root",
|
||||
index: 0,
|
||||
@@ -794,17 +736,17 @@ describe("api", () => {
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyAdded",
|
||||
title: "Recently Added",
|
||||
title: "Recently added",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "recentlyPlayed",
|
||||
title: "Recently Played",
|
||||
title: "Recently played",
|
||||
},
|
||||
{
|
||||
itemType: "albumList",
|
||||
id: "mostPlayed",
|
||||
title: "Most Played",
|
||||
title: "Most played",
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
@@ -814,6 +756,68 @@ describe("api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for the search container", () => {
|
||||
it("should return it", async () => {
|
||||
const root = await ws.getMetadataAsync({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<% layout('./layout', { title: "Failure" }) %>
|
||||
<% layout('./layout', { title: it.lang("failure") }) %>
|
||||
|
||||
<div id="content">
|
||||
<h1 class="failure"><%= it.message %></h1>
|
||||
<h1 class="cause"><%= it.cause || "" %></h1>
|
||||
</div>
|
||||
@@ -2,23 +2,35 @@
|
||||
|
||||
<div id="content">
|
||||
<h1><%= it.bonobService.name %> (<%= it.bonobService.sid %>)</h1>
|
||||
<h3>Expected config</h3>
|
||||
<h3><%= it.lang("expectedConfig") %></h3>
|
||||
<div><%= JSON.stringify(it.bonobService) %></div>
|
||||
<br/>
|
||||
<form action="<%= it.createRegistrationRoute %>" method="POST">
|
||||
<input type="submit" value="<%= it.lang("register") %>">
|
||||
</form>
|
||||
<br/>
|
||||
|
||||
<% if(it.registeredBonobService) { %>
|
||||
<h3>Existing service config</h3>
|
||||
<h3><%= it.lang("existingServiceConfig") %></h3>
|
||||
<div><%= JSON.stringify(it.registeredBonobService) %></div>
|
||||
<% } else { %>
|
||||
<h3>No existing service registration</h3>
|
||||
<h3><%= it.lang("noExistingServiceRegistration") %></h3>
|
||||
<% } %>
|
||||
<form action="<%= it.createRegistrationRoute %>" method="POST"><button>Register</button></form>
|
||||
<form action="<%= it.removeRegistrationRoute %>" method="POST"><button>Remove Registration</button></form>
|
||||
<h2>Devices</h2>
|
||||
<% if(it.registeredBonobService) { %>
|
||||
<br/>
|
||||
<form action="<%= it.removeRegistrationRoute %>" method="POST">
|
||||
<input type="submit" value="<%= it.lang("removeRegistration") %>">
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
<br/>
|
||||
<h2><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
|
||||
<ul>
|
||||
<% it.devices.forEach(function(d){ %>
|
||||
<li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<h2>Services <%= it.services.length %></h2>
|
||||
<h2><%= it.lang("services") %> (<%= it.services.length %>)</h2>
|
||||
<ul>
|
||||
<% it.services.forEach(function(s){ %>
|
||||
<li><%= s.name %> (<%= s.sid %>)</li>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<% layout('./layout', { title: "Login" }) %>
|
||||
<% layout('./layout', { title: it.lang("login") }) %>
|
||||
|
||||
<div id="content">
|
||||
<h1 class="login">Log<br>in<br>to<br><%= it.bonobService.name %></h1>
|
||||
<h1 class="login one-word-per-line"><%= it.lang("logInToBonob") %></h1>
|
||||
<form action="<%= it.loginRoute %>" method="POST">
|
||||
<label for="username">Username:</label><br>
|
||||
<label for="username"><%= it.lang("username") %>:</label><br>
|
||||
<input type="text" id="username" name="username"><br><br>
|
||||
<label for="password">Password:</label><br>
|
||||
<label for="password"><%= it.lang("password") %>:</label><br>
|
||||
<input type="password" id="password" name="password"><br>
|
||||
<input type="hidden" name="linkCode" value="<%= it.linkCode %>">
|
||||
<input type="submit" value="Login" id="submit">
|
||||
<input type="submit" value="<%= it.lang("login") %>" id="submit">
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
<% layout('./layout', { title: "Yippee" }) %>
|
||||
<% layout('./layout', { title: it.lang("success") }) %>
|
||||
|
||||
<div id="content">
|
||||
<h1 class="success"><%= it.message %></h1>
|
||||
|
||||
Reference in New Issue
Block a user