feat: create route to store FCM token

This commit is contained in:
2024-06-14 18:14:10 -04:00
parent 4d3c34579d
commit 231ed4bde4
16 changed files with 650 additions and 14 deletions

View 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();
});
});

View 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;

View File

@@ -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",

View File

@@ -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", {

View File

@@ -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";

View File

@@ -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];

View File

@@ -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();
}