feat: create route to store FCM token
This commit is contained in:
197
src/controllers/token/index.spec.ts
Normal file
197
src/controllers/token/index.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import app from "~/index";
|
||||
import { server } from "~/mocks";
|
||||
import { getDb, resetDb } from "~/models/db";
|
||||
import { deviceTokensTable } from "~/models/schema";
|
||||
|
||||
server.listen();
|
||||
|
||||
describe("requests the /token route", () => {
|
||||
const db = getDb({
|
||||
TURSO_URL: process.env.TURSO_URL ?? "http://127.0.0.1:3000",
|
||||
TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN ?? "asd",
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
it("should succeed", async () => {
|
||||
const res = await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "123", username: "test" }),
|
||||
});
|
||||
|
||||
expect(res.json()).resolves.toEqual({ success: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("succeeded, db should contain entry", async () => {
|
||||
const minimumTimestamp = DateTime.now();
|
||||
await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "123", username: "test" }),
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(deviceTokensTable)
|
||||
.where(eq(deviceTokensTable.deviceId, "123"))
|
||||
.get();
|
||||
|
||||
expect(row).toEqual({
|
||||
deviceId: "123",
|
||||
token: "123",
|
||||
username: "test",
|
||||
lastConnectedAt: expect.any(String),
|
||||
});
|
||||
// since SQL timestamp doesn't support milliseconds, compare to nearest second
|
||||
expect(
|
||||
+DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf(
|
||||
"second",
|
||||
),
|
||||
).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second"));
|
||||
});
|
||||
|
||||
it("with username as null, should succeed", async () => {
|
||||
const res = await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "123", username: null }),
|
||||
});
|
||||
|
||||
expect(res.json()).resolves.toEqual({ success: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("with username as null, db should contain entry", async () => {
|
||||
const minimumTimestamp = DateTime.now();
|
||||
await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "123", username: null }),
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(deviceTokensTable)
|
||||
.where(eq(deviceTokensTable.deviceId, "123"))
|
||||
.get();
|
||||
|
||||
expect(row).toEqual({
|
||||
deviceId: "123",
|
||||
token: "123",
|
||||
username: null,
|
||||
lastConnectedAt: expect.any(String),
|
||||
});
|
||||
// since SQL timestamp doesn't support milliseconds, compare to nearest second
|
||||
expect(
|
||||
+DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf(
|
||||
"second",
|
||||
),
|
||||
).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second"));
|
||||
});
|
||||
|
||||
it("device id already exists in db, should succeed", async () => {
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "123" });
|
||||
|
||||
const res = await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "124", deviceId: "123", username: null }),
|
||||
});
|
||||
|
||||
expect(res.json()).resolves.toEqual({ success: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("device id already exists in db, should contain new token", async () => {
|
||||
const minimumTimestamp = DateTime.now();
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "123" });
|
||||
await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "124", deviceId: "123", username: null }),
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(deviceTokensTable)
|
||||
.where(eq(deviceTokensTable.deviceId, "123"))
|
||||
.get();
|
||||
|
||||
expect(row).toEqual({
|
||||
deviceId: "123",
|
||||
token: "124",
|
||||
username: null,
|
||||
lastConnectedAt: expect.any(String),
|
||||
});
|
||||
// since SQL timestamp doesn't support milliseconds, compare to nearest second
|
||||
expect(
|
||||
+DateTime.fromSQL(row!.lastConnectedAt!, { zone: "utc" }).startOf(
|
||||
"second",
|
||||
),
|
||||
).toBeGreaterThanOrEqual(+minimumTimestamp.startOf("second"));
|
||||
});
|
||||
|
||||
it("token already exists in db, should fail", async () => {
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "123" });
|
||||
|
||||
const res = await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "124", username: null }),
|
||||
});
|
||||
|
||||
expect(res.json()).resolves.toEqual({ success: false });
|
||||
expect(res.status).toBe(412);
|
||||
});
|
||||
|
||||
it("token already exists in db, should not insert new entry", async () => {
|
||||
const minimumTimestamp = DateTime.now();
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "123" });
|
||||
await app.request("/token", {
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ token: "123", deviceId: "124", username: null }),
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(deviceTokensTable)
|
||||
.where(eq(deviceTokensTable.deviceId, "124"))
|
||||
.get();
|
||||
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
70
src/controllers/token/index.ts
Normal file
70
src/controllers/token/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||
import { env } from "hono/adapter";
|
||||
|
||||
import { saveToken } from "~/models/token";
|
||||
import type { Env } from "~/types/env";
|
||||
import {
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
SuccessResponseSchema,
|
||||
} from "~/types/schema";
|
||||
|
||||
const app = new OpenAPIHono<Env>();
|
||||
|
||||
const SaveTokenRequest = z.object({
|
||||
token: z.string(),
|
||||
deviceId: z.string(),
|
||||
username: z.string().nullable(),
|
||||
});
|
||||
|
||||
const SaveTokenResponse = SuccessResponseSchema();
|
||||
|
||||
const route = createRoute({
|
||||
tags: ["aniplay", "notifications"],
|
||||
operationId: "saveToken",
|
||||
summary: "Saves FCM token",
|
||||
method: "post",
|
||||
path: "/",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SaveTokenRequest,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: SaveTokenResponse,
|
||||
},
|
||||
},
|
||||
description: "Saved token successfully",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, async (c) => {
|
||||
const { token, deviceId, username } =
|
||||
await c.req.json<typeof SaveTokenRequest._type>();
|
||||
|
||||
try {
|
||||
await saveToken(env(c, "workerd"), deviceId, token, username);
|
||||
} catch (error) {
|
||||
if (
|
||||
error.code === "SQLITE_CONSTRAINT" &&
|
||||
error.message.includes("device_tokens.token")
|
||||
) {
|
||||
return c.json(ErrorResponse, 412);
|
||||
}
|
||||
|
||||
console.error(new Error("Failed to save token", { cause: error }));
|
||||
return c.json(ErrorResponse, 500);
|
||||
}
|
||||
|
||||
return c.json(SuccessResponse);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -3,10 +3,9 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import app from "~/index";
|
||||
import { server } from "~/mocks";
|
||||
import { getDb, resetDb } from "~/models/db";
|
||||
import { tokenTable } from "~/models/schema";
|
||||
import { deviceTokensTable } from "~/models/schema";
|
||||
|
||||
server.listen();
|
||||
console.error = () => {};
|
||||
|
||||
describe("requests the /watch-status route", () => {
|
||||
const db = getDb({
|
||||
@@ -19,7 +18,9 @@ describe("requests the /watch-status route", () => {
|
||||
});
|
||||
|
||||
it("saving title, deviceId in db, should succeed", async () => {
|
||||
await db.insert(tokenTable).values({ deviceId: "123", token: "asd" });
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "asd" });
|
||||
|
||||
const res = await app.request(
|
||||
"/watch-status",
|
||||
@@ -71,7 +72,9 @@ describe("requests the /watch-status route", () => {
|
||||
});
|
||||
|
||||
it("saving title, Anilist request fails, should succeed", async () => {
|
||||
await db.insert(tokenTable).values({ deviceId: "123", token: "asd" });
|
||||
await db
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId: "123", token: "asd" });
|
||||
|
||||
const res = await app.request(
|
||||
"/watch-status",
|
||||
|
||||
@@ -29,6 +29,10 @@ app.route(
|
||||
(controller) => controller.default,
|
||||
),
|
||||
);
|
||||
app.route(
|
||||
"/token",
|
||||
await import("~/controllers/token").then((controller) => controller.default),
|
||||
);
|
||||
|
||||
// The OpenAPI documentation will be available at /doc
|
||||
app.doc("/openapi.json", {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
|
||||
import type { Env } from "~/types/env";
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const tokenTable = sqliteTable("token", {
|
||||
export const deviceTokensTable = sqliteTable("device_tokens", {
|
||||
deviceId: text("device_id").primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
username: text("username"),
|
||||
/** Used to determine if a device hasn't been used in a while. Should start to ignore tokens where the device hasn't connected in about a month */
|
||||
/** Used to determine if a device hasn't been used in a while. Should start to ignore tokens where the device hasn't connected in about a month. */
|
||||
lastConnectedAt: text("last_connected_at").default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export const watchStatusTable = sqliteTable(
|
||||
{
|
||||
deviceId: text("device_id")
|
||||
.notNull()
|
||||
.references(() => tokenTable.deviceId),
|
||||
.references(() => deviceTokensTable.deviceId),
|
||||
titleId: integer("title_id").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -27,4 +27,4 @@ export const watchStatusTable = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const tables = [watchStatusTable, tokenTable];
|
||||
export const tables = [watchStatusTable, deviceTokensTable];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, sql } from "drizzle-orm";
|
||||
import type { Env } from "~/types/env";
|
||||
|
||||
import { getDb } from "./db";
|
||||
import { tokenTable } from "./schema";
|
||||
import { deviceTokensTable } from "./schema";
|
||||
|
||||
export function saveToken(
|
||||
env: Env,
|
||||
@@ -12,15 +12,15 @@ export function saveToken(
|
||||
username: string | null,
|
||||
) {
|
||||
return getDb(env)
|
||||
.insert(tokenTable)
|
||||
.insert(deviceTokensTable)
|
||||
.values({ deviceId, token, username })
|
||||
.run();
|
||||
}
|
||||
|
||||
export function updateDeviceLastConnectedAt(env: Env, deviceId: string) {
|
||||
return getDb(env)
|
||||
.update(tokenTable)
|
||||
.update(deviceTokensTable)
|
||||
.set({ lastConnectedAt: sql`(CURRENT_TIMESTAMP)` })
|
||||
.where(eq(tokenTable.deviceId, deviceId))
|
||||
.where(eq(deviceTokensTable.deviceId, deviceId))
|
||||
.run();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user