瀏覽代碼

test(api-gateway): cover namespace rollback paths

imccyu 1 月之前
父節點
當前提交
8b51a1e95c
共有 2 個文件被更改,包括 62 次插入 和 36 次删除
  1. 22 36
      packages/api/gateway/src/client/index.ts
  2. 40 0
      packages/api/gateway/tests/client.spec.ts

+ 22 - 36
packages/api/gateway/src/client/index.ts

@@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
       throw error
     }
     return async () => {
-      if (!namespace.service.remove('direct', descriptor.method, token)) return
+      namespace.service.remove('direct', descriptor.method, token)
       await this.disposeNamespace(descriptor.namespace, namespace)
     }
   }
@@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
       throw error
     }
     return async () => {
-      if (!namespace.service.remove('scoped', descriptor.method, token)) return
+      namespace.service.remove('scoped', descriptor.method, token)
       await this.disposeNamespace(descriptor.namespace, namespace)
     }
   }
@@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service {
     let record = this.methods.get(method)
     const fresh = record === undefined
     record ??= {}
-    if (record[kind] !== undefined) {
-      throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`)
-    }
-    try {
-      if (fresh) {
-        Object.defineProperty(this, method, {
-          configurable: true,
-          enumerable: true,
-          get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
-            const callerCtx = this.ctx
-            const current = this.methods.get(method)
-            const direct = current?.direct
-            const scoped = current?.scoped
-            return (...args: unknown[]) => {
-              return this.invokeRemote(direct, scoped, callerCtx, args)
-            }
-          },
-        })
-        this.methods.set(method, record)
-      }
-      if (kind === 'direct') record.direct = value
-      else record.scoped = value as ScopedMethod
-    } catch (error) {
-      if (kind === 'direct') delete record.direct
-      else delete record.scoped
-      if (fresh) {
-        this.methods.delete(method)
-        Reflect.deleteProperty(this, method)
-      }
-      throw error
+    if (fresh) {
+      Object.defineProperty(this, method, {
+        configurable: true,
+        enumerable: true,
+        get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
+          const callerCtx = this.ctx
+          const current = this.methods.get(method)
+          const direct = current?.direct
+          const scoped = current?.scoped
+          return (...args: unknown[]) => {
+            return this.invokeRemote(direct, scoped, callerCtx, args)
+          }
+        },
+      })
+      this.methods.set(method, record)
     }
+    if (kind === 'direct') record.direct = value
+    else record.scoped = value as ScopedMethod
   }
 
-  remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean {
+  remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
     const record = this.methods.get(method)
     const current = record?.[kind]
     /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
-    if (record === undefined || current?.token !== token) return false
+    if (record === undefined || current?.token !== token) return
     if (kind === 'direct') delete record.direct
     else delete record.scoped
-    if (record.direct !== undefined || record.scoped !== undefined) return true
+    if (record.direct !== undefined || record.scoped !== undefined) return
     this.methods.delete(method)
     Reflect.deleteProperty(this, method)
-    return true
   }
 }
 

+ 40 - 0
packages/api/gateway/tests/client.spec.ts

@@ -316,6 +316,32 @@ describe('Client TypeRT API', () => {
     await retry()
   })
 
+  it('rolls back a direct projection when its scoped projection fails to install', async () => {
+    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+    const disposeContext = await ctx.remote.$mount({
+      package: '@fixture/context-anchor',
+      descriptors: [contextDescriptor()],
+    })
+    const namespace = ctx.get('remote.goals') as unknown as {
+      installScoped: (...args: unknown[]) => void
+      readonly create?: unknown
+    }
+    const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
+      throw new Error('fixture scoped projection failure')
+    })
+    try {
+      await expect(ctx.remote.$mount({
+        package: '@fixture/direct-projection-failure',
+        descriptors: [directDescriptor()],
+      })).rejects.toThrow('fixture scoped projection failure')
+    } finally {
+      installScoped.mockRestore()
+    }
+
+    expect(namespace.create).toBeUndefined()
+    await disposeContext()
+  })
+
   it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
     const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
     const direct = directDescriptor()
@@ -406,6 +432,20 @@ describe('Client TypeRT API', () => {
     expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
   })
 
+  it('rejects a method obtained from a withdrawn namespace getter', async () => {
+    const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
+    const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
+    const namespace = ctx.get('remote.goals') as unknown as object
+    const getter = Object.getOwnPropertyDescriptor(namespace, 'create')?.get
+
+    await dispose()
+
+    expect(getter).toBeTypeOf('function')
+    const withdrawn = getter?.call(namespace) as (...args: unknown[]) => Promise<unknown>
+    await expect(withdrawn('agent-1', { objective: 'ship' }))
+      .rejects.toThrow('Remote method is no longer mounted')
+  })
+
   it('preserves a __proto__ wire parameter as an own named argument', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })