@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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.
- package/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import { EntityService } from "../src/services/entityService";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
4
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
5
|
+
const collectionRegistry = new PostgresCollectionRegistry();
|
|
6
|
+
|
|
7
|
+
// --- Mock Drizzle ORM table definitions ---
|
|
8
|
+
const mockAuthorsTable = {
|
|
9
|
+
id: { name: "id" },
|
|
10
|
+
name: { name: "name" },
|
|
11
|
+
_def: { tableName: "authors" }
|
|
12
|
+
};
|
|
13
|
+
const mockPostsTable = {
|
|
14
|
+
id: { name: "id",
|
|
15
|
+
dataType: "number" },
|
|
16
|
+
title: { name: "title" },
|
|
17
|
+
author_id: { name: "author_id",
|
|
18
|
+
dataType: "number" },
|
|
19
|
+
_def: { tableName: "posts" }
|
|
20
|
+
};
|
|
21
|
+
const mockTagsTable = {
|
|
22
|
+
id: { name: "id",
|
|
23
|
+
dataType: "number" },
|
|
24
|
+
name: { name: "name" },
|
|
25
|
+
_def: { tableName: "tags" }
|
|
26
|
+
};
|
|
27
|
+
const mockPostsTagsTable = {
|
|
28
|
+
post_id: { name: "post_id",
|
|
29
|
+
dataType: "number" },
|
|
30
|
+
tag_id: { name: "tag_id",
|
|
31
|
+
dataType: "number" },
|
|
32
|
+
_def: { tableName: "posts_tags" }
|
|
33
|
+
};
|
|
34
|
+
const mockProjectUsersTable = {
|
|
35
|
+
project_id: { name: "project_id" },
|
|
36
|
+
id: { name: "id" },
|
|
37
|
+
email: { name: "email" },
|
|
38
|
+
_def: { tableName: "project_users" }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// --- Correctly typed Mock Entity Collections ---
|
|
42
|
+
let authorsCollection: EntityCollection;
|
|
43
|
+
const tagsCollection: EntityCollection = {
|
|
44
|
+
slug: "tags",
|
|
45
|
+
name: "Tags",
|
|
46
|
+
table: "tags",
|
|
47
|
+
properties: {
|
|
48
|
+
id: { type: "number" },
|
|
49
|
+
name: { type: "string" }
|
|
50
|
+
},
|
|
51
|
+
idField: "id"
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const projectUsersCollection: EntityCollection = {
|
|
55
|
+
slug: "project_users",
|
|
56
|
+
name: "Project Users",
|
|
57
|
+
table: "project_users",
|
|
58
|
+
properties: {
|
|
59
|
+
project_id: { type: "string" },
|
|
60
|
+
id: { type: "string" },
|
|
61
|
+
email: { type: "string" }
|
|
62
|
+
},
|
|
63
|
+
primaryKeys: ["project_id", "id"]
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const postsCollection: EntityCollection = {
|
|
67
|
+
slug: "posts",
|
|
68
|
+
name: "Posts",
|
|
69
|
+
table: "posts",
|
|
70
|
+
properties: {
|
|
71
|
+
id: { type: "number" },
|
|
72
|
+
title: { type: "string" },
|
|
73
|
+
author: {
|
|
74
|
+
type: "relation",
|
|
75
|
+
relationName: "author"
|
|
76
|
+
},
|
|
77
|
+
tags: {
|
|
78
|
+
type: "relation",
|
|
79
|
+
relationName: "tags"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
relations: [
|
|
83
|
+
{
|
|
84
|
+
relationName: "author",
|
|
85
|
+
target: () => authorsCollection,
|
|
86
|
+
cardinality: "one",
|
|
87
|
+
direction: "owning",
|
|
88
|
+
localKey: "author_id"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
relationName: "tags",
|
|
92
|
+
target: () => tagsCollection,
|
|
93
|
+
cardinality: "many",
|
|
94
|
+
direction: "owning",
|
|
95
|
+
through: {
|
|
96
|
+
table: "posts_tags",
|
|
97
|
+
sourceColumn: "post_id",
|
|
98
|
+
targetColumn: "tag_id"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
idField: "id"
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
authorsCollection = {
|
|
106
|
+
slug: "authors",
|
|
107
|
+
name: "Authors",
|
|
108
|
+
table: "authors",
|
|
109
|
+
properties: {
|
|
110
|
+
id: { type: "number" },
|
|
111
|
+
name: { type: "string" },
|
|
112
|
+
posts: {
|
|
113
|
+
type: "relation",
|
|
114
|
+
relationName: "posts"
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
relations: [
|
|
118
|
+
{
|
|
119
|
+
relationName: "posts",
|
|
120
|
+
target: () => postsCollection,
|
|
121
|
+
cardinality: "many",
|
|
122
|
+
direction: "inverse",
|
|
123
|
+
foreignKeyOnTarget: "author_id"
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
idField: "id"
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
describe("EntityService", () => {
|
|
130
|
+
let entityService: EntityService;
|
|
131
|
+
let db: jest.Mocked<NodePgDatabase>;
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
|
|
135
|
+
if (path.startsWith("authors")) return authorsCollection;
|
|
136
|
+
if (path.startsWith("posts")) return postsCollection;
|
|
137
|
+
if (path.startsWith("tags")) return tagsCollection;
|
|
138
|
+
if (path.startsWith("project_users")) return projectUsersCollection;
|
|
139
|
+
return undefined;
|
|
140
|
+
});
|
|
141
|
+
jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
|
|
142
|
+
if (tableName === "authors") return mockAuthorsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
143
|
+
if (tableName === "posts") return mockPostsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
144
|
+
if (tableName === "tags") return mockTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
145
|
+
if (tableName === "posts_tags") return mockPostsTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
146
|
+
if (tableName === "project_users") return mockProjectUsersTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
147
|
+
return undefined;
|
|
148
|
+
});
|
|
149
|
+
jest.spyOn(collectionRegistry, "getCollections").mockReturnValue([authorsCollection, postsCollection, tagsCollection, projectUsersCollection]);
|
|
150
|
+
|
|
151
|
+
db = {
|
|
152
|
+
select: jest.fn().mockReturnThis(),
|
|
153
|
+
from: jest.fn().mockReturnThis(),
|
|
154
|
+
where: jest.fn().mockReturnThis(),
|
|
155
|
+
$dynamic: jest.fn().mockReturnThis(),
|
|
156
|
+
limit: jest.fn().mockResolvedValue([]),
|
|
157
|
+
orderBy: jest.fn().mockResolvedValue([]), // This is now a terminal operation by default
|
|
158
|
+
innerJoin: jest.fn().mockReturnThis(),
|
|
159
|
+
insert: jest.fn().mockReturnThis(),
|
|
160
|
+
values: jest.fn().mockReturnThis(),
|
|
161
|
+
returning: jest.fn().mockResolvedValue([]),
|
|
162
|
+
update: jest.fn().mockReturnThis(),
|
|
163
|
+
set: jest.fn().mockReturnThis(),
|
|
164
|
+
delete: jest.fn().mockReturnThis(),
|
|
165
|
+
transaction: jest.fn((callback) => callback(db))
|
|
166
|
+
} as unknown as jest.Mocked<NodePgDatabase>;
|
|
167
|
+
|
|
168
|
+
entityService = new EntityService(db, collectionRegistry);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
jest.restoreAllMocks();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("fetchEntity", () => {
|
|
176
|
+
it("should parse a 'one' relation ID into a relation object with a string ID", async () => {
|
|
177
|
+
const mockPost = {
|
|
178
|
+
id: 2,
|
|
179
|
+
title: "My First Post",
|
|
180
|
+
author: 1
|
|
181
|
+
};
|
|
182
|
+
db.limit.mockResolvedValue([mockPost]);
|
|
183
|
+
const entity = await entityService.fetchEntity("posts", 2);
|
|
184
|
+
// The service correctly converts relation IDs to strings.
|
|
185
|
+
expect(entity?.values.author).toEqual({
|
|
186
|
+
id: "1",
|
|
187
|
+
path: "authors",
|
|
188
|
+
__type: "relation"
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should parse a composite ID and use both parts in the WHERE clause", async () => {
|
|
193
|
+
const mockUser = {
|
|
194
|
+
project_id: "proj1",
|
|
195
|
+
id: "user1",
|
|
196
|
+
email: "test@test.com"
|
|
197
|
+
};
|
|
198
|
+
db.limit.mockResolvedValue([mockUser] as unknown as never);
|
|
199
|
+
|
|
200
|
+
const entity = await entityService.fetchEntity("project_users", "proj1:::user1");
|
|
201
|
+
|
|
202
|
+
// Check that we fetched the actual mocked user
|
|
203
|
+
expect(entity?.id).toBe("proj1:::user1");
|
|
204
|
+
expect(entity?.values.email).toBe("test@test.com");
|
|
205
|
+
|
|
206
|
+
expect(db.select).toHaveBeenCalled();
|
|
207
|
+
expect(db.from).toHaveBeenCalled();
|
|
208
|
+
expect(db.where).toHaveBeenCalled();
|
|
209
|
+
expect(db.limit).toHaveBeenCalledWith(1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("saveEntity (create)", () => {
|
|
214
|
+
it("should correctly serialize and deserialize a 'one' relation", async () => {
|
|
215
|
+
const newPost = {
|
|
216
|
+
title: "Post by Jane",
|
|
217
|
+
author: {
|
|
218
|
+
id: "3",
|
|
219
|
+
path: "authors",
|
|
220
|
+
__type: "relation"
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
db.returning.mockResolvedValue([{ id: 4 }]);
|
|
224
|
+
// Mock the fetch-back call after the save
|
|
225
|
+
db.limit.mockResolvedValue([{
|
|
226
|
+
id: 4,
|
|
227
|
+
title: "Post by Jane",
|
|
228
|
+
author_id: "3" // Database stores the foreign key
|
|
229
|
+
}]);
|
|
230
|
+
|
|
231
|
+
const entity = await entityService.saveEntity("posts", newPost);
|
|
232
|
+
|
|
233
|
+
// 1. Check that the relation was serialized to a foreign key for the database insert
|
|
234
|
+
expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
|
|
235
|
+
title: "Post by Jane",
|
|
236
|
+
author_id: "3" // Should be serialized to FK for database storage
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
// 2. Check that the returned entity has the relation deserialized correctly
|
|
240
|
+
expect(entity.id).toBe("4");
|
|
241
|
+
expect(entity.values.author).toEqual({
|
|
242
|
+
id: "3",
|
|
243
|
+
path: "authors",
|
|
244
|
+
__type: "relation"
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should save an entity with composite primary keys in UPSERT/onConflictDoUpdate mode", async () => {
|
|
249
|
+
const valuesToSave = {
|
|
250
|
+
project_id: "proj1",
|
|
251
|
+
id: "user1",
|
|
252
|
+
email: "new@test.com"
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const returnedSaved = {
|
|
256
|
+
project_id: "proj1",
|
|
257
|
+
id: "user1",
|
|
258
|
+
email: "new@test.com"
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const mockWhere = jest.fn().mockResolvedValue([returnedSaved]);
|
|
262
|
+
const mockSet = jest.fn().mockReturnValue({
|
|
263
|
+
where: mockWhere
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Intercept update chain
|
|
267
|
+
db.update.mockReturnValue({
|
|
268
|
+
set: mockSet
|
|
269
|
+
} as unknown as ReturnType<typeof db.update>);
|
|
270
|
+
|
|
271
|
+
// Mock fetch back (the final step of saveEntity)
|
|
272
|
+
db.limit.mockResolvedValue([returnedSaved] as unknown as never);
|
|
273
|
+
|
|
274
|
+
const savedEntity = await entityService.saveEntity("project_users", valuesToSave, "proj1:::user1");
|
|
275
|
+
|
|
276
|
+
expect(db.update).toHaveBeenCalled();
|
|
277
|
+
expect(mockSet).toHaveBeenCalledWith(expect.objectContaining({ email: "new@test.com" }));
|
|
278
|
+
expect(mockWhere).toHaveBeenCalled();
|
|
279
|
+
|
|
280
|
+
expect(savedEntity.id).toBe("proj1:::user1");
|
|
281
|
+
expect(savedEntity.values.email).toBe("new@test.com");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("saveEntity (update)", () => {
|
|
286
|
+
it("should update junction table for a 'many' relation", async () => {
|
|
287
|
+
const updatedPost = { tags: [{ id: 11 }, { id: 12 }] };
|
|
288
|
+
db.limit.mockResolvedValue([{
|
|
289
|
+
id: 5,
|
|
290
|
+
title: "Post with Tags"
|
|
291
|
+
}]);
|
|
292
|
+
|
|
293
|
+
await entityService.saveEntity("posts", updatedPost, 5);
|
|
294
|
+
|
|
295
|
+
expect(db.delete).toHaveBeenCalledWith(mockPostsTagsTable);
|
|
296
|
+
expect(db.where).toHaveBeenCalledWith(expect.any(Object));
|
|
297
|
+
expect(db.insert).toHaveBeenCalledWith(mockPostsTagsTable);
|
|
298
|
+
expect(db.values).toHaveBeenLastCalledWith([{
|
|
299
|
+
post_id: 5,
|
|
300
|
+
tag_id: 11
|
|
301
|
+
}, {
|
|
302
|
+
post_id: 5,
|
|
303
|
+
tag_id: 12
|
|
304
|
+
}]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("fetchCollectionFromPath", () => {
|
|
309
|
+
it("should fetch related entities from a nested path", async () => {
|
|
310
|
+
const mockRelatedPosts = [
|
|
311
|
+
{ id: 1,
|
|
312
|
+
title: "Post by John",
|
|
313
|
+
author_id: 1 },
|
|
314
|
+
{ id: 2,
|
|
315
|
+
title: "Another Post by John",
|
|
316
|
+
author_id: 1 }
|
|
317
|
+
];
|
|
318
|
+
// RelationService.fetchEntitiesUsingJoins ends query chain with where(), not orderBy()
|
|
319
|
+
// Make where() awaitable by returning a promise-like object
|
|
320
|
+
db.where.mockReturnValue({
|
|
321
|
+
then: (resolve: Function) => resolve(mockRelatedPosts),
|
|
322
|
+
limit: jest.fn().mockReturnValue({
|
|
323
|
+
then: (resolve: Function) => resolve(mockRelatedPosts)
|
|
324
|
+
})
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const entities = await entityService.fetchCollection("authors/1/posts", {});
|
|
328
|
+
|
|
329
|
+
// The service should have been called to get the 'authors' collection definition.
|
|
330
|
+
expect(collectionRegistry.getCollectionByPath).toHaveBeenCalledWith("authors");
|
|
331
|
+
// For inverse relations like authors->posts, no join is needed as it uses a WHERE clause on the foreign key
|
|
332
|
+
expect(entities).toHaveLength(2);
|
|
333
|
+
expect(entities[0].values.title).toBe("Post by John");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
describe("EntityService - Comprehensive Tests", () => {
|
|
340
|
+
let entityService: EntityService;
|
|
341
|
+
let db: jest.Mocked<NodePgDatabase<any>>;
|
|
342
|
+
|
|
343
|
+
// Extended mock tables for more complex scenarios
|
|
344
|
+
const mockUsersTable = {
|
|
345
|
+
id: { name: "id",
|
|
346
|
+
dataType: "number" },
|
|
347
|
+
email: { name: "email" },
|
|
348
|
+
name: { name: "name" },
|
|
349
|
+
created_at: { name: "created_at" },
|
|
350
|
+
project_id: { name: "project_id" }, // Add for relations
|
|
351
|
+
assignee_id: { name: "assignee_id" }, // Add for relations
|
|
352
|
+
_def: { tableName: "users" }
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const mockCompaniesTable = {
|
|
356
|
+
id: { name: "id" },
|
|
357
|
+
name: { name: "name" },
|
|
358
|
+
user_id: { name: "user_id" },
|
|
359
|
+
company_id: { name: "company_id" }, // Add for relations
|
|
360
|
+
_def: { tableName: "companies" }
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const mockProjectsTable = {
|
|
364
|
+
id: { name: "id" },
|
|
365
|
+
title: { name: "title" },
|
|
366
|
+
description: { name: "description" },
|
|
367
|
+
company_id: { name: "company_id" },
|
|
368
|
+
status: { name: "status" },
|
|
369
|
+
priority: { name: "priority" },
|
|
370
|
+
project_id: { name: "project_id" }, // Add for relations
|
|
371
|
+
assignee_id: { name: "assignee_id" }, // Add for relations
|
|
372
|
+
_def: { tableName: "projects" }
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const mockTasksTable = {
|
|
376
|
+
id: { name: "id" },
|
|
377
|
+
title: { name: "title" },
|
|
378
|
+
project_id: { name: "project_id" },
|
|
379
|
+
assignee_id: { name: "assignee_id" },
|
|
380
|
+
_def: { tableName: "tasks" }
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const mockCategoriesTable = {
|
|
384
|
+
id: { name: "id" },
|
|
385
|
+
name: { name: "name" },
|
|
386
|
+
parent_id: { name: "parent_id" },
|
|
387
|
+
_def: { tableName: "categories" }
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const mockProjectTagsTable = {
|
|
391
|
+
project_id: { name: "project_id" },
|
|
392
|
+
tag_id: { name: "tag_id" },
|
|
393
|
+
_def: { tableName: "project_tags" }
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const mockTagsTable = {
|
|
397
|
+
id: { name: "id",
|
|
398
|
+
dataType: "number" },
|
|
399
|
+
name: { name: "name" },
|
|
400
|
+
_def: { tableName: "tags" }
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Complex collection definitions
|
|
404
|
+
const usersCollection: EntityCollection = {
|
|
405
|
+
slug: "users",
|
|
406
|
+
name: "Users",
|
|
407
|
+
table: "users",
|
|
408
|
+
properties: {
|
|
409
|
+
id: { type: "number" },
|
|
410
|
+
email: { type: "string" },
|
|
411
|
+
name: { type: "string" },
|
|
412
|
+
created_at: { type: "date" },
|
|
413
|
+
companies: { type: "relation",
|
|
414
|
+
relationName: "companies" }
|
|
415
|
+
},
|
|
416
|
+
relations: [{
|
|
417
|
+
relationName: "companies",
|
|
418
|
+
target: () => companiesCollection,
|
|
419
|
+
cardinality: "many",
|
|
420
|
+
direction: "inverse",
|
|
421
|
+
foreignKeyOnTarget: "user_id"
|
|
422
|
+
}],
|
|
423
|
+
idField: "id"
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const companiesCollection: EntityCollection = {
|
|
427
|
+
slug: "companies",
|
|
428
|
+
name: "Companies",
|
|
429
|
+
table: "companies",
|
|
430
|
+
properties: {
|
|
431
|
+
id: { type: "number" },
|
|
432
|
+
name: { type: "string" },
|
|
433
|
+
owner: { type: "relation",
|
|
434
|
+
relationName: "owner" },
|
|
435
|
+
projects: { type: "relation",
|
|
436
|
+
relationName: "projects" }
|
|
437
|
+
},
|
|
438
|
+
relations: [
|
|
439
|
+
{
|
|
440
|
+
relationName: "owner",
|
|
441
|
+
target: () => usersCollection,
|
|
442
|
+
cardinality: "one",
|
|
443
|
+
direction: "owning",
|
|
444
|
+
localKey: "user_id"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
relationName: "projects",
|
|
448
|
+
target: () => projectsCollection,
|
|
449
|
+
cardinality: "many",
|
|
450
|
+
direction: "inverse",
|
|
451
|
+
foreignKeyOnTarget: "company_id"
|
|
452
|
+
}
|
|
453
|
+
],
|
|
454
|
+
idField: "id"
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const projectsCollection: EntityCollection = {
|
|
458
|
+
slug: "projects",
|
|
459
|
+
name: "Projects",
|
|
460
|
+
table: "projects",
|
|
461
|
+
properties: {
|
|
462
|
+
id: { type: "number" },
|
|
463
|
+
title: { type: "string" },
|
|
464
|
+
description: { type: "string" },
|
|
465
|
+
status: { type: "string" },
|
|
466
|
+
priority: { type: "number" },
|
|
467
|
+
company: { type: "relation",
|
|
468
|
+
relationName: "company" },
|
|
469
|
+
tasks: { type: "relation",
|
|
470
|
+
relationName: "tasks" },
|
|
471
|
+
tags: { type: "relation",
|
|
472
|
+
relationName: "tags" }
|
|
473
|
+
},
|
|
474
|
+
relations: [
|
|
475
|
+
{
|
|
476
|
+
relationName: "company",
|
|
477
|
+
target: () => companiesCollection,
|
|
478
|
+
cardinality: "one",
|
|
479
|
+
direction: "owning",
|
|
480
|
+
localKey: "company_id"
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
relationName: "tasks",
|
|
484
|
+
target: () => tasksCollection,
|
|
485
|
+
cardinality: "many",
|
|
486
|
+
direction: "inverse",
|
|
487
|
+
foreignKeyOnTarget: "project_id"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
relationName: "tags",
|
|
491
|
+
target: () => tagsCollection,
|
|
492
|
+
cardinality: "many",
|
|
493
|
+
direction: "owning",
|
|
494
|
+
through: {
|
|
495
|
+
table: "project_tags",
|
|
496
|
+
sourceColumn: "project_id",
|
|
497
|
+
targetColumn: "tag_id"
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
],
|
|
501
|
+
idField: "id"
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const tasksCollection: EntityCollection = {
|
|
505
|
+
slug: "tasks",
|
|
506
|
+
name: "Tasks",
|
|
507
|
+
table: "tasks",
|
|
508
|
+
properties: {
|
|
509
|
+
id: { type: "number" },
|
|
510
|
+
title: { type: "string" },
|
|
511
|
+
project: { type: "relation",
|
|
512
|
+
relationName: "project" },
|
|
513
|
+
assignee: { type: "relation",
|
|
514
|
+
relationName: "assignee" }
|
|
515
|
+
},
|
|
516
|
+
relations: [
|
|
517
|
+
{
|
|
518
|
+
relationName: "project",
|
|
519
|
+
target: () => projectsCollection,
|
|
520
|
+
cardinality: "one",
|
|
521
|
+
direction: "owning",
|
|
522
|
+
localKey: "project_id"
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
relationName: "assignee",
|
|
526
|
+
target: () => usersCollection,
|
|
527
|
+
cardinality: "one",
|
|
528
|
+
direction: "owning",
|
|
529
|
+
localKey: "assignee_id"
|
|
530
|
+
}
|
|
531
|
+
],
|
|
532
|
+
idField: "id"
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const tagsCollection: EntityCollection = {
|
|
536
|
+
slug: "tags",
|
|
537
|
+
name: "Tags",
|
|
538
|
+
table: "tags",
|
|
539
|
+
properties: {
|
|
540
|
+
id: { type: "number" },
|
|
541
|
+
name: { type: "string" }
|
|
542
|
+
},
|
|
543
|
+
idField: "id"
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const categoriesCollection: EntityCollection = {
|
|
547
|
+
slug: "categories",
|
|
548
|
+
name: "Categories",
|
|
549
|
+
table: "categories",
|
|
550
|
+
properties: {
|
|
551
|
+
id: { type: "number" },
|
|
552
|
+
name: { type: "string" },
|
|
553
|
+
parent: { type: "relation",
|
|
554
|
+
relationName: "parent" },
|
|
555
|
+
children: { type: "relation",
|
|
556
|
+
relationName: "children" }
|
|
557
|
+
},
|
|
558
|
+
relations: [
|
|
559
|
+
{
|
|
560
|
+
relationName: "parent",
|
|
561
|
+
target: () => categoriesCollection,
|
|
562
|
+
cardinality: "one",
|
|
563
|
+
direction: "owning",
|
|
564
|
+
localKey: "parent_id"
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
relationName: "children",
|
|
568
|
+
target: () => categoriesCollection,
|
|
569
|
+
cardinality: "many",
|
|
570
|
+
direction: "inverse",
|
|
571
|
+
foreignKeyOnTarget: "parent_id"
|
|
572
|
+
}
|
|
573
|
+
],
|
|
574
|
+
idField: "id"
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
beforeEach(() => {
|
|
578
|
+
jest.clearAllMocks();
|
|
579
|
+
|
|
580
|
+
jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
|
|
581
|
+
if (path.startsWith("users")) return usersCollection;
|
|
582
|
+
if (path.startsWith("companies")) return companiesCollection;
|
|
583
|
+
if (path.startsWith("projects")) return projectsCollection;
|
|
584
|
+
if (path.startsWith("tasks")) return tasksCollection;
|
|
585
|
+
if (path.startsWith("tags")) return tagsCollection;
|
|
586
|
+
if (path.startsWith("categories")) return categoriesCollection;
|
|
587
|
+
return undefined;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
|
|
591
|
+
if (tableName === "users") return mockUsersTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
592
|
+
if (tableName === "companies") return mockCompaniesTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
593
|
+
if (tableName === "projects") return mockProjectsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
594
|
+
if (tableName === "tasks") return mockTasksTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
595
|
+
if (tableName === "tags") return mockTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
596
|
+
if (tableName === "categories") return mockCategoriesTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
597
|
+
if (tableName === "project_tags") return mockProjectTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
598
|
+
return undefined;
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
db = {
|
|
602
|
+
select: jest.fn().mockReturnThis(),
|
|
603
|
+
from: jest.fn().mockReturnThis(),
|
|
604
|
+
where: jest.fn().mockReturnThis(),
|
|
605
|
+
$dynamic: jest.fn().mockReturnThis(),
|
|
606
|
+
limit: jest.fn().mockReturnThis(),
|
|
607
|
+
orderBy: jest.fn().mockReturnThis(),
|
|
608
|
+
innerJoin: jest.fn().mockReturnThis(),
|
|
609
|
+
insert: jest.fn().mockReturnThis(),
|
|
610
|
+
values: jest.fn().mockReturnThis(),
|
|
611
|
+
returning: jest.fn().mockResolvedValue([]),
|
|
612
|
+
update: jest.fn().mockReturnThis(),
|
|
613
|
+
set: jest.fn().mockReturnThis(),
|
|
614
|
+
delete: jest.fn().mockReturnThis(),
|
|
615
|
+
transaction: jest.fn((callback) => callback(db))
|
|
616
|
+
} as unknown as jest.Mocked<NodePgDatabase>;
|
|
617
|
+
|
|
618
|
+
// Add a then method to make the db object awaitable when the query chain ends
|
|
619
|
+
(db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve) => resolve([]));
|
|
620
|
+
|
|
621
|
+
entityService = new EntityService(db, collectionRegistry);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe("fetchEntity - Edge Cases", () => {
|
|
625
|
+
it("should handle numeric IDs correctly", async () => {
|
|
626
|
+
const mockUser = { id: 123,
|
|
627
|
+
email: "test@example.com",
|
|
628
|
+
name: "Test User" };
|
|
629
|
+
db.limit.mockResolvedValue([mockUser]);
|
|
630
|
+
|
|
631
|
+
const entity = await entityService.fetchEntity("users", 123);
|
|
632
|
+
|
|
633
|
+
expect(entity?.id).toBe("123");
|
|
634
|
+
expect(entity?.values.email).toBe("test@example.com");
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("should handle string IDs correctly for string ID type collections", async () => {
|
|
638
|
+
// Create a collection with string ID type
|
|
639
|
+
const stringIdCollection = {
|
|
640
|
+
...usersCollection,
|
|
641
|
+
properties: {
|
|
642
|
+
...usersCollection.properties,
|
|
643
|
+
id: { type: "string" }
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(stringIdCollection);
|
|
647
|
+
jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
|
|
648
|
+
if (tableName === "users") return {
|
|
649
|
+
id: { name: "id",
|
|
650
|
+
dataType: "string" },
|
|
651
|
+
email: { name: "email" },
|
|
652
|
+
name: { name: "name" },
|
|
653
|
+
_def: { tableName: "users" }
|
|
654
|
+
} as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
655
|
+
if (tableName === "companies") return mockCompaniesTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
656
|
+
if (tableName === "projects") return mockProjectsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
657
|
+
if (tableName === "tasks") return mockTasksTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
658
|
+
if (tableName === "tags") return mockTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
659
|
+
if (tableName === "categories") return mockCategoriesTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
660
|
+
if (tableName === "project_tags") return mockProjectTagsTable as unknown as ReturnType<typeof collectionRegistry.getTable>;
|
|
661
|
+
return undefined;
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const mockUser = { id: "uuid-123",
|
|
665
|
+
email: "test@example.com",
|
|
666
|
+
name: "Test User" };
|
|
667
|
+
db.limit.mockResolvedValue([mockUser]);
|
|
668
|
+
|
|
669
|
+
const entity = await entityService.fetchEntity("users", "uuid-123");
|
|
670
|
+
|
|
671
|
+
expect(entity?.id).toBe("uuid-123");
|
|
672
|
+
expect(entity?.values.email).toBe("test@example.com");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("should return undefined for non-existent entity", async () => {
|
|
676
|
+
db.limit.mockResolvedValue([]);
|
|
677
|
+
|
|
678
|
+
const entity = await entityService.fetchEntity("users", 999);
|
|
679
|
+
|
|
680
|
+
expect(entity).toBeUndefined();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("should handle entities with null relation fields", async () => {
|
|
684
|
+
const mockTask = { id: 1,
|
|
685
|
+
title: "Task 1",
|
|
686
|
+
project_id: null,
|
|
687
|
+
assignee_id: null };
|
|
688
|
+
db.limit.mockResolvedValue([mockTask]);
|
|
689
|
+
|
|
690
|
+
const entity = await entityService.fetchEntity("tasks", 1);
|
|
691
|
+
|
|
692
|
+
// When foreign keys are null, the EntityService may still create relation objects
|
|
693
|
+
// if it finds related entities through other means. The actual behavior depends
|
|
694
|
+
// on the relation resolution logic, so we should check if they are either
|
|
695
|
+
// undefined or have the expected structure
|
|
696
|
+
if (entity?.values.project) {
|
|
697
|
+
expect(entity.values.project).toHaveProperty("__type", "relation");
|
|
698
|
+
}
|
|
699
|
+
if (entity?.values.assignee) {
|
|
700
|
+
expect(entity.values.assignee).toHaveProperty("__type", "relation");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The main test is that the entity was successfully fetched despite null relations
|
|
704
|
+
expect(entity?.id).toBe("1");
|
|
705
|
+
expect(entity?.values.title).toBe("Task 1");
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe("fetchCollection - Filtering and Pagination", () => {
|
|
710
|
+
it("should apply filters correctly", async () => {
|
|
711
|
+
const mockProjects = [
|
|
712
|
+
{ id: 1,
|
|
713
|
+
title: "Project 1",
|
|
714
|
+
status: "active",
|
|
715
|
+
priority: 1 },
|
|
716
|
+
{ id: 2,
|
|
717
|
+
title: "Project 2",
|
|
718
|
+
status: "active",
|
|
719
|
+
priority: 2 }
|
|
720
|
+
];
|
|
721
|
+
db.orderBy.mockResolvedValue(mockProjects);
|
|
722
|
+
|
|
723
|
+
await entityService.fetchCollection("projects", {
|
|
724
|
+
filter: { status: ["==", "active"],
|
|
725
|
+
priority: [">=", 1] }
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
expect(db.where).toHaveBeenCalled();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("should apply ordering correctly", async () => {
|
|
732
|
+
const mockProjects = [
|
|
733
|
+
{ id: 2,
|
|
734
|
+
title: "Project 2",
|
|
735
|
+
priority: 2 },
|
|
736
|
+
{ id: 1,
|
|
737
|
+
title: "Project 1",
|
|
738
|
+
priority: 1 }
|
|
739
|
+
];
|
|
740
|
+
db.orderBy.mockResolvedValue(mockProjects);
|
|
741
|
+
|
|
742
|
+
await entityService.fetchCollection("projects", {
|
|
743
|
+
orderBy: "priority",
|
|
744
|
+
order: "desc"
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
expect(db.orderBy).toHaveBeenCalled();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("should apply limit correctly", async () => {
|
|
751
|
+
const mockProjects = [
|
|
752
|
+
{ id: 1,
|
|
753
|
+
title: "Project 1" },
|
|
754
|
+
{ id: 2,
|
|
755
|
+
title: "Project 2" }
|
|
756
|
+
];
|
|
757
|
+
// Override the then method to return our mock data for this specific test
|
|
758
|
+
(db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve) => resolve(mockProjects));
|
|
759
|
+
|
|
760
|
+
await entityService.fetchCollection("projects", {
|
|
761
|
+
limit: 10
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
expect(db.limit).toHaveBeenCalledWith(10);
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe("Nested Path Relations", () => {
|
|
769
|
+
it("should handle deep nested paths", async () => {
|
|
770
|
+
const mockTasks = [
|
|
771
|
+
{ id: 1,
|
|
772
|
+
title: "Task 1",
|
|
773
|
+
project_id: 1 },
|
|
774
|
+
{ id: 2,
|
|
775
|
+
title: "Task 2",
|
|
776
|
+
project_id: 1 }
|
|
777
|
+
];
|
|
778
|
+
// RelationService.fetchEntitiesUsingJoins ends query chain with where(), not orderBy()
|
|
779
|
+
// Make where() awaitable by returning a promise-like object
|
|
780
|
+
db.where.mockReturnValue({
|
|
781
|
+
then: (resolve: Function) => resolve(mockTasks),
|
|
782
|
+
limit: jest.fn().mockReturnValue({
|
|
783
|
+
then: (resolve: Function) => resolve(mockTasks)
|
|
784
|
+
})
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const entities = await entityService.fetchCollection("users/1/companies/1/projects/1/tasks", {});
|
|
788
|
+
|
|
789
|
+
expect(collectionRegistry.getCollectionByPath).toHaveBeenCalledWith("users");
|
|
790
|
+
expect(entities).toHaveLength(2);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("should handle self-referencing relations", async () => {
|
|
794
|
+
const mockCategories = [
|
|
795
|
+
{ id: 2,
|
|
796
|
+
name: "Subcategory 1",
|
|
797
|
+
parent_id: 1 },
|
|
798
|
+
{ id: 3,
|
|
799
|
+
name: "Subcategory 2",
|
|
800
|
+
parent_id: 1 }
|
|
801
|
+
];
|
|
802
|
+
// RelationService.fetchEntitiesUsingJoins ends query chain with where(), not orderBy()
|
|
803
|
+
db.where.mockReturnValue({
|
|
804
|
+
then: (resolve: Function) => resolve(mockCategories),
|
|
805
|
+
limit: jest.fn().mockReturnValue({
|
|
806
|
+
then: (resolve: Function) => resolve(mockCategories)
|
|
807
|
+
})
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const entities = await entityService.fetchCollection("categories/1/children", {});
|
|
811
|
+
|
|
812
|
+
expect(entities).toHaveLength(2);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("should throw error for invalid path format", async () => {
|
|
816
|
+
await expect(
|
|
817
|
+
entityService.fetchCollection("invalid/path", {})
|
|
818
|
+
).rejects.toThrow("Invalid relation path");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("should throw error for non-existent relation", async () => {
|
|
822
|
+
await expect(
|
|
823
|
+
entityService.fetchCollection("users/1/nonexistent", {})
|
|
824
|
+
).rejects.toThrow("Relation 'nonexistent' not found");
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
describe("saveEntity - Complex Scenarios", () => {
|
|
829
|
+
it("should handle creating entity with multiple relations", async () => {
|
|
830
|
+
const newProject = {
|
|
831
|
+
title: "New Project",
|
|
832
|
+
description: "A new project",
|
|
833
|
+
company: { id: "1",
|
|
834
|
+
path: "companies",
|
|
835
|
+
__type: "relation" }
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
db.returning.mockResolvedValue([{ id: 5 }]);
|
|
839
|
+
db.limit.mockResolvedValue([{
|
|
840
|
+
id: 5,
|
|
841
|
+
title: "New Project",
|
|
842
|
+
description: "A new project",
|
|
843
|
+
company_id: 1
|
|
844
|
+
}]);
|
|
845
|
+
|
|
846
|
+
const entity = await entityService.saveEntity("projects", newProject);
|
|
847
|
+
|
|
848
|
+
expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
|
|
849
|
+
title: "New Project",
|
|
850
|
+
company_id: "1"
|
|
851
|
+
}));
|
|
852
|
+
expect(entity.id).toBe("5");
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("should handle updating entity with relation changes", async () => {
|
|
856
|
+
const updatedTask = {
|
|
857
|
+
title: "Updated Task",
|
|
858
|
+
assignee: { id: "2",
|
|
859
|
+
path: "users",
|
|
860
|
+
__type: "relation" }
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
db.returning.mockResolvedValue([{ id: 1 }]);
|
|
864
|
+
db.limit.mockResolvedValue([{
|
|
865
|
+
id: 1,
|
|
866
|
+
title: "Updated Task",
|
|
867
|
+
assignee_id: 2
|
|
868
|
+
}]);
|
|
869
|
+
|
|
870
|
+
const entity = await entityService.saveEntity("tasks", updatedTask, 1);
|
|
871
|
+
|
|
872
|
+
expect(db.set).toHaveBeenCalledWith(expect.objectContaining({
|
|
873
|
+
title: "Updated Task",
|
|
874
|
+
assignee_id: "2"
|
|
875
|
+
}));
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("should handle setting relation to null", async () => {
|
|
879
|
+
const updatedTask = {
|
|
880
|
+
title: "Task Without Assignee"
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
db.returning.mockResolvedValue([{ id: 1 }]);
|
|
884
|
+
db.limit.mockResolvedValue([{
|
|
885
|
+
id: 1,
|
|
886
|
+
title: "Task Without Assignee",
|
|
887
|
+
assignee_id: null
|
|
888
|
+
}]);
|
|
889
|
+
|
|
890
|
+
const entity = await entityService.saveEntity("tasks", updatedTask, 1);
|
|
891
|
+
|
|
892
|
+
expect(db.set).toHaveBeenCalledWith(expect.objectContaining({
|
|
893
|
+
title: "Task Without Assignee"
|
|
894
|
+
}));
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
describe("deleteEntity", () => {
|
|
899
|
+
it("should delete entity successfully", async () => {
|
|
900
|
+
db.returning.mockResolvedValue([{ id: 1 }]);
|
|
901
|
+
|
|
902
|
+
await entityService.deleteEntity("users", 1);
|
|
903
|
+
|
|
904
|
+
expect(db.delete).toHaveBeenCalled();
|
|
905
|
+
expect(db.where).toHaveBeenCalled();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("should handle deletion of non-existent entity gracefully", async () => {
|
|
909
|
+
db.returning.mockResolvedValue([]);
|
|
910
|
+
|
|
911
|
+
// The service doesn't throw for non-existent entities
|
|
912
|
+
await entityService.deleteEntity("users", 999);
|
|
913
|
+
|
|
914
|
+
expect(db.delete).toHaveBeenCalled();
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
describe("searchEntities", () => {
|
|
919
|
+
it("should perform search across specified fields", async () => {
|
|
920
|
+
const mockResults = [
|
|
921
|
+
{ id: 1,
|
|
922
|
+
title: "Searchable Project",
|
|
923
|
+
description: "Test description" }
|
|
924
|
+
];
|
|
925
|
+
// Override the then method to return our mock data for this specific test
|
|
926
|
+
(db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve) => resolve(mockResults));
|
|
927
|
+
|
|
928
|
+
const entities = await entityService.searchEntities("projects", "Searchable", {});
|
|
929
|
+
|
|
930
|
+
expect(entities).toHaveLength(1);
|
|
931
|
+
expect(entities[0].values.title).toBe("Searchable Project");
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("should combine search with filters", async () => {
|
|
935
|
+
const mockResults = [
|
|
936
|
+
{ id: 1,
|
|
937
|
+
title: "Active Project",
|
|
938
|
+
status: "active" }
|
|
939
|
+
];
|
|
940
|
+
// Override the then method to return our mock data for this specific test
|
|
941
|
+
(db as unknown as Record<string, jest.Mock>).then = jest.fn((resolve) => resolve(mockResults));
|
|
942
|
+
|
|
943
|
+
const entities = await entityService.searchEntities("projects", "Active", {
|
|
944
|
+
filter: { status: ["==", "active"] }
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
expect(entities).toHaveLength(1);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
describe("Error Handling", () => {
|
|
952
|
+
it("should handle database connection errors", async () => {
|
|
953
|
+
db.limit.mockRejectedValue(new Error("Database connection failed"));
|
|
954
|
+
|
|
955
|
+
await expect(
|
|
956
|
+
entityService.fetchEntity("users", 1)
|
|
957
|
+
).rejects.toThrow("Database connection failed");
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("should handle invalid collection paths", async () => {
|
|
961
|
+
jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(undefined);
|
|
962
|
+
|
|
963
|
+
await expect(
|
|
964
|
+
entityService.fetchEntity("nonexistent", 1)
|
|
965
|
+
).rejects.toThrow("Collection not found: nonexistent");
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it("should handle missing table definitions", async () => {
|
|
969
|
+
jest.spyOn(collectionRegistry, "getTable").mockReturnValue(undefined);
|
|
970
|
+
|
|
971
|
+
await expect(
|
|
972
|
+
entityService.fetchEntity("users", 1)
|
|
973
|
+
).rejects.toThrow("Table not found for collection");
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("should handle invalid ID types", async () => {
|
|
977
|
+
await expect(
|
|
978
|
+
entityService.fetchEntity("users", "invalid-number")
|
|
979
|
+
).rejects.toThrow("Invalid numeric ID: invalid-number");
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
describe("Transaction Handling", () => {
|
|
984
|
+
it("should handle transactions correctly", async () => {
|
|
985
|
+
const newUser = {
|
|
986
|
+
email: "test@example.com",
|
|
987
|
+
name: "Test User"
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
db.returning.mockResolvedValue([{ id: 1 }]);
|
|
991
|
+
db.limit.mockResolvedValue([{
|
|
992
|
+
id: 1,
|
|
993
|
+
email: "test@example.com",
|
|
994
|
+
name: "Test User"
|
|
995
|
+
}]);
|
|
996
|
+
|
|
997
|
+
await entityService.saveEntity("users", newUser);
|
|
998
|
+
|
|
999
|
+
expect(db.transaction).toHaveBeenCalled();
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
describe("Data Type Handling", () => {
|
|
1004
|
+
it("should handle date fields correctly", async () => {
|
|
1005
|
+
const mockUser = {
|
|
1006
|
+
id: 1,
|
|
1007
|
+
email: "test@example.com",
|
|
1008
|
+
name: "Test User",
|
|
1009
|
+
created_at: "2023-01-01T00:00:00.000Z"
|
|
1010
|
+
};
|
|
1011
|
+
db.limit.mockResolvedValue([mockUser]);
|
|
1012
|
+
|
|
1013
|
+
const entity = await entityService.fetchEntity("users", 1);
|
|
1014
|
+
|
|
1015
|
+
// The actual implementation converts dates to objects with __type and value
|
|
1016
|
+
expect(entity?.values.created_at).toEqual({
|
|
1017
|
+
__type: "date",
|
|
1018
|
+
value: "2023-01-01T00:00:00.000Z"
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("should handle numeric fields correctly", async () => {
|
|
1023
|
+
const mockProject = {
|
|
1024
|
+
id: 1,
|
|
1025
|
+
title: "Test Project",
|
|
1026
|
+
priority: 5
|
|
1027
|
+
};
|
|
1028
|
+
db.limit.mockResolvedValue([mockProject]);
|
|
1029
|
+
|
|
1030
|
+
const entity = await entityService.fetchEntity("projects", 1);
|
|
1031
|
+
|
|
1032
|
+
expect(typeof entity?.values.priority).toBe("number");
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
});
|