@gravito/satellite-membership 0.1.1

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 (50) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +19 -0
  3. package/ARCHITECTURE.md +14 -0
  4. package/CHANGELOG.md +14 -0
  5. package/Dockerfile +25 -0
  6. package/README.md +112 -0
  7. package/WHITEPAPER.md +20 -0
  8. package/dist/index.d.ts +26 -0
  9. package/dist/index.js +1121 -0
  10. package/docs/EXTENDING.md +99 -0
  11. package/docs/PASSKEYS.md +78 -0
  12. package/locales/en.json +30 -0
  13. package/locales/zh-TW.json +30 -0
  14. package/package.json +35 -0
  15. package/src/Application/DTOs/MemberDTO.ts +34 -0
  16. package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
  17. package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
  18. package/src/Application/Mail/WelcomeMail.ts +45 -0
  19. package/src/Application/Services/PasskeysService.ts +198 -0
  20. package/src/Application/UseCases/ForgotPassword.ts +34 -0
  21. package/src/Application/UseCases/LoginMember.ts +64 -0
  22. package/src/Application/UseCases/RegisterMember.ts +65 -0
  23. package/src/Application/UseCases/ResetPassword.ts +30 -0
  24. package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
  25. package/src/Application/UseCases/UpdateSettings.ts +81 -0
  26. package/src/Application/UseCases/VerifyEmail.ts +23 -0
  27. package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
  28. package/src/Domain/Contracts/IMemberRepository.ts +11 -0
  29. package/src/Domain/Entities/Member.ts +219 -0
  30. package/src/Domain/Entities/MemberPasskey.ts +97 -0
  31. package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
  32. package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
  33. package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
  34. package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
  35. package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
  36. package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
  37. package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
  38. package/src/index.ts +234 -0
  39. package/src/manifest.json +15 -0
  40. package/tests/PasskeysService.test.ts +113 -0
  41. package/tests/email-integration.test.ts +161 -0
  42. package/tests/grand-review.ts +176 -0
  43. package/tests/integration.test.ts +75 -0
  44. package/tests/member.test.ts +47 -0
  45. package/tests/unit.test.ts +7 -0
  46. package/tests/update-settings.test.ts +101 -0
  47. package/tsconfig.json +26 -0
  48. package/views/emails/level_changed.html +35 -0
  49. package/views/emails/reset_password.html +34 -0
  50. package/views/emails/welcome.html +31 -0
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type { PasskeysConfig } from '../src/Application/Services/PasskeysService'
3
+ import { PasskeysService } from '../src/Application/Services/PasskeysService'
4
+ import type { IMemberPasskeyRepository } from '../src/Domain/Contracts/IMemberPasskeyRepository'
5
+ import { Member } from '../src/Domain/Entities/Member'
6
+ import { MemberPasskey } from '../src/Domain/Entities/MemberPasskey'
7
+
8
+ class InMemoryPasskeyRepository implements IMemberPasskeyRepository {
9
+ private store = new Map<string, MemberPasskey>()
10
+
11
+ async save(entity: MemberPasskey): Promise<void> {
12
+ this.store.set(entity.id, entity)
13
+ }
14
+
15
+ async findById(id: string): Promise<MemberPasskey | null> {
16
+ return this.store.get(id) ?? null
17
+ }
18
+
19
+ async findAll(): Promise<MemberPasskey[]> {
20
+ return Array.from(this.store.values())
21
+ }
22
+
23
+ async delete(id: string): Promise<void> {
24
+ this.store.delete(id)
25
+ }
26
+
27
+ async exists(id: string): Promise<boolean> {
28
+ return this.store.has(id)
29
+ }
30
+
31
+ async findByMemberId(memberId: string): Promise<MemberPasskey[]> {
32
+ return Array.from(this.store.values()).filter((entry) => entry.memberId === memberId)
33
+ }
34
+
35
+ async findByCredentialId(credentialId: string): Promise<MemberPasskey | null> {
36
+ return (
37
+ Array.from(this.store.values()).find((entry) => entry.credentialId === credentialId) ?? null
38
+ )
39
+ }
40
+
41
+ async deleteByCredentialId(credentialId: string): Promise<void> {
42
+ const entry = await this.findByCredentialId(credentialId)
43
+ if (entry) {
44
+ this.store.delete(entry.id)
45
+ }
46
+ }
47
+ }
48
+
49
+ function createSession() {
50
+ const store = new Map<string, string>()
51
+ return {
52
+ get(key: string) {
53
+ return store.get(key)
54
+ },
55
+ put(key: string, value: string) {
56
+ store.set(key, value)
57
+ },
58
+ forget(key: string) {
59
+ store.delete(key)
60
+ },
61
+ __store: store,
62
+ }
63
+ }
64
+
65
+ const config: PasskeysConfig = {
66
+ rpName: 'Test Membership',
67
+ rpID: 'localhost',
68
+ origin: 'http://localhost:3000',
69
+ timeout: 45000,
70
+ userVerification: 'preferred' as const,
71
+ attestationType: 'none' as const,
72
+ }
73
+
74
+ function createService() {
75
+ return new PasskeysService(new InMemoryPasskeyRepository(), config)
76
+ }
77
+
78
+ describe('PasskeysService', () => {
79
+ it('stores the generated registration challenge in the session', async () => {
80
+ const member = Member.create('member-1', 'Test User', 'test@example.com', 'hash')
81
+ const session = createSession()
82
+ const service = createService()
83
+
84
+ const options = await service.generateRegistrationOptions(member, session)
85
+ expect(options.challenge).toBeTruthy()
86
+ expect(session.__store.get('membership.passkeys.registration.challenge')).toBe(
87
+ options.challenge
88
+ )
89
+ })
90
+
91
+ it('includes stored credentials when generating authentication options', async () => {
92
+ const member = Member.create('member-2', 'Verifier', 'verify@example.com', 'hash')
93
+ const passkeyRepo = new InMemoryPasskeyRepository()
94
+ const service = new PasskeysService(passkeyRepo, config)
95
+ const credential = MemberPasskey.create({
96
+ memberId: member.id,
97
+ credentialId: Buffer.from('credential-123').toString('base64url'),
98
+ publicKey: Buffer.from('public-key').toString('base64'),
99
+ counter: 0,
100
+ transports: ['usb'],
101
+ })
102
+ await passkeyRepo.save(credential)
103
+
104
+ const session = createSession()
105
+ const options = await service.generateAuthenticationOptions(member, session)
106
+
107
+ expect(options.allowCredentials?.length).toBeGreaterThan(0)
108
+ expect(session.__store.get('membership.passkeys.authentication.challenge')).toBe(
109
+ options.challenge
110
+ )
111
+ expect(options.allowCredentials?.[0].id).toBe(credential.credentialId)
112
+ })
113
+ })
@@ -0,0 +1,161 @@
1
+ import { beforeAll, describe, expect, it } from 'bun:test'
2
+ import path from 'node:path'
3
+ import { DB, Schema } from '@gravito/atlas'
4
+ import { DevMailbox, MemoryTransport, OrbitSignal } from '@gravito/signal'
5
+ import { RegisterMember } from '../src/Application/UseCases/RegisterMember'
6
+ import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
7
+
8
+ describe('Membership Email Integration', () => {
9
+ const repo = new AtlasMemberRepository()
10
+ const mailbox = new DevMailbox()
11
+ const transport = new MemoryTransport(mailbox)
12
+
13
+ // Mock Core with Signal
14
+ const mockCore: any = {
15
+ hasher: {
16
+ make: async (_val: string) => `hashed_\${val}`,
17
+ check: async (_plain: string, hash: string) => `hashed_\${plain}` === hash,
18
+ },
19
+ hooks: {
20
+ actions: {} as Record<string, Function[]>,
21
+ addAction(name: string, cb: Function) {
22
+ this.actions[name] = this.actions[name] || []
23
+ this.actions[name].push(cb)
24
+ },
25
+ async doAction(name: string, data: any) {
26
+ if (this.actions[name]) {
27
+ for (const cb of this.actions[name]) {
28
+ await cb(data)
29
+ }
30
+ }
31
+ },
32
+ },
33
+ container: {
34
+ bindings: new Map(),
35
+ singleton(key: string, cb: Function) {
36
+ this.bindings.set(key, cb())
37
+ },
38
+ make(key: string) {
39
+ if (key === 'i18n') {
40
+ return { t: (k: string) => k }
41
+ }
42
+ return this.bindings.get(key)
43
+ },
44
+ },
45
+ logger: {
46
+ info: console.log,
47
+ error: console.error,
48
+ warn: console.warn,
49
+ },
50
+ }
51
+
52
+ // Setup Signal
53
+ const signal = new OrbitSignal({
54
+ transport,
55
+ from: { address: 'no-reply@gravito.dev', name: 'Gravito' },
56
+ viewsDir: path.resolve(import.meta.dir, '../views'),
57
+ })
58
+
59
+ // Setup UseCase
60
+ const registerUseCase = new RegisterMember(repo, mockCore)
61
+
62
+ beforeAll(async () => {
63
+ // 1. Database
64
+ DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
65
+ await Schema.create('members', (table) => {
66
+ table.string('id').primary()
67
+ table.string('name')
68
+ table.string('email').unique()
69
+ table.string('password_hash')
70
+ table.string('status')
71
+ table.text('roles')
72
+ table.string('verification_token')
73
+ table.timestamp('email_verified_at').nullable()
74
+ table.string('password_reset_token').nullable()
75
+ table.timestamp('password_reset_expires_at').nullable()
76
+ table.string('current_session_id').nullable()
77
+ table.string('remember_token').nullable()
78
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
79
+ table.text('metadata').nullable()
80
+ })
81
+
82
+ // 2. Register Hook (Manually since we are not booting the full provider)
83
+ mockCore.hooks.addAction('membership:send-verification', async (data: any) => {
84
+ await signal.send(
85
+ new (await import('../src/Application/Mail/WelcomeMail')).WelcomeMail(
86
+ data.email,
87
+ data.token
88
+ )
89
+ )
90
+ })
91
+ })
92
+
93
+ it('註冊後應觸發發送驗證郵件且包含美化樣式', async () => {
94
+ const email = 'test@example.com'
95
+ await registerUseCase.execute({
96
+ name: 'Test User',
97
+ email,
98
+ passwordPlain: 'password',
99
+ })
100
+
101
+ const messages = mailbox.list()
102
+ expect(messages.length).toBe(1)
103
+ expect(messages[0].envelope.subject).toBe('membership.emails.welcome_subject')
104
+ // 驗證是否包含 CSS 樣式標籤 (代表模板已渲染)
105
+ expect(messages[0].html).toContain('<style>')
106
+ expect(messages[0].html).toContain('Gravito App')
107
+ })
108
+
109
+ it('觸發忘記密碼應發送重設郵件', async () => {
110
+ // 1. 註冊一個 Hook (模擬 ServiceProvider)
111
+ mockCore.hooks.addAction('membership:send-reset-password', async (data: any) => {
112
+ await signal.send(
113
+ new (await import('../src/Application/Mail/ForgotPasswordMail')).ForgotPasswordMail(
114
+ data.email,
115
+ data.token
116
+ )
117
+ )
118
+ })
119
+
120
+ mailbox.clear()
121
+
122
+ // 2. 模擬觸發 Hook
123
+ await mockCore.hooks.doAction('membership:send-reset-password', {
124
+ email: 'reset@example.com',
125
+ token: 'secret-token',
126
+ })
127
+
128
+ const messages = mailbox.list()
129
+ expect(messages.length).toBe(1)
130
+ expect(messages[0].envelope.subject).toBe('membership.emails.reset_password_subject')
131
+ expect(messages[0].html).toContain('secret-token')
132
+ expect(messages[0].html).toContain('reset_password_button')
133
+ })
134
+
135
+ it('等級變更後應發送晉級通知郵件', async () => {
136
+ mockCore.hooks.addAction('membership:level-changed', async (data: any) => {
137
+ await signal.send(
138
+ new (await import('../src/Application/Mail/MemberLevelChangedMail')).MemberLevelChangedMail(
139
+ data.email,
140
+ data.oldLevel,
141
+ data.newLevel
142
+ )
143
+ )
144
+ })
145
+
146
+ mailbox.clear()
147
+
148
+ await mockCore.hooks.doAction('membership:level-changed', {
149
+ email: 'vip@example.com',
150
+ oldLevel: 'Silver',
151
+ newLevel: 'Gold',
152
+ })
153
+
154
+ const messages = mailbox.list()
155
+ expect(messages.length).toBe(1)
156
+ expect(messages[0].envelope.subject).toBe('membership.emails.level_changed_subject')
157
+ expect(messages[0].html).toContain('Silver')
158
+ expect(messages[0].html).toContain('Gold')
159
+ expect(messages[0].html).toContain('level_changed_badge')
160
+ })
161
+ })
@@ -0,0 +1,176 @@
1
+ import path from 'node:path'
2
+ import { DB, Schema } from '@gravito/atlas'
3
+ import { PlanetCore, setApp } from '@gravito/core'
4
+ import { OrbitSignal } from '@gravito/signal'
5
+ import type { LoginMember } from '../src/Application/UseCases/LoginMember'
6
+ import type { RegisterMember } from '../src/Application/UseCases/RegisterMember'
7
+ import { verifySingleDevice } from '../src/Interface/Http/Middleware/VerifySingleDevice'
8
+ import { MembershipServiceProvider } from '../src/index'
9
+
10
+ /**
11
+ * 🛰️ Gravito Membership "Grand Review" (大校閱)
12
+ *
13
+ * 此腳本模擬全系統在 Launchpad 環境下的運行狀況
14
+ */
15
+ async function grandReview() {
16
+ console.log('\n🚀 [Grand Review] 啟動全系統校閱流程...')
17
+
18
+ // 1. 初始化核心與軌道
19
+ const core = await PlanetCore.boot({
20
+ config: {
21
+ APP_NAME: 'Membership Review',
22
+ PORT: 3001,
23
+ 'membership.auth.single_device': true, // 開啟多設備限制
24
+ 'membership.branding.name': 'Review Admiral', // 自定義品牌
25
+ 'membership.branding.primary_color': '#10b981', // 自定義顏色 (綠色)
26
+ 'app.url': 'https://review.local',
27
+ 'database.default': 'sqlite',
28
+ 'database.connections.sqlite': {
29
+ driver: 'sqlite',
30
+ database: ':memory:',
31
+ },
32
+ },
33
+ orbits: [
34
+ new OrbitSignal({
35
+ devMode: true,
36
+ from: { address: 'system@gravito.dev', name: 'Gravito Core' },
37
+ viewsDir: path.resolve(import.meta.dir, '../views'),
38
+ }),
39
+ ],
40
+ })
41
+
42
+ // 1.2 強制設置全局 app 實例,供 Mailable 內部使用
43
+ setApp(core)
44
+
45
+ // 1.5 初始化 Atlas
46
+ DB.addConnection('default', {
47
+ driver: 'sqlite',
48
+ database: ':memory:',
49
+ })
50
+
51
+ // 2. 註冊服務
52
+ core.container.instance('i18n', {
53
+ t: (k: string) => k,
54
+ addResource: () => {},
55
+ on: () => {},
56
+ })
57
+
58
+ // 取得真正的 Repository
59
+ const realRepo = new (
60
+ await import('../src/Infrastructure/Persistence/AtlasMemberRepository')
61
+ ).AtlasMemberRepository()
62
+ core.container.instance('membership.repo', realRepo)
63
+
64
+ // Mock Auth (Sentinel)
65
+ const mockAuth = {
66
+ guard: () => ({
67
+ attempt: async () => true,
68
+ user: async () => {
69
+ // 從 Repo 抓出剛才註冊的人
70
+ const members = await realRepo.findAll()
71
+ return members[0]
72
+ },
73
+ logout: async () => {},
74
+ }),
75
+ }
76
+ core.container.instance('auth', mockAuth)
77
+
78
+ await core.use(new MembershipServiceProvider())
79
+ await core.bootstrap()
80
+
81
+ console.log('✅ [System] 核心與衛星模組已就緒。')
82
+
83
+ // 3. 準備資料庫 (執行遷移)
84
+ console.log('📦 [Database] 正在建立會員資料表...')
85
+ await Schema.create('members', (table) => {
86
+ table.string('id').primary()
87
+ table.string('name')
88
+ table.string('email').unique()
89
+ table.string('password_hash')
90
+ table.string('status').default('pending')
91
+ table.text('roles').default('["member"]')
92
+ table.string('verification_token').nullable()
93
+ table.timestamp('email_verified_at').nullable()
94
+ table.string('password_reset_token').nullable()
95
+ table.timestamp('password_reset_expires_at').nullable()
96
+ table.string('current_session_id').nullable()
97
+ table.string('remember_token').nullable()
98
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
99
+ table.timestamp('updated_at').nullable()
100
+ table.text('metadata').nullable()
101
+ })
102
+
103
+ const _repo = core.container.make<any>('membership.repo')
104
+ const register = core.container.make<RegisterMember>('membership.register')
105
+ const login = core.container.make<LoginMember>('membership.login')
106
+
107
+ // --- 測試案例 A: 註冊與郵件發送 ---
108
+ console.log('\n🧪 [Test A] 模擬新會員註冊...')
109
+ const email = 'commander@gravito.dev'
110
+ await register.execute({
111
+ name: 'Gravito Commander',
112
+ email: email,
113
+ passwordPlain: 'mission-critical-123',
114
+ })
115
+
116
+ console.log('📬 [Signal] 請檢查上方日誌,應包含美化後的 Welcome Mail HTML。')
117
+
118
+ // --- 測試案例 B: 多設備限制 ---
119
+ console.log('\n🧪 [Test B] 模擬多設備登入限制...')
120
+
121
+ // 模擬 Session A
122
+ const mockSessionA = {
123
+ id: () => 'session_device_1',
124
+ get: (k: string) => (k === 'login_web_auth_session' ? email : null),
125
+ put: () => {},
126
+ regenerate: () => {},
127
+ }
128
+ core.container.instance('session', mockSessionA)
129
+
130
+ console.log('📱 設備 1 正在登入...')
131
+ await login.execute({ email, passwordPlain: 'mission-critical-123' })
132
+
133
+ // 模擬 Session B (另一個設備)
134
+ const mockSessionB = {
135
+ id: () => 'session_device_2',
136
+ get: (k: string) => (k === 'login_web_auth_session' ? email : null),
137
+ put: () => {},
138
+ regenerate: () => {},
139
+ }
140
+ core.container.instance('session', mockSessionB)
141
+
142
+ console.log('💻 設備 2 (新設備) 正在登入...')
143
+ await login.execute({ email, passwordPlain: 'mission-critical-123' })
144
+
145
+ // 模擬設備 1 的後續請求,應被攔截
146
+ console.log('🛡️ 驗證設備 1 是否被強制登出...')
147
+ core.container.instance('session', mockSessionA) // 切換回設備 1 的環境
148
+
149
+ // 建立模擬 Context
150
+ const mockContext: any = {
151
+ get: (key: string) => {
152
+ if (key === 'core') {
153
+ return core
154
+ }
155
+ return null
156
+ },
157
+ req: { header: () => 'application/json' },
158
+ json: (d: any) => d,
159
+ }
160
+
161
+ try {
162
+ await verifySingleDevice(mockContext, async () => {
163
+ console.log('❌ [Fail] 設備 1 居然還能訪問!')
164
+ })
165
+ } catch (err: any) {
166
+ console.log(`✅ [Pass] 設備 1 被攔截,錯誤訊息: ${err.message}`)
167
+ }
168
+
169
+ console.log('\n🏁 [Grand Review] 校閱完成!所有系統運作正常。')
170
+ process.exit(0)
171
+ }
172
+
173
+ grandReview().catch((err) => {
174
+ console.error('💥 校閱過程中發生崩潰:', err)
175
+ process.exit(1)
176
+ })
@@ -0,0 +1,75 @@
1
+ import { beforeAll, describe, expect, it } from 'bun:test'
2
+ import { DB, Schema } from '@gravito/atlas'
3
+ import { RegisterMember } from '../src/Application/UseCases/RegisterMember'
4
+ import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
5
+
6
+ describe('Membership Satellite Integration', () => {
7
+ const repo = new AtlasMemberRepository()
8
+
9
+ // Mock Core
10
+ const mockCore: any = {
11
+ hasher: {
12
+ make: async (val: string) => `hashed_${val}`,
13
+ check: async (plain: string, hash: string) => `hashed_${plain}` === hash,
14
+ },
15
+ hooks: {
16
+ doAction: async () => {},
17
+ },
18
+ container: {
19
+ make: (key: string) => {
20
+ if (key === 'i18n') {
21
+ return { t: (k: string) => k }
22
+ }
23
+ return null
24
+ },
25
+ },
26
+ }
27
+
28
+ const registerUseCase = new RegisterMember(repo, mockCore)
29
+
30
+ beforeAll(async () => {
31
+ // 1. 配置 SQLite 記憶體資料庫
32
+ DB.addConnection('default', {
33
+ driver: 'sqlite',
34
+ database: ':memory:',
35
+ })
36
+
37
+ // 2. 建立測試用的資料表
38
+ await Schema.dropIfExists('members')
39
+ await Schema.create('members', (table) => {
40
+ table.string('id').primary()
41
+ table.string('name')
42
+ table.string('email').unique()
43
+ table.string('password_hash')
44
+ table.string('status')
45
+ table.text('roles').default('["member"]')
46
+ table.string('verification_token').nullable()
47
+ table.timestamp('email_verified_at').nullable()
48
+ table.string('password_reset_token').nullable()
49
+ table.timestamp('password_reset_expires_at').nullable()
50
+ table.string('current_session_id').nullable()
51
+ table.string('remember_token').nullable()
52
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
53
+ table.timestamp('updated_at').nullable()
54
+ table.text('metadata').nullable()
55
+ })
56
+ })
57
+
58
+ it('應該能成功註冊新會員', async () => {
59
+ const input = {
60
+ name: 'Carl',
61
+ email: `carl-${Date.now()}@example.com`,
62
+ passwordPlain: 'secret123',
63
+ }
64
+
65
+ const result = await registerUseCase.execute(input)
66
+
67
+ expect(result.id).toBeDefined()
68
+ expect(result.email).toBe(input.email)
69
+
70
+ // 驗證是否真的存入 Repository
71
+ const savedMember = await repo.findById(result.id)
72
+ expect(savedMember).not.toBeNull()
73
+ expect(savedMember?.name).toBe(input.name)
74
+ })
75
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { Member, MemberStatus } from '../src/Domain/Entities/Member'
3
+
4
+ describe('Member Domain Entity', () => {
5
+ it('應該能正確建立新會員實體且預設狀態為 PENDING', () => {
6
+ const member = Member.create('user-1', 'Carl', 'carl@example.com', 'hash-123')
7
+
8
+ expect(member.id).toBe('user-1')
9
+ expect(member.name).toBe('Carl')
10
+ expect(member.email).toBe('carl@example.com')
11
+ expect(member.status).toBe(MemberStatus.PENDING)
12
+ expect(member.createdAt).toBeInstanceOf(Date)
13
+ })
14
+
15
+ it('執行 verifyEmail() 後狀態應該變更為 ACTIVE', () => {
16
+ const member = Member.create('u1', 'N', 'e', 'h')
17
+
18
+ member.verifyEmail()
19
+
20
+ expect(member.status).toBe(MemberStatus.ACTIVE)
21
+ })
22
+
23
+ it('應該支援 metadata 的存取與擴充', () => {
24
+ // 模擬從資料庫重建包含 metadata 的實體
25
+ const member = Member.reconstitute('u1', {
26
+ name: 'Carl',
27
+ email: 'c',
28
+ passwordHash: 'h',
29
+ status: MemberStatus.ACTIVE,
30
+ createdAt: new Date(),
31
+ updatedAt: new Date(),
32
+ metadata: {
33
+ vat_number: '12345678',
34
+ level: 'gold',
35
+ },
36
+ })
37
+
38
+ expect(member.metadata.vat_number).toBe('12345678')
39
+ expect(member.metadata.level).toBe('gold')
40
+ })
41
+
42
+ it('當無 metadata 時應該回傳空物件而非 undefined', () => {
43
+ const member = Member.create('u1', 'N', 'e', 'h')
44
+ expect(member.metadata).toBeDefined()
45
+ expect(Object.keys(member.metadata).length).toBe(0)
46
+ })
47
+ })
@@ -0,0 +1,7 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ describe('Membership', () => {
4
+ it('should work', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,101 @@
1
+ import { beforeAll, describe, expect, it } from 'bun:test'
2
+ import { DB, Schema } from '@gravito/atlas'
3
+ import { UpdateSettings } from '../src/Application/UseCases/UpdateSettings'
4
+ import { Member, MemberStatus } from '../src/Domain/Entities/Member'
5
+ import { AtlasMemberRepository } from '../src/Infrastructure/Persistence/AtlasMemberRepository'
6
+
7
+ describe('Membership UpdateSettings Integration', () => {
8
+ const repo = new AtlasMemberRepository()
9
+ let updateUseCase: UpdateSettings
10
+
11
+ // Mock Core with Hasher
12
+ const mockCore: any = {
13
+ hasher: {
14
+ make: async (val: string) => `hashed_${val}`,
15
+ check: async (plain: string, hash: string) => `hashed_${plain}` === hash,
16
+ },
17
+ hooks: {
18
+ doAction: async () => {},
19
+ },
20
+ }
21
+
22
+ beforeAll(async () => {
23
+ DB.addConnection('default', { driver: 'sqlite', database: ':memory:' })
24
+ await Schema.dropIfExists('members')
25
+ await Schema.create('members', (table) => {
26
+ table.string('id').primary()
27
+ table.string('name')
28
+ table.string('email').unique()
29
+ table.string('password_hash')
30
+ table.string('status')
31
+ table.text('roles').default('["member"]')
32
+ table.string('verification_token').nullable()
33
+ table.timestamp('email_verified_at').nullable()
34
+ table.string('password_reset_token').nullable()
35
+ table.timestamp('password_reset_expires_at').nullable()
36
+ table.string('current_session_id').nullable()
37
+ table.string('remember_token').nullable()
38
+ table.timestamp('created_at').default('CURRENT_TIMESTAMP')
39
+ table.timestamp('updated_at').nullable()
40
+ table.text('metadata').nullable()
41
+ })
42
+
43
+ updateUseCase = new UpdateSettings(repo, mockCore)
44
+ })
45
+
46
+ it('should update profile and merge metadata', async () => {
47
+ // 1. Create initial member
48
+ const initialHash = await mockCore.hasher.make('old-pass')
49
+ const member = Member.reconstitute('u1', {
50
+ name: 'Old Name',
51
+ email: 'test@test.com',
52
+ passwordHash: initialHash,
53
+ status: MemberStatus.ACTIVE,
54
+ roles: ['member'],
55
+ createdAt: new Date(),
56
+ updatedAt: new Date(),
57
+ metadata: { initial: 'data' },
58
+ })
59
+ await repo.save(member)
60
+
61
+ // 2. Perform update
62
+ const result = await updateUseCase.execute({
63
+ memberId: 'u1',
64
+ name: 'New Name',
65
+ metadata: { theme: 'dark' },
66
+ })
67
+
68
+ expect(result.name).toBe('New Name')
69
+ expect(result.metadata?.initial).toBe('data')
70
+ expect(result.metadata?.theme).toBe('dark')
71
+ })
72
+
73
+ it('should securely change password', async () => {
74
+ const pass = await mockCore.hasher.make('current-pass')
75
+ const member = Member.create('u2', 'N', 'e2@e.com', pass)
76
+ await repo.save(member)
77
+
78
+ // Should throw if current password wrong
79
+ try {
80
+ await updateUseCase.execute({
81
+ memberId: 'u2',
82
+ currentPassword: 'wrong',
83
+ newPassword: 'new',
84
+ })
85
+ expect(true).toBe(false) // Should not reach here
86
+ } catch (e: any) {
87
+ expect(e.message).toBe('Invalid current password')
88
+ }
89
+
90
+ // Should succeed if correct
91
+ await updateUseCase.execute({
92
+ memberId: 'u2',
93
+ currentPassword: 'current-pass',
94
+ newPassword: 'secure-new-pass',
95
+ })
96
+
97
+ const updated = await repo.findById('u2')
98
+ const isNewPassValid = await mockCore.hasher.check('secure-new-pass', updated?.passwordHash)
99
+ expect(isNewPassValid).toBe(true)
100
+ })
101
+ })