test(api-gateway): cover namespace rollback paths
This commit is contained in:
@@ -194,7 +194,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return async () => {
|
return async () => {
|
||||||
if (!namespace.service.remove('direct', descriptor.method, token)) return
|
namespace.service.remove('direct', descriptor.method, token)
|
||||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return async () => {
|
return async () => {
|
||||||
if (!namespace.service.remove('scoped', descriptor.method, token)) return
|
namespace.service.remove('scoped', descriptor.method, token)
|
||||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,50 +390,36 @@ class RemoteNamespaceService extends Service {
|
|||||||
let record = this.methods.get(method)
|
let record = this.methods.get(method)
|
||||||
const fresh = record === undefined
|
const fresh = record === undefined
|
||||||
record ??= {}
|
record ??= {}
|
||||||
if (record[kind] !== undefined) {
|
if (fresh) {
|
||||||
throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`)
|
Object.defineProperty(this, method, {
|
||||||
}
|
configurable: true,
|
||||||
try {
|
enumerable: true,
|
||||||
if (fresh) {
|
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||||
Object.defineProperty(this, method, {
|
const callerCtx = this.ctx
|
||||||
configurable: true,
|
const current = this.methods.get(method)
|
||||||
enumerable: true,
|
const direct = current?.direct
|
||||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
const scoped = current?.scoped
|
||||||
const callerCtx = this.ctx
|
return (...args: unknown[]) => {
|
||||||
const current = this.methods.get(method)
|
return this.invokeRemote(direct, scoped, callerCtx, args)
|
||||||
const direct = current?.direct
|
}
|
||||||
const scoped = current?.scoped
|
},
|
||||||
return (...args: unknown[]) => {
|
})
|
||||||
return this.invokeRemote(direct, scoped, callerCtx, args)
|
this.methods.set(method, record)
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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 (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 record = this.methods.get(method)
|
||||||
const current = record?.[kind]
|
const current = record?.[kind]
|
||||||
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
|
/* 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
|
if (kind === 'direct') delete record.direct
|
||||||
else delete record.scoped
|
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)
|
this.methods.delete(method)
|
||||||
Reflect.deleteProperty(this, method)
|
Reflect.deleteProperty(this, method)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,32 @@ describe('Client TypeRT API', () => {
|
|||||||
await retry()
|
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 () => {
|
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||||
const direct = directDescriptor()
|
const direct = directDescriptor()
|
||||||
@@ -406,6 +432,20 @@ describe('Client TypeRT API', () => {
|
|||||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
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 () => {
|
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||||
|
|||||||
Reference in New Issue
Block a user