@twasik4/pocket-service 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ /// <reference types="vitest/globals" />
2
+ import type { Request } from "express";
3
+ import z from "zod";
4
+ import {
5
+ createAuthResolver,
6
+ createMetaRouterWithAuth,
7
+ defaultHeaderAuthResolver,
8
+ } from "../src/workers/express-types";
9
+
10
+ function createMockResponse() {
11
+ return {
12
+ statusCode: 200,
13
+ body: undefined as unknown,
14
+ status(code: number) {
15
+ this.statusCode = code;
16
+ return this;
17
+ },
18
+ json(payload: unknown) {
19
+ this.body = payload;
20
+ return this;
21
+ },
22
+ };
23
+ }
24
+
25
+ describe("express-types", () => {
26
+ it("reads user from x-user-meta header when valid JSON is provided", () => {
27
+ const req = {
28
+ headers: {
29
+ "x-user-meta": JSON.stringify({ userId: "user-1", role: "admin" }),
30
+ },
31
+ } as unknown as Request;
32
+
33
+ expect(defaultHeaderAuthResolver(req)).toEqual({
34
+ userId: "user-1",
35
+ role: "admin",
36
+ });
37
+ });
38
+
39
+ it("returns null when x-user-meta is invalid JSON", () => {
40
+ const req = {
41
+ headers: {
42
+ "x-user-id": "user-1",
43
+ "x-user-meta": "{invalid-json",
44
+ },
45
+ } as unknown as Request;
46
+
47
+ expect(defaultHeaderAuthResolver(req)).toBeNull();
48
+ });
49
+
50
+ it("falls back to x-user-id header when x-user-meta is not set", () => {
51
+ const req = {
52
+ headers: { "x-user-id": "user-2" },
53
+ } as unknown as Request;
54
+
55
+ expect(defaultHeaderAuthResolver(req)).toEqual({ userId: "user-2" });
56
+ });
57
+
58
+ it("uses auth strategies in order and then fallback resolver", async () => {
59
+ const authResolver = createAuthResolver(
60
+ [
61
+ {
62
+ name: "first",
63
+ canHandle: async () => false,
64
+ authenticate: async () => ({ userId: "ignored" }),
65
+ },
66
+ {
67
+ name: "second",
68
+ authenticate: async () => null,
69
+ },
70
+ ],
71
+ {
72
+ fallbackResolver: async () => ({ userId: "fallback" }),
73
+ },
74
+ );
75
+
76
+ await expect(
77
+ authResolver({ headers: {} } as unknown as Request, {
78
+ method: "GET",
79
+ fullPath: "/",
80
+ requireAuth: true,
81
+ }),
82
+ ).resolves.toEqual({ userId: "fallback" });
83
+ });
84
+
85
+ it("returns route metadata from createMetaRouterWithAuth", () => {
86
+ const { routes, addRoute } = createMetaRouterWithAuth();
87
+
88
+ addRoute(
89
+ {
90
+ method: "POST",
91
+ fullPath: "/items/:id",
92
+ requireAuth: true,
93
+ bodyValidator: z.object({ name: z.string() }),
94
+ paramsValidator: z.object({ id: z.string() }),
95
+ meta: { description: "Create item" },
96
+ },
97
+ (_req, res) => {
98
+ res.status(200).json({ ok: true });
99
+ },
100
+ );
101
+
102
+ expect(routes).toHaveLength(1);
103
+ expect(routes[0]).toMatchObject({
104
+ method: "POST",
105
+ fullPath: "/items/:id",
106
+ requireAuth: true,
107
+ meta: { description: "Create item" },
108
+ });
109
+ });
110
+
111
+ it("returns 401 for protected route when auth resolver returns null", async () => {
112
+ const { router, addRoute } = createMetaRouterWithAuth({
113
+ authResolver: async () => null,
114
+ });
115
+
116
+ addRoute(
117
+ {
118
+ method: "POST",
119
+ fullPath: "/secure",
120
+ requireAuth: true,
121
+ bodyValidator: z.object({ name: z.string() }),
122
+ },
123
+ (_req, res) => {
124
+ res.status(200).json({ ok: true });
125
+ },
126
+ );
127
+
128
+ const layer = (router as any).stack.find(
129
+ (l: any) => l.route?.path === "/secure",
130
+ );
131
+ const authMiddleware = layer.route.stack[0].handle;
132
+
133
+ const req = { headers: {}, body: { name: "a" } } as unknown as Request;
134
+ const res = createMockResponse();
135
+ const next = vi.fn();
136
+
137
+ await authMiddleware(req, res, next);
138
+
139
+ expect(next).not.toHaveBeenCalled();
140
+ expect(res.statusCode).toBe(401);
141
+ expect(res.body).toEqual({ isSuccess: false, message: "Unauthorized" });
142
+ });
143
+
144
+ it("parses JSON body string when object schema is used", () => {
145
+ const { router, addRoute } = createMetaRouterWithAuth();
146
+
147
+ addRoute(
148
+ {
149
+ method: "POST",
150
+ fullPath: "/body",
151
+ bodyValidator: z.object({ count: z.number() }),
152
+ },
153
+ (_req, res) => {
154
+ res.status(200).json({ ok: true });
155
+ },
156
+ );
157
+
158
+ const layer = (router as any).stack.find(
159
+ (l: any) => l.route?.path === "/body",
160
+ );
161
+ const bodyMiddleware = layer.route.stack[0].handle;
162
+
163
+ const req = { body: '{"count":1}' } as Request;
164
+ const res = createMockResponse();
165
+ const next = vi.fn();
166
+
167
+ bodyMiddleware(req, res as any, next);
168
+
169
+ expect(next).toHaveBeenCalledOnce();
170
+ expect(req.body).toEqual({ count: 1 });
171
+ });
172
+
173
+ it("returns 400 for invalid JSON body string", () => {
174
+ const { router, addRoute } = createMetaRouterWithAuth();
175
+
176
+ addRoute(
177
+ {
178
+ method: "POST",
179
+ fullPath: "/invalid-body",
180
+ bodyValidator: z.object({ count: z.number() }),
181
+ },
182
+ (_req, res) => {
183
+ res.status(200).json({ ok: true });
184
+ },
185
+ );
186
+
187
+ const layer = (router as any).stack.find(
188
+ (l: any) => l.route?.path === "/invalid-body",
189
+ );
190
+ const bodyMiddleware = layer.route.stack[0].handle;
191
+
192
+ const req = { body: "{bad" } as Request;
193
+ const res = createMockResponse();
194
+ const next = vi.fn();
195
+
196
+ bodyMiddleware(req, res as any, next);
197
+
198
+ expect(next).not.toHaveBeenCalled();
199
+ expect(res.statusCode).toBe(400);
200
+ expect((res.body as any).message).toBe("Invalid JSON in request body");
201
+ });
202
+
203
+ it("returns 400 when params validation fails", () => {
204
+ const { router, addRoute } = createMetaRouterWithAuth();
205
+
206
+ addRoute(
207
+ {
208
+ method: "GET",
209
+ fullPath: "/items/:id",
210
+ paramsValidator: z.object({ id: z.uuid() }),
211
+ },
212
+ (_req, res) => {
213
+ res.status(200).json({ ok: true });
214
+ },
215
+ );
216
+
217
+ const layer = (router as any).stack.find(
218
+ (l: any) => l.route?.path === "/items/:id",
219
+ );
220
+ const paramsMiddleware = layer.route.stack[0].handle;
221
+
222
+ const req = { params: { id: "not-a-uuid" } } as unknown as Request;
223
+ const res = createMockResponse();
224
+ const next = vi.fn();
225
+
226
+ paramsMiddleware(req, res as any, next);
227
+
228
+ expect(next).not.toHaveBeenCalled();
229
+ expect(res.statusCode).toBe(400);
230
+ expect((res.body as any).message).toBe("Invalid URL parameters");
231
+ });
232
+ });
@@ -0,0 +1,172 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { MongoMemoryServer } from "mongodb-memory-server";
3
+ import { ObjectId } from "mongodb";
4
+ import z from "zod";
5
+ import {
6
+ createMongo,
7
+ defineMongoCollection,
8
+ type MongoCollectionsConfig,
9
+ } from "../src/workers/db/mongo/mongo";
10
+
11
+ describe("Mongo abstraction advanced behaviors", () => {
12
+ let mongoServer: MongoMemoryServer;
13
+
14
+ beforeAll(async () => {
15
+ mongoServer = await MongoMemoryServer.create();
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await mongoServer.stop();
20
+ });
21
+
22
+ it("supports soft delete filtering and deletedBy attribution", async () => {
23
+ const mongo = createMongo(
24
+ "soft_delete_db",
25
+ {
26
+ users: defineMongoCollection({
27
+ schema: z.object({
28
+ name: z.string(),
29
+ deletedAt: z.date().optional(),
30
+ deletedBy: z.string().optional(),
31
+ }),
32
+ }),
33
+ },
34
+ { uri: mongoServer.getUri() },
35
+ );
36
+
37
+ await mongo.connect();
38
+
39
+ await mongo.users.insert({ name: "Ada" });
40
+ await mongo.users.delete({ name: "Ada" });
41
+
42
+ await mongo.users.insert({ name: "Grace" });
43
+ await mongo.users.delete({ name: "Grace" }, "admin-user");
44
+
45
+ const visible = await mongo.users.get({ name: "Ada" });
46
+ const rawAda = await mongo.db.collection("users").findOne({ name: "Ada" });
47
+ const rawGrace = await mongo.db.collection("users").findOne({
48
+ name: "Grace",
49
+ });
50
+
51
+ expect(visible).toEqual([]);
52
+ expect(rawAda?.deletedAt).toBeInstanceOf(Date);
53
+ expect(rawAda?.deletedBy).toBe("system");
54
+ expect(rawGrace?.deletedBy).toBe("admin-user");
55
+
56
+ await mongo.disconnect();
57
+ });
58
+
59
+ it("tracks revisions and updates updatedAt/revision on updateOne", async () => {
60
+ const config = {
61
+ records: {
62
+ ...defineMongoCollection({
63
+ schema: z.object({
64
+ key: z.string(),
65
+ value: z.string(),
66
+ revision: z.number().default(0),
67
+ updatedAt: z.date().optional(),
68
+ }),
69
+ }),
70
+ trackRevisions: true,
71
+ },
72
+ } satisfies MongoCollectionsConfig;
73
+
74
+ const mongo = createMongo("revisions_db", config, {
75
+ uri: mongoServer.getUri(),
76
+ });
77
+
78
+ await mongo.connect();
79
+
80
+ await mongo.records.insert({
81
+ key: "k1",
82
+ value: "v1",
83
+ revision: 0,
84
+ updatedAt: new Date("2020-01-01T00:00:00.000Z"),
85
+ });
86
+
87
+ await mongo.records.updateOne({ key: "k1" }, { value: "v2" });
88
+
89
+ const current = await mongo.records.getOne({ key: "k1" });
90
+ const revisions = await mongo.records.getRevisions({ key: "k1" });
91
+ const mostRecent = await mongo.records.getMostRecentRevision();
92
+
93
+ expect(current?.value).toBe("v2");
94
+ expect(current?.revision).toBe(1);
95
+ expect(current?.updatedAt).toBeInstanceOf(Date);
96
+ expect(revisions.length).toBeGreaterThanOrEqual(2);
97
+ expect(mostRecent?.value).toBe("v2");
98
+
99
+ await mongo.disconnect();
100
+ });
101
+
102
+ it("creates configured indexes during setup", async () => {
103
+ const mongo = createMongo(
104
+ "index_setup_db",
105
+ {
106
+ users: defineMongoCollection({
107
+ schema: z.object({
108
+ email: z.string(),
109
+ createdAt: z.date(),
110
+ }),
111
+ indexes: [
112
+ { key: { email: 1 }, unique: true, name: "users_email_unique" },
113
+ { key: { createdAt: 1 }, name: "users_created_at" },
114
+ ],
115
+ }),
116
+ },
117
+ { uri: mongoServer.getUri() },
118
+ );
119
+
120
+ await mongo.connect();
121
+
122
+ const indexes = await mongo.db.collection("users").indexes();
123
+
124
+ expect(indexes.some((idx) => idx.name === "users_email_unique")).toBe(true);
125
+ expect(indexes.some((idx) => idx.name === "users_created_at")).toBe(true);
126
+
127
+ await mongo.disconnect();
128
+ });
129
+
130
+ it("fails when enabling timeSeries on an existing non-time-series collection", async () => {
131
+ const dbName = `timeseries_mismatch_${new ObjectId().toHexString()}`;
132
+
133
+ const initial = createMongo(
134
+ dbName,
135
+ {
136
+ metrics: defineMongoCollection({
137
+ schema: z.object({
138
+ timestamp: z.date(),
139
+ value: z.number(),
140
+ }),
141
+ }),
142
+ },
143
+ { uri: mongoServer.getUri() },
144
+ );
145
+
146
+ await initial.connect();
147
+ await initial.db.createCollection("legacy_metrics");
148
+ await initial.disconnect();
149
+
150
+ const withTimeSeries = createMongo(
151
+ dbName,
152
+ {
153
+ legacy_metrics: defineMongoCollection({
154
+ schema: z.object({
155
+ timestamp: z.date(),
156
+ value: z.number(),
157
+ }),
158
+ timeSeries: {
159
+ timeField: "timestamp",
160
+ },
161
+ }),
162
+ },
163
+ { uri: mongoServer.getUri() },
164
+ );
165
+
166
+ await expect(withTimeSeries.connect()).rejects.toThrow(
167
+ "already exists and is not a time-series collection",
168
+ );
169
+
170
+ await withTimeSeries.disconnect();
171
+ });
172
+ });
@@ -89,4 +89,53 @@ describe("MongoDB Dependency", () => {
89
89
  expect(res[0].name).toEqual("updated");
90
90
  console.log("Updated document:", res[0]);
91
91
  });
92
+
93
+ it("Should support creating and using time-series collections", async () => {
94
+ const tsService = new Service()
95
+ .withName("admin-service")
96
+ .withPort(4121)
97
+ .withRedis("redis://localhost:6379")
98
+ .withMongo({
99
+ dbName: "test_timeseries",
100
+ uri: mongoServer.getUri(),
101
+ collections: {
102
+ metrics: defineMongoCollection({
103
+ schema: z.object({
104
+ timestamp: z.date(),
105
+ host: z.string(),
106
+ value: z.number(),
107
+ }),
108
+ timeSeries: {
109
+ timeField: "timestamp",
110
+ metaField: "host",
111
+ granularity: "seconds",
112
+ },
113
+ }),
114
+ },
115
+ })
116
+ .build();
117
+
118
+ await tsService.mongo.connect();
119
+
120
+ const collectionInfo = await tsService.mongo.db
121
+ .listCollections({ name: "metrics" }, { nameOnly: false })
122
+ .next();
123
+
124
+ expect(
125
+ collectionInfo?.type === "timeseries" ||
126
+ Boolean((collectionInfo?.options as any)?.timeseries),
127
+ ).toBe(true);
128
+
129
+ await tsService.db.metrics.insert({
130
+ timestamp: new Date(),
131
+ host: "app-1",
132
+ value: 42,
133
+ });
134
+ const values = await tsService.db.metrics.get({ host: "app-1" });
135
+
136
+ expect(values.length).toBe(1);
137
+ expect(values[0].value).toBe(42);
138
+
139
+ await tsService.mongo.disconnect();
140
+ });
92
141
  });
@@ -1,7 +1,7 @@
1
1
  /// <reference types="vitest/globals" />
2
2
  import { Service } from "../src/workers/service";
3
3
 
4
- describe.only("Redis Dependency", () => {
4
+ describe("Redis Dependency", () => {
5
5
  let service: Service<{}>;
6
6
 
7
7
  beforeAll(async () => {
@@ -0,0 +1,30 @@
1
+ /// <reference types="vitest/globals" />
2
+ import {
3
+ generateRandomHexString,
4
+ getUseableDatesFromMs,
5
+ } from "../src/workers/utils";
6
+
7
+ describe("Utils", () => {
8
+ it("generates a hex string of the requested length", () => {
9
+ const value = generateRandomHexString(16);
10
+ expect(value).toHaveLength(16);
11
+ expect(value).toMatch(/^[0-9a-f]+$/);
12
+ });
13
+
14
+ it("throws when requested length is odd", () => {
15
+ expect(() => generateRandomHexString(3)).toThrow(
16
+ "Length must be an even number",
17
+ );
18
+ });
19
+
20
+ it("converts milliseconds to day/hour/minute/second parts", () => {
21
+ const ms =
22
+ 2 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000 + 4 * 60 * 1000 + 5 * 1000;
23
+ expect(getUseableDatesFromMs(ms)).toEqual({
24
+ days: 2,
25
+ hours: 3,
26
+ minutes: 4,
27
+ seconds: 5,
28
+ });
29
+ });
30
+ });