create-svc 0.1.10 → 0.1.11
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/README.md +46 -43
- package/bin/create-service.mjs +2 -0
- package/package.json +12 -9
- package/src/cli.test.ts +28 -10
- package/src/cli.ts +195 -30
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +1 -0
- package/src/naming.ts +23 -0
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +17 -4
- package/src/profiles.ts +2 -5
- package/src/scaffold.test.ts +231 -40
- package/src/scaffold.ts +84 -29
- package/src/vault.test.ts +61 -1
- package/src/vault.ts +77 -15
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +124 -47
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
- package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +11 -4
- package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
- package/templates/shared/scripts/cloudrun/lib.ts +174 -41
- package/templates/shared/scripts/cloudrun/neon.ts +45 -0
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -44
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Makefile +14 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-connectrpc/package.json +12 -5
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
- package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
- package/templates/variants/bun-connectrpc/src/index.ts +76 -176
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-hono/Makefile +14 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
- package/templates/variants/bun-hono/package.json +12 -5
- package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/repository.ts +68 -421
- package/templates/variants/bun-hono/src/db/schema.ts +15 -64
- package/templates/variants/bun-hono/src/index.ts +65 -180
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +72 -41
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/go-chi/Makefile +27 -11
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +21 -10
- package/templates/variants/go-chi/go.mod +1 -3
- package/templates/variants/go-chi/internal/app/service.go +202 -685
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +27 -11
- package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-connectrpc/Makefile +26 -9
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
- package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +1 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
- package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
- package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
- package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
- package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
- package/templates/variants/bun-hono/src/chat/service.ts +0 -384
- package/templates/variants/bun-hono/src/chat/types.ts +0 -142
- package/templates/variants/bun-hono/src/storage.ts +0 -72
- package/templates/variants/bun-hono/src/webhooks.ts +0 -35
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
- package/templates/variants/go-chi/buf.gen.yaml +0 -12
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
-
import { SQL } from "bun";
|
|
3
|
-
import { createApp } from "../src/index";
|
|
4
|
-
import { DefaultChatService } from "../src/chat/service";
|
|
5
|
-
import { ChatRepository } from "../src/db/repository";
|
|
6
|
-
import { createDb } from "../src/db/client";
|
|
7
|
-
import type { AttachmentObjectMetadata, AttachmentUploadTarget } from "../src/chat/types";
|
|
8
|
-
import type { AttachmentStorage } from "../src/storage";
|
|
9
|
-
|
|
10
|
-
const databaseUrl = Bun.env.DATABASE_URL?.trim();
|
|
11
|
-
const integrationTest = databaseUrl ? test : test.skip;
|
|
12
|
-
|
|
13
|
-
let sql: SQL | null = null;
|
|
14
|
-
|
|
15
|
-
beforeEach(async () => {
|
|
16
|
-
if (!databaseUrl) {
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
sql = new SQL(databaseUrl);
|
|
20
|
-
await sql.unsafe(`
|
|
21
|
-
truncate table
|
|
22
|
-
webhook_events,
|
|
23
|
-
attachments,
|
|
24
|
-
messages,
|
|
25
|
-
conversation_participants,
|
|
26
|
-
conversations,
|
|
27
|
-
users
|
|
28
|
-
restart identity cascade
|
|
29
|
-
`);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterEach(async () => {
|
|
33
|
-
await sql?.end();
|
|
34
|
-
sql = null;
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
integrationTest("list messages returns newest-first pages with attachment metadata", async () => {
|
|
38
|
-
Bun.env.ATTACHMENT_PUBLIC_BASE_URL = "https://storage.test";
|
|
39
|
-
const storage = new FakeAttachmentStorage();
|
|
40
|
-
const app = createApp(new DefaultChatService(new ChatRepository(createDb(databaseUrl)), storage, new NoopWebhookAdapter()));
|
|
41
|
-
|
|
42
|
-
const user = await createUser(app);
|
|
43
|
-
const conversation = await createConversation(app, user.id);
|
|
44
|
-
const messageIds: string[] = [];
|
|
45
|
-
|
|
46
|
-
for (let index = 1; index <= 55; index += 1) {
|
|
47
|
-
const message = await createMessage(app, conversation.id, user.id, `message-${index}`);
|
|
48
|
-
messageIds.push(message.id);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await rewriteMessageTimestamps(messageIds);
|
|
52
|
-
|
|
53
|
-
const uploadResult = await requestJson(app, "/v1/attachments/uploads", {
|
|
54
|
-
method: "POST",
|
|
55
|
-
body: {
|
|
56
|
-
conversation_id: conversation.id,
|
|
57
|
-
user_id: user.id,
|
|
58
|
-
filename: "photo.png",
|
|
59
|
-
content_type: "image/png",
|
|
60
|
-
byte_size: 1234,
|
|
61
|
-
},
|
|
62
|
-
expectedStatus: 201,
|
|
63
|
-
});
|
|
64
|
-
const attachment = uploadResult.result.attachment as {
|
|
65
|
-
id: string;
|
|
66
|
-
publicUrl: string;
|
|
67
|
-
filename: string;
|
|
68
|
-
contentType: string;
|
|
69
|
-
byteSize: number;
|
|
70
|
-
status: string;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
storage.setObjectMetadataFromUrl(uploadResult.result.attachment.publicUrl, {
|
|
74
|
-
contentType: "image/png",
|
|
75
|
-
byteSize: 1234,
|
|
76
|
-
publicUrl: uploadResult.result.attachment.publicUrl,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
await requestJson(app, `/v1/attachments/${attachment.id}/finalize`, {
|
|
80
|
-
method: "POST",
|
|
81
|
-
body: {
|
|
82
|
-
message_id: messageIds[54],
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const firstPage = await requestJson(app, `/v1/conversations/${conversation.id}/messages`);
|
|
87
|
-
expect(firstPage.messages).toHaveLength(50);
|
|
88
|
-
expect(firstPage.messages[0].body).toBe("message-55");
|
|
89
|
-
expect(firstPage.messages[49].body).toBe("message-6");
|
|
90
|
-
expect(firstPage.next_cursor).toBeString();
|
|
91
|
-
expect(firstPage.messages[0].attachments).toEqual([
|
|
92
|
-
{
|
|
93
|
-
id: attachment.id,
|
|
94
|
-
filename: "photo.png",
|
|
95
|
-
content_type: "image/png",
|
|
96
|
-
byte_size: 1234,
|
|
97
|
-
status: "ready",
|
|
98
|
-
public_url: attachment.publicUrl,
|
|
99
|
-
},
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
const secondPage = await requestJson(
|
|
103
|
-
app,
|
|
104
|
-
`/v1/conversations/${conversation.id}/messages?cursor=${encodeURIComponent(firstPage.next_cursor)}`
|
|
105
|
-
);
|
|
106
|
-
expect(secondPage.messages.map((message: { body: string }) => message.body)).toEqual([
|
|
107
|
-
"message-5",
|
|
108
|
-
"message-4",
|
|
109
|
-
"message-3",
|
|
110
|
-
"message-2",
|
|
111
|
-
"message-1",
|
|
112
|
-
]);
|
|
113
|
-
expect(secondPage.next_cursor).toBeUndefined();
|
|
114
|
-
|
|
115
|
-
const invalidLimit = await app.request(`/v1/conversations/${conversation.id}/messages?limit=0`);
|
|
116
|
-
expect(invalidLimit.status).toBe(400);
|
|
117
|
-
expect(await invalidLimit.json()).toEqual({
|
|
118
|
-
error: "limit must be a positive integer",
|
|
119
|
-
code: "invalid_limit",
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const tooLargeLimit = await app.request(`/v1/conversations/${conversation.id}/messages?limit=101`);
|
|
123
|
-
expect(tooLargeLimit.status).toBe(400);
|
|
124
|
-
expect(await tooLargeLimit.json()).toEqual({
|
|
125
|
-
error: "limit must be at most 100",
|
|
126
|
-
code: "invalid_limit",
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
async function createUser(app: ReturnType<typeof createApp>) {
|
|
131
|
-
const response = await requestJson(app, "/v1/users", {
|
|
132
|
-
method: "POST",
|
|
133
|
-
body: {
|
|
134
|
-
username: "alice",
|
|
135
|
-
display_name: "Alice",
|
|
136
|
-
},
|
|
137
|
-
expectedStatus: 201,
|
|
138
|
-
});
|
|
139
|
-
return response.user as { id: string };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function createConversation(app: ReturnType<typeof createApp>, createdByUserId: string) {
|
|
143
|
-
const response = await requestJson(app, "/v1/conversations", {
|
|
144
|
-
method: "POST",
|
|
145
|
-
body: {
|
|
146
|
-
created_by_user_id: createdByUserId,
|
|
147
|
-
title: "General",
|
|
148
|
-
participant_user_ids: [createdByUserId],
|
|
149
|
-
},
|
|
150
|
-
expectedStatus: 201,
|
|
151
|
-
});
|
|
152
|
-
return response.conversation as { id: string };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function createMessage(app: ReturnType<typeof createApp>, conversationId: string, userId: string, body: string) {
|
|
156
|
-
const response = await requestJson(app, `/v1/conversations/${conversationId}/messages`, {
|
|
157
|
-
method: "POST",
|
|
158
|
-
body: {
|
|
159
|
-
user_id: userId,
|
|
160
|
-
body,
|
|
161
|
-
},
|
|
162
|
-
expectedStatus: 201,
|
|
163
|
-
});
|
|
164
|
-
return response.message as { id: string };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function requestJson(
|
|
168
|
-
app: ReturnType<typeof createApp>,
|
|
169
|
-
path: string,
|
|
170
|
-
input: {
|
|
171
|
-
method?: string;
|
|
172
|
-
body?: unknown;
|
|
173
|
-
expectedStatus?: number;
|
|
174
|
-
} = {}
|
|
175
|
-
) {
|
|
176
|
-
const response = await app.request(path, {
|
|
177
|
-
method: input.method ?? "GET",
|
|
178
|
-
headers: input.body ? { "Content-Type": "application/json" } : undefined,
|
|
179
|
-
body: input.body ? JSON.stringify(input.body) : undefined,
|
|
180
|
-
});
|
|
181
|
-
expect(response.status).toBe(input.expectedStatus ?? 200);
|
|
182
|
-
return response.json();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function rewriteMessageTimestamps(messageIds: string[]) {
|
|
186
|
-
if (!sql) {
|
|
187
|
-
throw new Error("sql client not initialized");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const baseTime = Date.parse("2026-01-01T00:00:00.000Z");
|
|
191
|
-
for (const [index, messageId] of messageIds.entries()) {
|
|
192
|
-
const createdAt = new Date(baseTime + (index + 1) * 1000).toISOString();
|
|
193
|
-
await sql`
|
|
194
|
-
update messages
|
|
195
|
-
set created_at = ${createdAt}::timestamptz,
|
|
196
|
-
updated_at = ${createdAt}::timestamptz
|
|
197
|
-
where id = ${messageId}
|
|
198
|
-
`;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
class FakeAttachmentStorage implements AttachmentStorage {
|
|
203
|
-
private readonly metadata = new Map<string, AttachmentObjectMetadata>();
|
|
204
|
-
|
|
205
|
-
async createSignedUpload(input: {
|
|
206
|
-
attachmentId: string;
|
|
207
|
-
conversationId: string;
|
|
208
|
-
filename: string;
|
|
209
|
-
contentType: string;
|
|
210
|
-
}): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }> {
|
|
211
|
-
const bucket = "test-bucket";
|
|
212
|
-
const key = `attachments/${input.conversationId}/${input.attachmentId}/${input.filename}`;
|
|
213
|
-
const publicUrl = `https://storage.test/${key}`;
|
|
214
|
-
return {
|
|
215
|
-
bucket,
|
|
216
|
-
key,
|
|
217
|
-
upload: {
|
|
218
|
-
method: "PUT",
|
|
219
|
-
url: `https://uploads.test/${key}`,
|
|
220
|
-
headers: { "Content-Type": input.contentType },
|
|
221
|
-
},
|
|
222
|
-
publicUrl,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async getObjectMetadata(input: { bucket: string; key: string }) {
|
|
227
|
-
const metadata = this.metadata.get(`${input.bucket}/${input.key}`);
|
|
228
|
-
if (!metadata) {
|
|
229
|
-
throw new Error(`missing metadata for ${input.bucket}/${input.key}`);
|
|
230
|
-
}
|
|
231
|
-
return metadata;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
setObjectMetadataFromUrl(publicUrl: string, input: Omit<AttachmentObjectMetadata, "bucket" | "key">) {
|
|
235
|
-
const [, bucketAndKey = ""] = publicUrl.split("https://storage.test/");
|
|
236
|
-
this.metadata.set(`test-bucket/${bucketAndKey}`, {
|
|
237
|
-
bucket: "test-bucket",
|
|
238
|
-
key: bucketAndKey,
|
|
239
|
-
contentType: input.contentType,
|
|
240
|
-
byteSize: input.byteSize,
|
|
241
|
-
publicUrl: input.publicUrl,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
class NoopWebhookAdapter {
|
|
247
|
-
async normalize() {
|
|
248
|
-
return {
|
|
249
|
-
provider: "generic",
|
|
250
|
-
externalEventId: "evt_test",
|
|
251
|
-
eventType: "generic.event",
|
|
252
|
-
signatureValid: true,
|
|
253
|
-
payloadJson: "{}",
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
version: v2
|
|
2
|
-
plugins:
|
|
3
|
-
- local: protoc-gen-go
|
|
4
|
-
out: gen
|
|
5
|
-
opt:
|
|
6
|
-
- paths=source_relative
|
|
7
|
-
- Mchat/v1/chat.proto={{MODULE_PATH}}/gen/chat/v1
|
|
8
|
-
- local: protoc-gen-connect-go
|
|
9
|
-
out: gen
|
|
10
|
-
opt:
|
|
11
|
-
- paths=source_relative
|
|
12
|
-
- Mchat/v1/chat.proto={{MODULE_PATH}}/gen/chat/v1
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"log"
|
|
6
|
-
"os"
|
|
7
|
-
"sort"
|
|
8
|
-
"strings"
|
|
9
|
-
"time"
|
|
10
|
-
|
|
11
|
-
_ "github.com/jackc/pgx/v5/stdlib"
|
|
12
|
-
"github.com/jmoiron/sqlx"
|
|
13
|
-
|
|
14
|
-
"{{MODULE_PATH}}/internal/app"
|
|
15
|
-
"{{MODULE_PATH}}/internal/config"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
func main() {
|
|
19
|
-
cfg, err := config.Load()
|
|
20
|
-
if err != nil {
|
|
21
|
-
log.Fatal(err)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
db, err := app.OpenDatabase(context.Background(), cfg.DatabaseURL)
|
|
25
|
-
if err != nil {
|
|
26
|
-
db, err = waitForDatabase(cfg.DatabaseURL, 30*time.Second)
|
|
27
|
-
if err != nil {
|
|
28
|
-
log.Fatal(err)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if _, err := db.ExecContext(context.Background(), `
|
|
33
|
-
create table if not exists schema_migrations (
|
|
34
|
-
version text primary key,
|
|
35
|
-
applied_at timestamptz not null default now()
|
|
36
|
-
)`); err != nil {
|
|
37
|
-
log.Fatal(err)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
entries, err := os.ReadDir("migrations")
|
|
41
|
-
if err != nil {
|
|
42
|
-
log.Fatal(err)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
versions := make([]string, 0, len(entries))
|
|
46
|
-
for _, entry := range entries {
|
|
47
|
-
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
|
48
|
-
continue
|
|
49
|
-
}
|
|
50
|
-
versions = append(versions, entry.Name())
|
|
51
|
-
}
|
|
52
|
-
sort.Strings(versions)
|
|
53
|
-
|
|
54
|
-
for _, version := range versions {
|
|
55
|
-
var count int
|
|
56
|
-
if err := db.GetContext(context.Background(), &count, `select count(*) from schema_migrations where version = $1`, version); err != nil {
|
|
57
|
-
log.Fatal(err)
|
|
58
|
-
}
|
|
59
|
-
if count > 0 {
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
sqlBytes, err := os.ReadFile("migrations/" + version)
|
|
64
|
-
if err != nil {
|
|
65
|
-
log.Fatal(err)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
tx, err := db.BeginTxx(context.Background(), nil)
|
|
69
|
-
if err != nil {
|
|
70
|
-
log.Fatal(err)
|
|
71
|
-
}
|
|
72
|
-
if _, err := tx.ExecContext(context.Background(), string(sqlBytes)); err != nil {
|
|
73
|
-
_ = tx.Rollback()
|
|
74
|
-
log.Fatal(err)
|
|
75
|
-
}
|
|
76
|
-
if _, err := tx.ExecContext(context.Background(), `insert into schema_migrations (version) values ($1)`, version); err != nil {
|
|
77
|
-
_ = tx.Rollback()
|
|
78
|
-
log.Fatal(err)
|
|
79
|
-
}
|
|
80
|
-
if err := tx.Commit(); err != nil {
|
|
81
|
-
log.Fatal(err)
|
|
82
|
-
}
|
|
83
|
-
log.Printf("applied migration %s", version)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
func waitForDatabase(databaseURL string, timeout time.Duration) (*sqlx.DB, error) {
|
|
88
|
-
deadline := time.Now().Add(timeout)
|
|
89
|
-
var lastErr error
|
|
90
|
-
|
|
91
|
-
for time.Now().Before(deadline) {
|
|
92
|
-
db, err := app.OpenDatabase(context.Background(), databaseURL)
|
|
93
|
-
if err == nil {
|
|
94
|
-
return db, nil
|
|
95
|
-
}
|
|
96
|
-
lastErr = err
|
|
97
|
-
time.Sleep(time.Second)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return nil, lastErr
|
|
101
|
-
}
|
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
package httpapi
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"bytes"
|
|
5
|
-
"context"
|
|
6
|
-
"encoding/json"
|
|
7
|
-
"fmt"
|
|
8
|
-
"net/http"
|
|
9
|
-
"net/http/httptest"
|
|
10
|
-
"net/url"
|
|
11
|
-
"os"
|
|
12
|
-
"strings"
|
|
13
|
-
"testing"
|
|
14
|
-
"time"
|
|
15
|
-
|
|
16
|
-
"github.com/go-chi/chi/v5"
|
|
17
|
-
_ "github.com/jackc/pgx/v5/stdlib"
|
|
18
|
-
"github.com/jmoiron/sqlx"
|
|
19
|
-
|
|
20
|
-
"{{MODULE_PATH}}/internal/app"
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
func TestListMessagesPaginationIncludesAttachmentMetadata(t *testing.T) {
|
|
24
|
-
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
|
25
|
-
if databaseURL == "" {
|
|
26
|
-
t.Skip("DATABASE_URL is required for integration test")
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
t.Setenv("ATTACHMENT_PUBLIC_BASE_URL", "https://storage.test")
|
|
30
|
-
|
|
31
|
-
db, err := app.OpenDatabase(context.Background(), databaseURL)
|
|
32
|
-
if err != nil {
|
|
33
|
-
t.Fatalf("open database: %v", err)
|
|
34
|
-
}
|
|
35
|
-
t.Cleanup(func() { _ = db.Close() })
|
|
36
|
-
|
|
37
|
-
if _, err := db.ExecContext(context.Background(), `
|
|
38
|
-
truncate table
|
|
39
|
-
webhook_events,
|
|
40
|
-
attachments,
|
|
41
|
-
messages,
|
|
42
|
-
conversation_participants,
|
|
43
|
-
conversations,
|
|
44
|
-
users
|
|
45
|
-
restart identity cascade`); err != nil {
|
|
46
|
-
t.Fatalf("truncate tables: %v", err)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
storage := newFakeStorage()
|
|
50
|
-
service := app.NewChatService(db, storage, app.GenericWebhookAdapter{})
|
|
51
|
-
router := chi.NewRouter()
|
|
52
|
-
RegisterRoutes(router, service)
|
|
53
|
-
server := httptest.NewServer(router)
|
|
54
|
-
t.Cleanup(server.Close)
|
|
55
|
-
|
|
56
|
-
userID := createUser(t, server.URL)
|
|
57
|
-
conversationID := createConversation(t, server.URL, userID)
|
|
58
|
-
|
|
59
|
-
messageIDs := make([]string, 0, 55)
|
|
60
|
-
for index := 1; index <= 55; index++ {
|
|
61
|
-
messageIDs = append(messageIDs, createMessage(t, server.URL, conversationID, userID, fmt.Sprintf("message-%d", index)))
|
|
62
|
-
}
|
|
63
|
-
rewriteMessageTimestamps(t, db, messageIDs)
|
|
64
|
-
|
|
65
|
-
upload := createAttachmentUpload(t, server.URL, conversationID, userID)
|
|
66
|
-
storage.set(upload.Result.Attachment.PublicURL, "image/png", 1234)
|
|
67
|
-
finalizeAttachment(t, server.URL, upload.Result.Attachment.ID, messageIDs[54])
|
|
68
|
-
|
|
69
|
-
firstPage := listMessagesPage(t, server.URL, conversationID, "")
|
|
70
|
-
if len(firstPage.Messages) != 50 {
|
|
71
|
-
t.Fatalf("expected 50 messages, got %d", len(firstPage.Messages))
|
|
72
|
-
}
|
|
73
|
-
if firstPage.Messages[0].Body != "message-55" {
|
|
74
|
-
t.Fatalf("expected newest message first, got %s", firstPage.Messages[0].Body)
|
|
75
|
-
}
|
|
76
|
-
if firstPage.Messages[49].Body != "message-6" {
|
|
77
|
-
t.Fatalf("expected oldest message on first page to be message-6, got %s", firstPage.Messages[49].Body)
|
|
78
|
-
}
|
|
79
|
-
if firstPage.NextCursor == "" {
|
|
80
|
-
t.Fatal("expected next_cursor on first page")
|
|
81
|
-
}
|
|
82
|
-
if len(firstPage.Messages[0].Attachments) != 1 {
|
|
83
|
-
t.Fatalf("expected attachment metadata on newest message, got %#v", firstPage.Messages[0].Attachments)
|
|
84
|
-
}
|
|
85
|
-
if firstPage.Messages[0].Attachments[0].PublicURL != upload.Result.Attachment.PublicURL {
|
|
86
|
-
t.Fatalf("expected public_url %s, got %s", upload.Result.Attachment.PublicURL, firstPage.Messages[0].Attachments[0].PublicURL)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
secondPage := listMessagesPage(t, server.URL, conversationID, firstPage.NextCursor)
|
|
90
|
-
expectedBodies := []string{"message-5", "message-4", "message-3", "message-2", "message-1"}
|
|
91
|
-
if len(secondPage.Messages) != len(expectedBodies) {
|
|
92
|
-
t.Fatalf("expected %d messages on second page, got %d", len(expectedBodies), len(secondPage.Messages))
|
|
93
|
-
}
|
|
94
|
-
for index, body := range expectedBodies {
|
|
95
|
-
if secondPage.Messages[index].Body != body {
|
|
96
|
-
t.Fatalf("expected %s at index %d, got %s", body, index, secondPage.Messages[index].Body)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if secondPage.NextCursor != "" {
|
|
100
|
-
t.Fatalf("expected no next cursor on final page, got %s", secondPage.NextCursor)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
type pageResponse struct {
|
|
105
|
-
Messages []struct {
|
|
106
|
-
ID string `json:"id"`
|
|
107
|
-
Body string `json:"body"`
|
|
108
|
-
Attachments []struct {
|
|
109
|
-
ID string `json:"id"`
|
|
110
|
-
Filename string `json:"filename"`
|
|
111
|
-
ContentType string `json:"content_type"`
|
|
112
|
-
ByteSize int64 `json:"byte_size"`
|
|
113
|
-
Status string `json:"status"`
|
|
114
|
-
PublicURL string `json:"public_url"`
|
|
115
|
-
} `json:"attachments"`
|
|
116
|
-
} `json:"messages"`
|
|
117
|
-
NextCursor string `json:"next_cursor"`
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
type attachmentUploadResponse struct {
|
|
121
|
-
Result struct {
|
|
122
|
-
Attachment struct {
|
|
123
|
-
ID string `json:"id"`
|
|
124
|
-
PublicURL string `json:"public_url"`
|
|
125
|
-
} `json:"attachment"`
|
|
126
|
-
} `json:"result"`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
type fakeStorage struct {
|
|
130
|
-
metadata map[string]struct {
|
|
131
|
-
contentType string
|
|
132
|
-
byteSize int64
|
|
133
|
-
publicURL string
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
func newFakeStorage() *fakeStorage {
|
|
138
|
-
return &fakeStorage{metadata: map[string]struct {
|
|
139
|
-
contentType string
|
|
140
|
-
byteSize int64
|
|
141
|
-
publicURL string
|
|
142
|
-
}{}}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
func (f *fakeStorage) CreateSignedUpload(_ context.Context, attachmentID string, conversationID string, filename string, contentType string) (string, string, app.UploadTarget, string, error) {
|
|
146
|
-
key := fmt.Sprintf("attachments/%s/%s/%s", conversationID, attachmentID, filename)
|
|
147
|
-
return "test-bucket", key, app.UploadTarget{
|
|
148
|
-
Method: http.MethodPut,
|
|
149
|
-
URL: "https://uploads.test/" + key,
|
|
150
|
-
Headers: map[string]string{
|
|
151
|
-
"Content-Type": contentType,
|
|
152
|
-
},
|
|
153
|
-
}, "https://storage.test/" + key, nil
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
func (f *fakeStorage) GetObjectMetadata(_ context.Context, bucket string, key string) (string, int64, string, error) {
|
|
157
|
-
entry, ok := f.metadata[bucket+"/"+key]
|
|
158
|
-
if !ok {
|
|
159
|
-
return "", 0, "", fmt.Errorf("missing metadata for %s/%s", bucket, key)
|
|
160
|
-
}
|
|
161
|
-
return entry.contentType, entry.byteSize, entry.publicURL, nil
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
func (f *fakeStorage) set(publicURL string, contentType string, byteSize int64) {
|
|
165
|
-
key := strings.TrimPrefix(publicURL, "https://storage.test/")
|
|
166
|
-
f.metadata["test-bucket/"+key] = struct {
|
|
167
|
-
contentType string
|
|
168
|
-
byteSize int64
|
|
169
|
-
publicURL string
|
|
170
|
-
}{
|
|
171
|
-
contentType: contentType,
|
|
172
|
-
byteSize: byteSize,
|
|
173
|
-
publicURL: publicURL,
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
func createUser(t *testing.T, baseURL string) string {
|
|
178
|
-
var response struct {
|
|
179
|
-
User struct {
|
|
180
|
-
ID string `json:"id"`
|
|
181
|
-
} `json:"user"`
|
|
182
|
-
}
|
|
183
|
-
requestJSON(t, http.MethodPost, baseURL+"/v1/users", map[string]any{
|
|
184
|
-
"username": "alice",
|
|
185
|
-
"display_name": "Alice",
|
|
186
|
-
}, &response)
|
|
187
|
-
return response.User.ID
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
func createConversation(t *testing.T, baseURL string, userID string) string {
|
|
191
|
-
var response struct {
|
|
192
|
-
Conversation struct {
|
|
193
|
-
ID string `json:"id"`
|
|
194
|
-
} `json:"conversation"`
|
|
195
|
-
}
|
|
196
|
-
requestJSON(t, http.MethodPost, baseURL+"/v1/conversations", map[string]any{
|
|
197
|
-
"created_by_user_id": userID,
|
|
198
|
-
"title": "General",
|
|
199
|
-
"participant_user_ids": []string{userID},
|
|
200
|
-
}, &response)
|
|
201
|
-
return response.Conversation.ID
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
func createMessage(t *testing.T, baseURL string, conversationID string, userID string, body string) string {
|
|
205
|
-
var response struct {
|
|
206
|
-
Message struct {
|
|
207
|
-
ID string `json:"id"`
|
|
208
|
-
} `json:"message"`
|
|
209
|
-
}
|
|
210
|
-
requestJSON(t, http.MethodPost, baseURL+"/v1/conversations/"+conversationID+"/messages", map[string]any{
|
|
211
|
-
"user_id": userID,
|
|
212
|
-
"body": body,
|
|
213
|
-
}, &response)
|
|
214
|
-
return response.Message.ID
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
func createAttachmentUpload(t *testing.T, baseURL string, conversationID string, userID string) attachmentUploadResponse {
|
|
218
|
-
var response attachmentUploadResponse
|
|
219
|
-
requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/uploads", map[string]any{
|
|
220
|
-
"conversation_id": conversationID,
|
|
221
|
-
"user_id": userID,
|
|
222
|
-
"filename": "photo.png",
|
|
223
|
-
"content_type": "image/png",
|
|
224
|
-
"byte_size": 1234,
|
|
225
|
-
}, &response)
|
|
226
|
-
return response
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
func finalizeAttachment(t *testing.T, baseURL string, attachmentID string, messageID string) {
|
|
230
|
-
requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/"+attachmentID+"/finalize", map[string]any{
|
|
231
|
-
"message_id": messageID,
|
|
232
|
-
}, &struct{}{})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
func listMessagesPage(t *testing.T, baseURL string, conversationID string, cursor string) pageResponse {
|
|
236
|
-
requestURL := baseURL + "/v1/conversations/" + conversationID + "/messages"
|
|
237
|
-
if cursor != "" {
|
|
238
|
-
requestURL += "?cursor=" + url.QueryEscape(cursor)
|
|
239
|
-
}
|
|
240
|
-
var response pageResponse
|
|
241
|
-
requestJSON(t, http.MethodGet, requestURL, nil, &response)
|
|
242
|
-
return response
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
func requestJSON(t *testing.T, method string, url string, payload any, out any) {
|
|
246
|
-
t.Helper()
|
|
247
|
-
|
|
248
|
-
var bodyReader *bytes.Reader
|
|
249
|
-
if payload == nil {
|
|
250
|
-
bodyReader = bytes.NewReader(nil)
|
|
251
|
-
} else {
|
|
252
|
-
body, err := json.Marshal(payload)
|
|
253
|
-
if err != nil {
|
|
254
|
-
t.Fatalf("marshal request: %v", err)
|
|
255
|
-
}
|
|
256
|
-
bodyReader = bytes.NewReader(body)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
request, err := http.NewRequest(method, url, bodyReader)
|
|
260
|
-
if err != nil {
|
|
261
|
-
t.Fatalf("new request: %v", err)
|
|
262
|
-
}
|
|
263
|
-
if payload != nil {
|
|
264
|
-
request.Header.Set("Content-Type", "application/json")
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
response, err := http.DefaultClient.Do(request)
|
|
268
|
-
if err != nil {
|
|
269
|
-
t.Fatalf("do request: %v", err)
|
|
270
|
-
}
|
|
271
|
-
defer response.Body.Close()
|
|
272
|
-
|
|
273
|
-
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
274
|
-
var problem map[string]any
|
|
275
|
-
_ = json.NewDecoder(response.Body).Decode(&problem)
|
|
276
|
-
t.Fatalf("unexpected status %d: %#v", response.StatusCode, problem)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if out != nil {
|
|
280
|
-
if err := json.NewDecoder(response.Body).Decode(out); err != nil {
|
|
281
|
-
t.Fatalf("decode response: %v", err)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
func rewriteMessageTimestamps(t *testing.T, db *sqlx.DB, messageIDs []string) {
|
|
287
|
-
t.Helper()
|
|
288
|
-
baseTime := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
|
289
|
-
for index, messageID := range messageIDs {
|
|
290
|
-
createdAt := baseTime.Add(time.Duration(index+1) * time.Second)
|
|
291
|
-
if _, err := db.ExecContext(context.Background(), `
|
|
292
|
-
update messages
|
|
293
|
-
set created_at = $2, updated_at = $2
|
|
294
|
-
where id = $1`, messageID, createdAt); err != nil {
|
|
295
|
-
t.Fatalf("rewrite message timestamp: %v", err)
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|