@gencow/core 0.1.7 → 0.1.9

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/dist/storage.js CHANGED
@@ -4,53 +4,137 @@ import * as crypto from "crypto";
4
4
  // ─── Constants ──────────────────────────────────────────
5
5
  /** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
6
6
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
7
+ /** 기본 스토리지 쿼터: 1GB */
8
+ const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
7
9
  function formatBytes(bytes) {
8
10
  if (bytes < 1024)
9
11
  return `${bytes}B`;
10
12
  if (bytes < 1024 * 1024)
11
13
  return `${(bytes / 1024).toFixed(1)}KB`;
12
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
14
+ if (bytes < 1024 * 1024 * 1024)
15
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
16
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
13
17
  }
14
18
  // ─── Implementation ─────────────────────────────────────
15
19
  const metaStore = new Map();
20
+ /**
21
+ * files 테이블 자동 생성 (이미 존재하면 무시)
22
+ * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
23
+ */
24
+ let filesTableEnsured = false;
25
+ async function ensureFilesTable(rawSql) {
26
+ if (filesTableEnsured)
27
+ return;
28
+ await rawSql(`
29
+ DO $$
30
+ BEGIN
31
+ IF NOT EXISTS (
32
+ SELECT 1 FROM information_schema.tables
33
+ WHERE table_schema = current_schema()
34
+ AND table_name = 'files'
35
+ ) THEN
36
+ CREATE TABLE files (
37
+ id SERIAL PRIMARY KEY,
38
+ storage_id TEXT NOT NULL,
39
+ name TEXT NOT NULL,
40
+ size TEXT NOT NULL,
41
+ type TEXT NOT NULL DEFAULT 'application/octet-stream',
42
+ uploaded_by TEXT DEFAULT 'api',
43
+ created_at TIMESTAMP DEFAULT NOW()
44
+ );
45
+ END IF;
46
+ END$$;
47
+ `);
48
+ filesTableEnsured = true;
49
+ }
50
+ /**
51
+ * 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
52
+ */
53
+ async function checkStorageQuota(rawSql, newFileSize, quota) {
54
+ if (quota <= 0)
55
+ return; // 무제한
56
+ const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`);
57
+ const currentUsage = Number(rows[0]?.total || "0");
58
+ const projectedUsage = currentUsage + newFileSize;
59
+ if (projectedUsage > quota) {
60
+ throw new Error(`Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
61
+ `quota is ${formatBytes(quota)}. Delete files or increase quota.`);
62
+ }
63
+ }
64
+ /**
65
+ * DB에 파일 메타데이터 기록 — store() 호출 시 자동 수행
66
+ *
67
+ * 대시보드 업로드(admin.ts)는 uploaded_by='dashboard'로 별도 INSERT하므로,
68
+ * 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
69
+ */
70
+ async function recordFileToDb(rawSql, storageId, name, size, type, uploadedBy = "api") {
71
+ await ensureFilesTable(rawSql);
72
+ await rawSql(`INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
73
+ VALUES ($1, $2, $3, $4, $5, NOW())`, [storageId, name, String(size), type, uploadedBy]);
74
+ }
16
75
  /**
17
76
  * Create a storage instance — Convex storage 패턴 재현
18
77
  *
78
+ * @param dir - 파일 저장 디렉토리
79
+ * @param options - DB 연동 + 쿼터 옵션
80
+ *
19
81
  * @example
20
- * const storage = createStorage('./uploads');
82
+ * const storage = createStorage('./uploads', { rawSql, storageQuota: 1024 * 1024 * 1024 });
21
83
  * const id = await storage.store(file);
22
84
  * const url = storage.getUrl(id);
23
85
  */
24
- export function createStorage(dir = "./uploads") {
86
+ export function createStorage(dir = "./uploads", options) {
87
+ const rawSql = options?.rawSql;
88
+ const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
25
89
  // Ensure directory exists
26
90
  fs.mkdir(dir, { recursive: true }).catch(() => { });
27
91
  return {
28
92
  async store(file, filename) {
29
- // 크기 제한 검증
93
+ // 크기 제한 검증 (단일 파일)
30
94
  if (file.size > MAX_FILE_SIZE) {
31
95
  throw new Error(`File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
32
96
  }
97
+ // 스토리지 쿼터 검증 (앱 전체 용량)
98
+ if (rawSql) {
99
+ await checkStorageQuota(rawSql, file.size, quota);
100
+ }
33
101
  const id = crypto.randomUUID();
34
102
  const name = filename || (file instanceof File ? file.name : `${id}.bin`);
35
103
  const filePath = path.join(dir, id);
36
104
  const arrayBuffer = await file.arrayBuffer();
37
105
  await fs.writeFile(filePath, Buffer.from(arrayBuffer));
106
+ const type = file.type || "application/octet-stream";
38
107
  metaStore.set(id, {
39
108
  id,
40
109
  name,
41
110
  size: file.size,
42
- type: file.type || "application/octet-stream",
111
+ type,
43
112
  path: filePath,
44
113
  });
114
+ // DB 자동 기록 (rawSql 있을 때만)
115
+ if (rawSql) {
116
+ try {
117
+ await recordFileToDb(rawSql, id, name, file.size, type, "api");
118
+ }
119
+ catch (e) {
120
+ // DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
121
+ const msg = e instanceof Error ? e.message : String(e);
122
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
123
+ }
124
+ }
45
125
  return id;
46
126
  },
47
127
  async storeBuffer(buffer, filename, type = "application/octet-stream") {
48
- const id = crypto.randomUUID();
49
- const filePath = path.join(dir, id);
50
- // 크기 제한 검증
128
+ // 크기 제한 검증 (단일 파일)
51
129
  if (buffer.length > MAX_FILE_SIZE) {
52
130
  throw new Error(`File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
53
131
  }
132
+ // 스토리지 쿼터 검증 (앱 전체 용량)
133
+ if (rawSql) {
134
+ await checkStorageQuota(rawSql, buffer.length, quota);
135
+ }
136
+ const id = crypto.randomUUID();
137
+ const filePath = path.join(dir, id);
54
138
  await fs.writeFile(filePath, buffer);
55
139
  metaStore.set(id, {
56
140
  id,
@@ -59,6 +143,16 @@ export function createStorage(dir = "./uploads") {
59
143
  type,
60
144
  path: filePath,
61
145
  });
146
+ // DB 자동 기록
147
+ if (rawSql) {
148
+ try {
149
+ await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
150
+ }
151
+ catch (e) {
152
+ const msg = e instanceof Error ? e.message : String(e);
153
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
154
+ }
155
+ }
62
156
  return id;
63
157
  },
64
158
  getUrl(storageId) {
@@ -73,11 +167,21 @@ export function createStorage(dir = "./uploads") {
73
167
  await fs.unlink(meta.path).catch(() => { });
74
168
  metaStore.delete(storageId);
75
169
  }
170
+ // DB에서도 삭제 (rawSql 있을 때만)
171
+ if (rawSql) {
172
+ try {
173
+ await rawSql(`DELETE FROM files WHERE storage_id = $1`, [storageId]);
174
+ }
175
+ catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
176
+ }
76
177
  },
77
178
  };
78
179
  }
79
180
  /**
80
181
  * Hono routes for serving stored files
182
+ *
183
+ * 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
184
+ * 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
81
185
  */
82
186
  export function storageRoutes(storage, rawSql, storageDir) {
83
187
  return async (c) => {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+ import { pgTable } from "drizzle-orm/pg-core";
10
+ import type { GencowCtx } from "./reactive";
11
+ import type { SQL } from "drizzle-orm";
12
+ /**
13
+ * Access control filter function.
14
+ * Returns a Drizzle SQL condition, `true` (allow all), or `false` (deny all).
15
+ * Can be async (e.g., for team membership lookups).
16
+ */
17
+ export type AccessFilter = (ctx: GencowCtx) => SQL | boolean | Promise<SQL | boolean>;
18
+ /** Field-level access control: per-field read permission. */
19
+ export interface FieldAccessRule {
20
+ read: (ctx: GencowCtx) => boolean;
21
+ }
22
+ /** Options for gencowTable — access control metadata. */
23
+ export interface GencowTableOptions {
24
+ /** Row-level filter — auto-injected into all queries on this table */
25
+ filter: AccessFilter;
26
+ /** Field-level access — unauthorized fields are nullified in results */
27
+ fieldAccess?: Record<string, FieldAccessRule>;
28
+ }
29
+ /** Stored metadata for a gencowTable. */
30
+ export interface TableAccessMeta {
31
+ filter: AccessFilter;
32
+ fieldAccess?: Record<string, FieldAccessRule>;
33
+ tableName: string;
34
+ }
35
+ declare global {
36
+ var __gencow_tableAccessRegistry: Map<any, TableAccessMeta>;
37
+ }
38
+ /**
39
+ * Drizzle pgTable wrapper with schema-level access control.
40
+ *
41
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
42
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
43
+ * WHERE clauses.
44
+ *
45
+ * @param name - PostgreSQL table name
46
+ * @param columns - Drizzle column definitions (same as pgTable)
47
+ * @param options - Access control (filter required, fieldAccess optional)
48
+ */
49
+ export declare function gencowTable<T extends Record<string, any>>(name: string, columns: T, options: GencowTableOptions): ReturnType<typeof pgTable<string, T>>;
50
+ /**
51
+ * Convenience helper for userId-based isolation.
52
+ *
53
+ * Usage:
54
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
55
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
56
+ *
57
+ * @param columnName - Name of the user ID column (default: "userId")
58
+ */
59
+ export declare function ownerFilter(columnName?: string): GencowTableOptions;
60
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
61
+ export declare function getTableAccessMeta(table: any): TableAccessMeta | undefined;
62
+ /** Check if a table has gencowTable metadata registered. */
63
+ export declare function isGencowTable(table: any): boolean;
64
+ /** Get all registered gencowTables (for audit/boot-time checks). */
65
+ export declare function getAllGencowTables(): Map<any, TableAccessMeta>;
66
+ /** Reset registry (for testing only). */
67
+ export declare function _resetTableRegistry(): void;
package/dist/table.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+ import { pgTable } from "drizzle-orm/pg-core";
10
+ import { eq } from "drizzle-orm";
11
+ function isOwnerFilter(opts) {
12
+ return "_ownerColumn" in opts;
13
+ }
14
+ if (!globalThis.__gencow_tableAccessRegistry) {
15
+ globalThis.__gencow_tableAccessRegistry = new Map();
16
+ }
17
+ const tableAccessRegistry = globalThis.__gencow_tableAccessRegistry;
18
+ // ─── gencowTable() ──────────────────────────────────────
19
+ /**
20
+ * Drizzle pgTable wrapper with schema-level access control.
21
+ *
22
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
23
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
24
+ * WHERE clauses.
25
+ *
26
+ * @param name - PostgreSQL table name
27
+ * @param columns - Drizzle column definitions (same as pgTable)
28
+ * @param options - Access control (filter required, fieldAccess optional)
29
+ */
30
+ export function gencowTable(name, columns, options) {
31
+ if (!options || (typeof options.filter !== "function" && !isOwnerFilter(options))) {
32
+ throw new Error(`[gencow] gencowTable("${name}") requires a filter option. ` +
33
+ `Use ownerFilter("userId") for simple user isolation, ` +
34
+ `or { filter: () => true } for public tables.`);
35
+ }
36
+ // Create standard Drizzle pgTable — fully compatible
37
+ const table = pgTable(name, columns);
38
+ // Resolve ownerFilter to a concrete filter bound to this table
39
+ let filter;
40
+ if (isOwnerFilter(options)) {
41
+ const columnName = options._ownerColumn;
42
+ const col = table[columnName];
43
+ if (!col) {
44
+ throw new Error(`[gencow] ownerFilter("${columnName}"): column "${columnName}" not found on table "${name}". ` +
45
+ `Available columns: ${Object.keys(table).filter(k => !k.startsWith("_") && !k.startsWith("$")).join(", ")}`);
46
+ }
47
+ filter = (ctx) => {
48
+ const user = ctx.auth.requireAuth();
49
+ return eq(col, user.id);
50
+ };
51
+ }
52
+ else {
53
+ filter = options.filter;
54
+ }
55
+ // Store access control metadata keyed by the table object reference
56
+ tableAccessRegistry.set(table, {
57
+ filter,
58
+ fieldAccess: options.fieldAccess,
59
+ tableName: name,
60
+ });
61
+ return table;
62
+ }
63
+ // ─── ownerFilter() helper ───────────────────────────────
64
+ /**
65
+ * Convenience helper for userId-based isolation.
66
+ *
67
+ * Usage:
68
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
69
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
70
+ *
71
+ * @param columnName - Name of the user ID column (default: "userId")
72
+ */
73
+ export function ownerFilter(columnName = "userId") {
74
+ // Return a marker object — gencowTable() resolves this to a concrete filter
75
+ // by binding to the actual table column at registration time.
76
+ return {
77
+ _ownerColumn: columnName,
78
+ // Placeholder filter — replaced by gencowTable()
79
+ filter: () => { throw new Error("[gencow] ownerFilter placeholder should not be called directly"); },
80
+ };
81
+ }
82
+ // ─── Lookup ─────────────────────────────────────────────
83
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
84
+ export function getTableAccessMeta(table) {
85
+ return tableAccessRegistry.get(table);
86
+ }
87
+ /** Check if a table has gencowTable metadata registered. */
88
+ export function isGencowTable(table) {
89
+ return tableAccessRegistry.has(table);
90
+ }
91
+ /** Get all registered gencowTables (for audit/boot-time checks). */
92
+ export function getAllGencowTables() {
93
+ return new Map(tableAccessRegistry);
94
+ }
95
+ /** Reset registry (for testing only). */
96
+ export function _resetTableRegistry() {
97
+ tableAccessRegistry.clear();
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gencow/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,114 @@
1
+ /**
2
+ * packages/core/src/__tests__/auth.test.ts
3
+ *
4
+ * Tests for auth module — AuthCtx, defineAuth, auth-config.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/auth.test.ts
7
+ */
8
+
9
+ import { describe, it, expect } from "bun:test";
10
+ import { defineAuth } from "../auth-config";
11
+ import type { GencowAuthConfig } from "../auth-config";
12
+
13
+ // ─── defineAuth() ───────────────────────────────────────
14
+
15
+ describe("defineAuth()", () => {
16
+ it("빈 설정 객체 반환", () => {
17
+ const config = defineAuth({});
18
+ expect(config).toEqual({});
19
+ });
20
+
21
+ it("emailVerification 설정이 그대로 반환된다", () => {
22
+ const sendFn = async () => {};
23
+ const config = defineAuth({
24
+ emailVerification: {
25
+ sendOnSignUp: true,
26
+ requireEmailVerification: true,
27
+ autoSignInAfterVerification: true,
28
+ sendVerificationEmail: sendFn,
29
+ },
30
+ });
31
+
32
+ expect(config.emailVerification?.sendOnSignUp).toBe(true);
33
+ expect(config.emailVerification?.requireEmailVerification).toBe(true);
34
+ expect(config.emailVerification?.autoSignInAfterVerification).toBe(true);
35
+ expect(config.emailVerification?.sendVerificationEmail).toBe(sendFn);
36
+ });
37
+
38
+ it("부분 설정도 허용된다", () => {
39
+ const config = defineAuth({
40
+ emailVerification: {
41
+ sendVerificationEmail: async () => {},
42
+ },
43
+ });
44
+
45
+ expect(config.emailVerification?.sendOnSignUp).toBeUndefined();
46
+ expect(config.emailVerification?.sendVerificationEmail).toBeDefined();
47
+ });
48
+ });
49
+
50
+ // ─── AuthCtx interface 패턴 ─────────────────────────────
51
+
52
+ describe("AuthCtx 패턴 (mock)", () => {
53
+ // AuthCtx는 런타임에서 생성되므로, 인터페이스 수준에서 패턴 검증
54
+
55
+ it("getUserIdentity — 비로그인 시 null", () => {
56
+ const authCtx = {
57
+ getUserIdentity: () => null,
58
+ requireAuth: () => { throw new Error("Authentication required"); },
59
+ };
60
+
61
+ expect(authCtx.getUserIdentity()).toBeNull();
62
+ });
63
+
64
+ it("getUserIdentity — 로그인 시 유저 반환", () => {
65
+ const user = { id: "u1", email: "test@test.com", name: "Test" };
66
+ const authCtx = {
67
+ getUserIdentity: () => user,
68
+ requireAuth: () => user,
69
+ };
70
+
71
+ expect(authCtx.getUserIdentity()).toEqual(user);
72
+ expect(authCtx.getUserIdentity()!.id).toBe("u1");
73
+ expect(authCtx.getUserIdentity()!.email).toBe("test@test.com");
74
+ });
75
+
76
+ it("requireAuth — 비로그인 시 에러 throw", () => {
77
+ const authCtx = {
78
+ getUserIdentity: () => null,
79
+ requireAuth: () => { throw new Error("Authentication required"); },
80
+ };
81
+
82
+ expect(() => authCtx.requireAuth()).toThrow("Authentication required");
83
+ });
84
+
85
+ it("requireAuth — 로그인 시 유저 반환 (throw 안 함)", () => {
86
+ const user = { id: "u2", email: "auth@test.com" };
87
+ const authCtx = {
88
+ getUserIdentity: () => user,
89
+ requireAuth: () => user,
90
+ };
91
+
92
+ expect(() => authCtx.requireAuth()).not.toThrow();
93
+ expect(authCtx.requireAuth()).toEqual(user);
94
+ });
95
+ });
96
+
97
+ // ─── Secure by Default 검증 ─────────────────────────────
98
+
99
+ describe("Secure by Default — 기본 인증 필수", () => {
100
+ // query/mutation의 isPublic 기본값은 reactive.test.ts에서 검증됨.
101
+ // 여기서는 auth 엔드포인트 레벨 패턴만 확인.
102
+
103
+ it("공개(public) 쿼리는 auth 없이 실행 가능해야 함", () => {
104
+ // 이 테스트는 query({ public: true })의 isPublic 플래그 확인
105
+ // reactive.test.ts의 "Secure by Default" 섹션과 연계
106
+ const mockQueryDef = { isPublic: true, handler: async () => [] };
107
+ expect(mockQueryDef.isPublic).toBe(true);
108
+ });
109
+
110
+ it("비공개 쿼리는 기본적으로 auth 필수", () => {
111
+ const mockQueryDef = { isPublic: false, handler: async () => [] };
112
+ expect(mockQueryDef.isPublic).toBe(false);
113
+ });
114
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * packages/core/src/__tests__/httpaction.test.ts
3
+ *
4
+ * Tests for httpAction() — custom HTTP endpoint registration.
5
+ *
6
+ * Run: bun test packages/core/src/__tests__/httpaction.test.ts
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "bun:test";
10
+ import { httpAction, getRegisteredHttpActions } from "../reactive";
11
+
12
+ // Clean up httpAction registry between tests
13
+ // Note: httpAction uses globalThis.__gencow_httpActionRegistry (push-only array)
14
+ // We clear it in beforeEach to isolate tests.
15
+
16
+ describe("httpAction()", () => {
17
+ const initialLength = getRegisteredHttpActions().length;
18
+
19
+ it("httpAction 등록 시 레지스트리에 추가된다", () => {
20
+ const before = getRegisteredHttpActions().length;
21
+
22
+ httpAction({
23
+ method: "GET",
24
+ path: "/test/health",
25
+ handler: async () => ({ body: { status: "ok" } }),
26
+ });
27
+
28
+ const after = getRegisteredHttpActions().length;
29
+ expect(after).toBe(before + 1);
30
+ });
31
+
32
+ it("method, path가 정확히 설정된다", () => {
33
+ httpAction({
34
+ method: "POST",
35
+ path: "/webhook/stripe",
36
+ handler: async () => ({ body: { received: true } }),
37
+ });
38
+
39
+ const actions = getRegisteredHttpActions();
40
+ const last = actions[actions.length - 1];
41
+ expect(last.method).toBe("POST");
42
+ expect(last.path).toBe("/webhook/stripe");
43
+ });
44
+
45
+ it("public 기본값은 false이다", () => {
46
+ httpAction({
47
+ method: "GET",
48
+ path: "/test/private",
49
+ handler: async () => ({ body: {} }),
50
+ });
51
+
52
+ const actions = getRegisteredHttpActions();
53
+ const last = actions[actions.length - 1];
54
+ expect(last.isPublic).toBe(false);
55
+ });
56
+
57
+ it("public: true 설정 시 isPublic === true", () => {
58
+ httpAction({
59
+ method: "GET",
60
+ path: "/test/public-endpoint",
61
+ public: true,
62
+ handler: async () => ({ body: {} }),
63
+ });
64
+
65
+ const actions = getRegisteredHttpActions();
66
+ const last = actions[actions.length - 1];
67
+ expect(last.isPublic).toBe(true);
68
+ });
69
+
70
+ it("handler가 HttpActionDef에 포함된다", () => {
71
+ const handler = async () => ({ body: { ok: true } });
72
+ httpAction({
73
+ method: "PUT",
74
+ path: "/test/with-handler",
75
+ handler,
76
+ });
77
+
78
+ const actions = getRegisteredHttpActions();
79
+ const last = actions[actions.length - 1];
80
+ expect(last.handler).toBe(handler);
81
+ });
82
+
83
+ it("모든 HTTP 메서드 지원 (GET, POST, PUT, DELETE, PATCH)", () => {
84
+ const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
85
+ const before = getRegisteredHttpActions().length;
86
+
87
+ for (const method of methods) {
88
+ httpAction({
89
+ method,
90
+ path: `/test/method-${method.toLowerCase()}`,
91
+ handler: async () => ({ body: {} }),
92
+ });
93
+ }
94
+
95
+ const after = getRegisteredHttpActions().length;
96
+ expect(after).toBe(before + 5);
97
+
98
+ const actions = getRegisteredHttpActions();
99
+ const registered = actions.slice(-5).map(a => a.method);
100
+ expect(registered).toEqual(["GET", "POST", "PUT", "DELETE", "PATCH"]);
101
+ });
102
+
103
+ it("getRegisteredHttpActions()는 복사본을 반환한다 (원본 보호)", () => {
104
+ const a = getRegisteredHttpActions();
105
+ const b = getRegisteredHttpActions();
106
+ expect(a).not.toBe(b);
107
+ expect(a).toEqual(b);
108
+ });
109
+
110
+ it("Hono 경로 패턴 (/api/:id) 지원", () => {
111
+ httpAction({
112
+ method: "GET",
113
+ path: "/api/apps/:id/status",
114
+ public: true,
115
+ handler: async () => ({ body: {} }),
116
+ });
117
+
118
+ const actions = getRegisteredHttpActions();
119
+ const last = actions[actions.length - 1];
120
+ expect(last.path).toBe("/api/apps/:id/status");
121
+ });
122
+ });
@@ -235,3 +235,80 @@ describe("Secure by Default — public 플래그", () => {
235
235
  expect(priv?.isPublic).toBe(false);
236
236
  });
237
237
  });
238
+
239
+ // ─── mutation("name", def) 새 시그니처 테스트 ────────────────────────────────
240
+
241
+ describe("mutation(name, def) — query와 동일 패턴", () => {
242
+ it("mutation('name', { handler })로 등록하면 name이 올바르게 설정된다", () => {
243
+ const m = mutation("newsig.basic", {
244
+ handler: async () => ({ ok: true }),
245
+ });
246
+ expect((m as any).name || (getRegisteredMutations().find(x => x.invalidates.length === 0 && x.handler === (m as any).handler) as any)?.name).toBeDefined();
247
+ const all = getRegisteredMutations();
248
+ const found = all.find(x => x.name === "newsig.basic");
249
+ expect(found).toBeDefined();
250
+ expect(found!.isPublic).toBe(false);
251
+ });
252
+
253
+ it("mutation('name', { public: true })로 등록하면 isPublic === true", () => {
254
+ const m = mutation("newsig.public", {
255
+ public: true,
256
+ handler: async () => ({ ok: true }),
257
+ });
258
+ expect(m.isPublic).toBe(true);
259
+ });
260
+
261
+ it("invalidates 미지정 시 빈 배열이 기본값", () => {
262
+ const m = mutation("newsig.noInvalidates", {
263
+ handler: async () => ({ ok: true }),
264
+ });
265
+ const all = getRegisteredMutations();
266
+ const found = all.find(x => x.name === "newsig.noInvalidates");
267
+ expect(found!.invalidates).toEqual([]);
268
+ });
269
+
270
+ it("invalidates 지정 시 올바르게 전달된다", () => {
271
+ const m = mutation("newsig.withInvalidates", {
272
+ invalidates: ["tasks.list", "tasks.get"],
273
+ handler: async () => ({ ok: true }),
274
+ });
275
+ const all = getRegisteredMutations();
276
+ const found = all.find(x => x.name === "newsig.withInvalidates");
277
+ expect(found!.invalidates).toEqual(["tasks.list", "tasks.get"]);
278
+ });
279
+
280
+ it("기존 객체 스타일도 여전히 동작한다 (하위 호환)", () => {
281
+ const m = mutation({
282
+ name: "newsig.compat.object",
283
+ invalidates: ["a.list"],
284
+ handler: async () => ({ ok: true }),
285
+ });
286
+ const all = getRegisteredMutations();
287
+ const found = all.find(x => x.name === "newsig.compat.object");
288
+ expect(found).toBeDefined();
289
+ expect(found!.invalidates).toEqual(["a.list"]);
290
+ });
291
+
292
+ it("기존 배열 스타일도 여전히 동작한다 (하위 호환)", () => {
293
+ const m = mutation(["b.list"], async () => ({ ok: true }), "newsig.compat.array");
294
+ const all = getRegisteredMutations();
295
+ const found = all.find(x => x.name === "newsig.compat.array");
296
+ expect(found).toBeDefined();
297
+ expect(found!.invalidates).toEqual(["b.list"]);
298
+ });
299
+
300
+ it("이름 미지정 시 console.warn이 호출된다", () => {
301
+ const warnSpy = mock(() => {});
302
+ const originalWarn = console.warn;
303
+ console.warn = warnSpy;
304
+
305
+ mutation(["c.list"], async () => ({ ok: true }));
306
+
307
+ expect(warnSpy).toHaveBeenCalled();
308
+ const warnMsg = warnSpy.mock.calls[0][0] as string;
309
+ expect(warnMsg).toContain("[gencow]");
310
+ expect(warnMsg).toContain("without explicit name");
311
+
312
+ console.warn = originalWarn;
313
+ });
314
+ });