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.
Files changed (168) hide show
  1. package/README.md +46 -43
  2. package/bin/create-service.mjs +2 -0
  3. package/package.json +12 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +195 -30
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +231 -40
  14. package/src/scaffold.ts +84 -29
  15. package/src/vault.test.ts +61 -1
  16. package/src/vault.ts +77 -15
  17. package/templates/shared/.github/workflows/ci.yml +2 -1
  18. package/templates/shared/.github/workflows/deploy.yml +2 -0
  19. package/templates/shared/README.md +124 -47
  20. package/templates/shared/grafana/alerts.yaml +54 -0
  21. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  22. package/templates/shared/scripts/authctl.ts +231 -0
  23. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  24. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  25. package/templates/shared/scripts/cloudrun/cli.ts +324 -7
  26. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  27. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  28. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  29. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  30. package/templates/shared/scripts/dev.ts +22 -0
  31. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  32. package/templates/shared/scripts/local-docker.ts +63 -0
  33. package/templates/shared/scripts/local-env.ts +27 -0
  34. package/templates/shared/scripts/seed.ts +73 -0
  35. package/templates/shared/scripts/wait-for-db.ts +32 -0
  36. package/templates/shared/service.config.ts +59 -0
  37. package/templates/shared/service.yaml +24 -44
  38. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  39. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  40. package/templates/targets/workers/Makefile +33 -0
  41. package/templates/targets/workers/README.md +75 -0
  42. package/templates/targets/workers/package.json +35 -0
  43. package/templates/targets/workers/scripts/workers/cli.ts +397 -0
  44. package/templates/targets/workers/src/auth.ts +178 -0
  45. package/templates/targets/workers/src/index.ts +198 -0
  46. package/templates/targets/workers/src/storage.ts +370 -0
  47. package/templates/targets/workers/test/app.test.ts +108 -0
  48. package/templates/targets/workers/tsconfig.json +11 -0
  49. package/templates/targets/workers/wrangler.toml +24 -0
  50. package/templates/variants/bun-connectrpc/Makefile +14 -8
  51. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  52. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  53. package/templates/variants/bun-connectrpc/package.json +12 -5
  54. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  55. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  56. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  57. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  58. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  59. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  60. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  61. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  62. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  63. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  64. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  65. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  66. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  67. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  68. package/templates/variants/bun-hono/Makefile +14 -8
  69. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  70. package/templates/variants/bun-hono/package.json +12 -5
  71. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  72. package/templates/variants/bun-hono/src/auth.ts +181 -0
  73. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  74. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  75. package/templates/variants/bun-hono/src/index.ts +65 -180
  76. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  77. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  78. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  79. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  80. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  81. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  82. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  83. package/templates/variants/go-chi/Makefile +27 -11
  84. package/templates/variants/go-chi/atlas.hcl +8 -0
  85. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  86. package/templates/variants/go-chi/go.mod +1 -3
  87. package/templates/variants/go-chi/internal/app/service.go +202 -685
  88. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  89. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  90. package/templates/variants/go-chi/internal/config/config.go +27 -11
  91. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  92. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  93. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  94. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  95. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  96. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  97. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  98. package/templates/variants/go-chi/package.json +7 -1
  99. package/templates/variants/go-connectrpc/Makefile +26 -9
  100. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  101. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  102. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  103. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  104. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  105. package/templates/variants/go-connectrpc/go.mod +1 -1
  106. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  107. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  108. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  109. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  110. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  111. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  112. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  113. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  114. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  115. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  116. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  117. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  118. package/templates/variants/go-connectrpc/package.json +7 -1
  119. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  120. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  121. package/templates/root/.github/workflows/ci.yml +0 -26
  122. package/templates/root/.github/workflows/deploy.yml +0 -22
  123. package/templates/root/Dockerfile +0 -23
  124. package/templates/root/README.md +0 -69
  125. package/templates/root/buf.gen.yaml +0 -10
  126. package/templates/root/buf.yaml +0 -9
  127. package/templates/root/cmd/server/main.go +0 -44
  128. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  129. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  130. package/templates/root/go.mod +0 -10
  131. package/templates/root/internal/app/service.go +0 -152
  132. package/templates/root/internal/app/token_source.go +0 -50
  133. package/templates/root/internal/cloudflare/client.go +0 -160
  134. package/templates/root/internal/config/config.go +0 -55
  135. package/templates/root/internal/connectapi/handler.go +0 -79
  136. package/templates/root/internal/httpapi/routes.go +0 -93
  137. package/templates/root/internal/vault/client.go +0 -148
  138. package/templates/root/package.json +0 -12
  139. package/templates/root/protos/dns/v1/dns.proto +0 -58
  140. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  141. package/templates/root/scripts/cloudrun/config.ts +0 -50
  142. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  143. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  144. package/templates/root/service.yaml +0 -50
  145. package/templates/root/test/go.test.ts +0 -19
  146. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  147. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  148. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  149. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  150. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  151. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  152. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  153. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  154. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  155. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  156. package/templates/variants/bun-hono/src/storage.ts +0 -72
  157. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  158. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  159. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  160. package/templates/variants/go-chi/buf.yaml +0 -9
  161. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  162. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  163. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  164. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  165. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  166. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  167. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  168. 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,9 +0,0 @@
1
- version: v2
2
- modules:
3
- - path: protos
4
- lint:
5
- use:
6
- - STANDARD
7
- breaking:
8
- use:
9
- - FILE
@@ -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
- }