feat: create route to store FCM token
This commit is contained in:
1
drizzle/0002_public_whistler.sql
Normal file
1
drizzle/0002_public_whistler.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX `token_token_unique` ON `token` (`token`);
|
||||||
20
drizzle/0003_puzzling_nightmare.sql
Normal file
20
drizzle/0003_puzzling_nightmare.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Custom SQL migration file, put you code below! --
|
||||||
|
DROP TABLE `watch_status`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `token`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `token` (
|
||||||
|
`device_id` text NOT NULL,
|
||||||
|
`token` text NOT NULL UNIQUE ON CONFLICT FAIL,
|
||||||
|
`username` text,
|
||||||
|
`last_connected_at` text DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
PRIMARY KEY(`device_id`) ON CONFLICT REPLACE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `watch_status` (
|
||||||
|
`device_id` text NOT NULL,
|
||||||
|
`title_id` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`device_id`, `title_id`),
|
||||||
|
FOREIGN KEY (`device_id`) REFERENCES `token`(`device_id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
20
drizzle/0004_jittery_black_knight.sql
Normal file
20
drizzle/0004_jittery_black_knight.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Custom SQL migration file, put you code below! --
|
||||||
|
DROP TABLE `watch_status`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `token`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE TABLE `device_tokens` (
|
||||||
|
`device_id` text NOT NULL,
|
||||||
|
`token` text NOT NULL UNIQUE ON CONFLICT FAIL,
|
||||||
|
`username` text,
|
||||||
|
`last_connected_at` text DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
PRIMARY KEY(`device_id`) ON CONFLICT REPLACE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `watch_status` (
|
||||||
|
`device_id` text NOT NULL,
|
||||||
|
`title_id` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`device_id`, `title_id`),
|
||||||
|
FOREIGN KEY (`device_id`) REFERENCES `device_tokens`(`device_id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
99
drizzle/meta/0002_snapshot.json
Normal file
99
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "548c249b-15e5-4a6a-a3e4-c539d8774728",
|
||||||
|
"prevId": "d5b8fe62-fa26-4e9b-94eb-d3d38701f620",
|
||||||
|
"tables": {
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_connected_at": {
|
||||||
|
"name": "last_connected_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(CURRENT_TIMESTAMP)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"token_token_unique": {
|
||||||
|
"name": "token_token_unique",
|
||||||
|
"columns": ["token"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"watch_status": {
|
||||||
|
"name": "watch_status",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title_id": {
|
||||||
|
"name": "title_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"watch_status_device_id_token_device_id_fk": {
|
||||||
|
"name": "watch_status_device_id_token_device_id_fk",
|
||||||
|
"tableFrom": "watch_status",
|
||||||
|
"tableTo": "token",
|
||||||
|
"columnsFrom": ["device_id"],
|
||||||
|
"columnsTo": ["device_id"],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"watch_status_device_id_title_id_pk": {
|
||||||
|
"columns": ["device_id", "title_id"],
|
||||||
|
"name": "watch_status_device_id_title_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
drizzle/meta/0003_snapshot.json
Normal file
99
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"id": "3c96b576-9b17-42f7-9dd3-908a83c2a94b",
|
||||||
|
"prevId": "548c249b-15e5-4a6a-a3e4-c539d8774728",
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"tables": {
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_connected_at": {
|
||||||
|
"name": "last_connected_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(CURRENT_TIMESTAMP)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"token_token_unique": {
|
||||||
|
"name": "token_token_unique",
|
||||||
|
"columns": ["token"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"watch_status": {
|
||||||
|
"name": "watch_status",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title_id": {
|
||||||
|
"name": "title_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"watch_status_device_id_token_device_id_fk": {
|
||||||
|
"name": "watch_status_device_id_token_device_id_fk",
|
||||||
|
"tableFrom": "watch_status",
|
||||||
|
"columnsFrom": ["device_id"],
|
||||||
|
"tableTo": "token",
|
||||||
|
"columnsTo": ["device_id"],
|
||||||
|
"onUpdate": "no action",
|
||||||
|
"onDelete": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"watch_status_device_id_title_id_pk": {
|
||||||
|
"columns": ["device_id", "title_id"],
|
||||||
|
"name": "watch_status_device_id_title_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
drizzle/meta/0004_snapshot.json
Normal file
101
drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "223cc621-0232-4499-973a-9013d134b1f9",
|
||||||
|
"prevId": "3c96b576-9b17-42f7-9dd3-908a83c2a94b",
|
||||||
|
"tables": {
|
||||||
|
"device_tokens": {
|
||||||
|
"name": "device_tokens",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_connected_at": {
|
||||||
|
"name": "last_connected_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(CURRENT_TIMESTAMP)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"device_tokens_token_unique": {
|
||||||
|
"name": "device_tokens_token_unique",
|
||||||
|
"columns": ["token"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"watch_status": {
|
||||||
|
"name": "watch_status",
|
||||||
|
"columns": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title_id": {
|
||||||
|
"name": "title_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"watch_status_device_id_device_tokens_device_id_fk": {
|
||||||
|
"name": "watch_status_device_id_device_tokens_device_id_fk",
|
||||||
|
"tableFrom": "watch_status",
|
||||||
|
"tableTo": "device_tokens",
|
||||||
|
"columnsFrom": ["device_id"],
|
||||||
|
"columnsTo": ["device_id"],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"watch_status_device_id_title_id_pk": {
|
||||||
|
"columns": ["device_id", "title_id"],
|
||||||
|
"name": "watch_status_device_id_title_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {
|
||||||
|
"\"token\"": "\"device_tokens\""
|
||||||
|
},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,27 @@
|
|||||||
"when": 1718107695989,
|
"when": 1718107695989,
|
||||||
"tag": "0001_purple_franklin_richards",
|
"tag": "0001_purple_franklin_richards",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1718281079139,
|
||||||
|
"tag": "0002_public_whistler",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1718394970979,
|
||||||
|
"tag": "0003_puzzling_nightmare",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1718402777422,
|
||||||
|
"tag": "0004_jittery_black_knight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,11 @@
|
|||||||
"@cloudflare/workers-types": "^4.20240403.0",
|
"@cloudflare/workers-types": "^4.20240403.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/bun": "^1.1.2",
|
"@types/bun": "^1.1.2",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"drizzle-kit": "^0.22.6",
|
"drizzle-kit": "^0.22.6",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^15.2.7",
|
"lint-staged": "^15.2.7",
|
||||||
|
"luxon": "^3.4.4",
|
||||||
"msw": "^2.3.0",
|
"msw": "^2.3.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-toml": "^2.0.1",
|
"prettier-plugin-toml": "^2.0.1",
|
||||||
|
|||||||
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 app from "~/index";
|
||||||
import { server } from "~/mocks";
|
import { server } from "~/mocks";
|
||||||
import { getDb, resetDb } from "~/models/db";
|
import { getDb, resetDb } from "~/models/db";
|
||||||
import { tokenTable } from "~/models/schema";
|
import { deviceTokensTable } from "~/models/schema";
|
||||||
|
|
||||||
server.listen();
|
server.listen();
|
||||||
console.error = () => {};
|
|
||||||
|
|
||||||
describe("requests the /watch-status route", () => {
|
describe("requests the /watch-status route", () => {
|
||||||
const db = getDb({
|
const db = getDb({
|
||||||
@@ -19,7 +18,9 @@ describe("requests the /watch-status route", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("saving title, deviceId in db, should succeed", async () => {
|
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(
|
const res = await app.request(
|
||||||
"/watch-status",
|
"/watch-status",
|
||||||
@@ -71,7 +72,9 @@ describe("requests the /watch-status route", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("saving title, Anilist request fails, should succeed", async () => {
|
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(
|
const res = await app.request(
|
||||||
"/watch-status",
|
"/watch-status",
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ app.route(
|
|||||||
(controller) => controller.default,
|
(controller) => controller.default,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
app.route(
|
||||||
|
"/token",
|
||||||
|
await import("~/controllers/token").then((controller) => controller.default),
|
||||||
|
);
|
||||||
|
|
||||||
// The OpenAPI documentation will be available at /doc
|
// The OpenAPI documentation will be available at /doc
|
||||||
app.doc("/openapi.json", {
|
app.doc("/openapi.json", {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
|
||||||
import type { Env } from "~/types/env";
|
import type { Env } from "~/types/env";
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
text,
|
text,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const tokenTable = sqliteTable("token", {
|
export const deviceTokensTable = sqliteTable("device_tokens", {
|
||||||
deviceId: text("device_id").primaryKey(),
|
deviceId: text("device_id").primaryKey(),
|
||||||
token: text("token").notNull(),
|
token: text("token").notNull().unique(),
|
||||||
username: text("username"),
|
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)`),
|
lastConnectedAt: text("last_connected_at").default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const watchStatusTable = sqliteTable(
|
|||||||
{
|
{
|
||||||
deviceId: text("device_id")
|
deviceId: text("device_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tokenTable.deviceId),
|
.references(() => deviceTokensTable.deviceId),
|
||||||
titleId: integer("title_id").notNull(),
|
titleId: integer("title_id").notNull(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(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 type { Env } from "~/types/env";
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { tokenTable } from "./schema";
|
import { deviceTokensTable } from "./schema";
|
||||||
|
|
||||||
export function saveToken(
|
export function saveToken(
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -12,15 +12,15 @@ export function saveToken(
|
|||||||
username: string | null,
|
username: string | null,
|
||||||
) {
|
) {
|
||||||
return getDb(env)
|
return getDb(env)
|
||||||
.insert(tokenTable)
|
.insert(deviceTokensTable)
|
||||||
.values({ deviceId, token, username })
|
.values({ deviceId, token, username })
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateDeviceLastConnectedAt(env: Env, deviceId: string) {
|
export function updateDeviceLastConnectedAt(env: Env, deviceId: string) {
|
||||||
return getDb(env)
|
return getDb(env)
|
||||||
.update(tokenTable)
|
.update(deviceTokensTable)
|
||||||
.set({ lastConnectedAt: sql`(CURRENT_TIMESTAMP)` })
|
.set({ lastConnectedAt: sql`(CURRENT_TIMESTAMP)` })
|
||||||
.where(eq(tokenTable.deviceId, deviceId))
|
.where(eq(deviceTokensTable.deviceId, deviceId))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user