feat(memfs): add owner tags for block ownership tracking (#744)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -200,7 +200,10 @@ export async function ensureSkillsBlocks(agentId: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
// Create the block and attach to agent
|
||||
const createdBlock = await client.blocks.create(blockData);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId });
|
||||
createdLabels.push(label);
|
||||
}
|
||||
|
||||
@@ -306,6 +306,49 @@ async function fetchAgentBlocks(agentId: string): Promise<Block[]> {
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all blocks owned by this agent (via owner tag).
|
||||
* This includes both attached and detached blocks.
|
||||
*/
|
||||
async function fetchOwnedBlocks(agentId: string): Promise<Block[]> {
|
||||
const client = await getClient();
|
||||
const ownerTag = `owner:${agentId}`;
|
||||
const page = await client.blocks.list({ tags: [ownerTag], limit: 1000 });
|
||||
|
||||
// Handle both array response and paginated response
|
||||
if (Array.isArray(page)) {
|
||||
return page;
|
||||
}
|
||||
|
||||
const items =
|
||||
(page as { items?: Block[] }).items ||
|
||||
(page as { blocks?: Block[] }).blocks ||
|
||||
[];
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill owner tags on blocks that don't have them.
|
||||
* This ensures backwards compatibility with blocks created before tagging.
|
||||
*/
|
||||
async function backfillOwnerTags(
|
||||
agentId: string,
|
||||
blocks: Block[],
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
const ownerTag = `owner:${agentId}`;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.id) continue;
|
||||
const tags = block.tags || [];
|
||||
if (!tags.includes(ownerTag)) {
|
||||
await client.blocks.update(block.id, {
|
||||
tags: [...tags, ownerTag],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMemoryFilesystemTree(
|
||||
systemLabels: string[],
|
||||
detachedLabels: string[],
|
||||
@@ -453,14 +496,25 @@ export async function syncMemoryFilesystem(
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
const detachedBlockIds = { ...lastState.detachedBlockIds };
|
||||
// Backfill owner tags on attached blocks (for backwards compat)
|
||||
await backfillOwnerTags(agentId, attachedBlocks);
|
||||
|
||||
// Discover detached blocks via owner tag (replaces detachedBlockIds tracking)
|
||||
const allOwnedBlocks = await fetchOwnedBlocks(agentId);
|
||||
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
|
||||
const detachedBlocks = allOwnedBlocks.filter((b) => !attachedIds.has(b.id));
|
||||
|
||||
// Build detached block map and IDs (for sync state compatibility)
|
||||
const detachedBlockIds: Record<string, string> = {};
|
||||
const detachedBlockMap = new Map<string, Block>();
|
||||
for (const [label, blockId] of Object.entries(detachedBlockIds)) {
|
||||
try {
|
||||
const block = await client.blocks.retrieve(blockId);
|
||||
detachedBlockMap.set(label, block as Block);
|
||||
} catch {
|
||||
delete detachedBlockIds[label];
|
||||
for (const block of detachedBlocks) {
|
||||
if (block.label && block.id) {
|
||||
// Skip managed blocks (skills, loaded_skills, memory_filesystem)
|
||||
if (MANAGED_BLOCK_LABELS.has(block.label)) {
|
||||
continue;
|
||||
}
|
||||
detachedBlockIds[block.label] = block.id;
|
||||
detachedBlockMap.set(block.label, block);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +554,10 @@ export async function syncMemoryFilesystem(
|
||||
|
||||
// Create block from file (parsing frontmatter for description/limit)
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
const createdBlock = await client.blocks.create(blockData);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
@@ -512,14 +569,13 @@ export async function syncMemoryFilesystem(
|
||||
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> delete block
|
||||
// File deleted, block unchanged -> detach only (block stays with owner tag)
|
||||
if (blockEntry.id) {
|
||||
try {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
// Also delete the block to avoid orphaned blocks on the server
|
||||
await client.blocks.delete(blockEntry.id);
|
||||
// Note: Don't delete the block - it keeps its owner tag for potential recovery
|
||||
deletedBlocks.push(label);
|
||||
} catch (err) {
|
||||
// Block may have been manually deleted already - ignore
|
||||
@@ -585,7 +641,10 @@ export async function syncMemoryFilesystem(
|
||||
fileEntry.content,
|
||||
label,
|
||||
);
|
||||
const createdBlock = await client.blocks.create(blockData);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
@@ -638,7 +697,10 @@ export async function syncMemoryFilesystem(
|
||||
}
|
||||
|
||||
const blockData = parseBlockFromFileContent(fileEntry.content, label);
|
||||
const createdBlock = await client.blocks.create(blockData);
|
||||
const createdBlock = await client.blocks.create({
|
||||
...blockData,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
detachedBlockIds[blockData.label] = createdBlock.id;
|
||||
detachedBlockMap.set(blockData.label, createdBlock as Block);
|
||||
@@ -649,10 +711,8 @@ export async function syncMemoryFilesystem(
|
||||
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> delete block
|
||||
if (blockEntry.id) {
|
||||
await client.blocks.delete(blockEntry.id);
|
||||
}
|
||||
// File deleted, block unchanged -> just remove from tracking (block keeps owner tag)
|
||||
// Note: Don't delete the block - it stays discoverable via owner tag
|
||||
deletedBlocks.push(label);
|
||||
delete detachedBlockIds[label];
|
||||
continue;
|
||||
@@ -808,6 +868,7 @@ export async function ensureMemoryFilesystemBlock(agentId: string) {
|
||||
description: "Filesystem view of memory blocks",
|
||||
limit: 20000,
|
||||
read_only: true,
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
|
||||
if (createdBlock.id) {
|
||||
@@ -891,16 +952,19 @@ export async function checkMemoryFilesystemStatus(
|
||||
const newFiles: string[] = [];
|
||||
const newBlocks: string[] = [];
|
||||
|
||||
// Fetch user blocks for status check
|
||||
const detachedBlockIds = { ...lastState.detachedBlockIds };
|
||||
// Discover detached blocks via owner tag
|
||||
const allOwnedBlocks = await fetchOwnedBlocks(agentId);
|
||||
const attachedIds = new Set(attachedBlocks.map((b) => b.id));
|
||||
const detachedBlocks = allOwnedBlocks.filter((b) => !attachedIds.has(b.id));
|
||||
|
||||
const detachedBlockMap = new Map<string, Block>();
|
||||
const client = await getClient();
|
||||
for (const [label, blockId] of Object.entries(detachedBlockIds)) {
|
||||
try {
|
||||
const block = await client.blocks.retrieve(blockId);
|
||||
detachedBlockMap.set(label, block as Block);
|
||||
} catch {
|
||||
delete detachedBlockIds[label];
|
||||
for (const block of detachedBlocks) {
|
||||
if (block.label) {
|
||||
// Skip managed blocks
|
||||
if (MANAGED_BLOCK_LABELS.has(block.label)) {
|
||||
continue;
|
||||
}
|
||||
detachedBlockMap.set(block.label, block);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
173
src/tests/agent/memoryFilesystem.integration.test.ts
Normal file
173
src/tests/agent/memoryFilesystem.integration.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Integration tests for memory filesystem block tagging.
|
||||
* These tests hit the real Letta API and require LETTA_API_KEY to be set.
|
||||
*
|
||||
* Run with: bun test src/tests/agent/memoryFilesystem.integration.test.ts
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import Letta from "@letta-ai/letta-client";
|
||||
|
||||
// Skip all tests if no API key is available
|
||||
const LETTA_API_KEY = process.env.LETTA_API_KEY;
|
||||
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || "https://api.letta.com";
|
||||
|
||||
const describeIntegration = LETTA_API_KEY ? describe : describe.skip;
|
||||
|
||||
describeIntegration("block tagging integration", () => {
|
||||
let client: Letta;
|
||||
let testAgentId: string;
|
||||
const createdBlockIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new Letta({
|
||||
baseURL: LETTA_BASE_URL,
|
||||
apiKey: LETTA_API_KEY!,
|
||||
});
|
||||
|
||||
// Create a test agent
|
||||
const agent = await client.agents.create({
|
||||
name: `memfs-test-${Date.now()}`,
|
||||
model: "letta/letta-free",
|
||||
embedding: "letta/letta-free",
|
||||
});
|
||||
testAgentId = agent.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up: delete created blocks
|
||||
for (const blockId of createdBlockIds) {
|
||||
try {
|
||||
await client.blocks.delete(blockId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test agent
|
||||
if (testAgentId) {
|
||||
try {
|
||||
await client.agents.delete(testAgentId);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("block created with owner tag is discoverable via tag query", async () => {
|
||||
const ownerTag = `owner:${testAgentId}`;
|
||||
|
||||
// Create a block with owner tag
|
||||
const block = await client.blocks.create({
|
||||
label: `test-tagged-${Date.now()}`,
|
||||
value: "Test content",
|
||||
tags: [ownerTag],
|
||||
});
|
||||
createdBlockIds.push(block.id);
|
||||
|
||||
// Query blocks by owner tag
|
||||
const ownedBlocks = await client.blocks.list({ tags: [ownerTag] });
|
||||
const ownedBlocksArray = Array.isArray(ownedBlocks)
|
||||
? ownedBlocks
|
||||
: (ownedBlocks as { items?: Array<{ id: string }> }).items || [];
|
||||
|
||||
// Verify our block is in the results
|
||||
const found = ownedBlocksArray.some((b) => b.id === block.id);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
test("detached block remains discoverable via owner tag after detach", async () => {
|
||||
const ownerTag = `owner:${testAgentId}`;
|
||||
|
||||
// Create and attach a block
|
||||
const block = await client.blocks.create({
|
||||
label: `test-detach-${Date.now()}`,
|
||||
value: "Test content for detach",
|
||||
tags: [ownerTag],
|
||||
});
|
||||
createdBlockIds.push(block.id);
|
||||
|
||||
await client.agents.blocks.attach(block.id, { agent_id: testAgentId });
|
||||
|
||||
// Verify it's attached
|
||||
const attachedBlocks = await client.agents.blocks.list(testAgentId);
|
||||
const attachedArray = Array.isArray(attachedBlocks)
|
||||
? attachedBlocks
|
||||
: (attachedBlocks as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(attachedArray.some((b) => b.id === block.id)).toBe(true);
|
||||
|
||||
// Detach the block
|
||||
await client.agents.blocks.detach(block.id, { agent_id: testAgentId });
|
||||
|
||||
// Verify it's no longer attached
|
||||
const afterDetach = await client.agents.blocks.list(testAgentId);
|
||||
const afterDetachArray = Array.isArray(afterDetach)
|
||||
? afterDetach
|
||||
: (afterDetach as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(afterDetachArray.some((b) => b.id === block.id)).toBe(false);
|
||||
|
||||
// But it should still be discoverable via owner tag
|
||||
const ownedBlocks = await client.blocks.list({ tags: [ownerTag] });
|
||||
const ownedBlocksArray = Array.isArray(ownedBlocks)
|
||||
? ownedBlocks
|
||||
: (ownedBlocks as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(ownedBlocksArray.some((b) => b.id === block.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("backfill adds owner tag to existing block", async () => {
|
||||
const ownerTag = `owner:${testAgentId}`;
|
||||
|
||||
// Create a block WITHOUT owner tag
|
||||
const block = await client.blocks.create({
|
||||
label: `test-backfill-${Date.now()}`,
|
||||
value: "Test content for backfill",
|
||||
// No tags
|
||||
});
|
||||
createdBlockIds.push(block.id);
|
||||
|
||||
// Verify it's NOT discoverable via owner tag initially
|
||||
const beforeBackfill = await client.blocks.list({ tags: [ownerTag] });
|
||||
const beforeArray = Array.isArray(beforeBackfill)
|
||||
? beforeBackfill
|
||||
: (beforeBackfill as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(beforeArray.some((b) => b.id === block.id)).toBe(false);
|
||||
|
||||
// Backfill: add owner tag
|
||||
await client.blocks.update(block.id, {
|
||||
tags: [ownerTag],
|
||||
});
|
||||
|
||||
// Now it should be discoverable via owner tag
|
||||
const afterBackfill = await client.blocks.list({ tags: [ownerTag] });
|
||||
const afterArray = Array.isArray(afterBackfill)
|
||||
? afterBackfill
|
||||
: (afterBackfill as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(afterArray.some((b) => b.id === block.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("multiple agents can own the same block", async () => {
|
||||
const ownerTag1 = `owner:${testAgentId}`;
|
||||
const ownerTag2 = `owner:other-agent-${Date.now()}`;
|
||||
|
||||
// Create a block with both owner tags (shared block)
|
||||
const block = await client.blocks.create({
|
||||
label: `test-shared-${Date.now()}`,
|
||||
value: "Shared content",
|
||||
tags: [ownerTag1, ownerTag2],
|
||||
});
|
||||
createdBlockIds.push(block.id);
|
||||
|
||||
// Verify it's discoverable via both tags
|
||||
const owned1 = await client.blocks.list({ tags: [ownerTag1] });
|
||||
const owned1Array = Array.isArray(owned1)
|
||||
? owned1
|
||||
: (owned1 as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(owned1Array.some((b) => b.id === block.id)).toBe(true);
|
||||
|
||||
const owned2 = await client.blocks.list({ tags: [ownerTag2] });
|
||||
const owned2Array = Array.isArray(owned2)
|
||||
? owned2
|
||||
: (owned2 as { items?: Array<{ id: string }> }).items || [];
|
||||
expect(owned2Array.some((b) => b.id === block.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,27 @@ import {
|
||||
|
||||
// Helper to create a mock client
|
||||
function createMockClient(options: {
|
||||
blocks?: Array<{ id: string; label: string; value: string }>;
|
||||
blocks?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
ownedBlocks?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
onBlockCreate?: (data: unknown) => { id: string };
|
||||
onBlockUpdate?: (label: string, data: unknown) => void;
|
||||
onBlockUpdate?: (blockId: string, data: unknown) => void;
|
||||
onAgentBlockUpdate?: (label: string, data: unknown) => void;
|
||||
onBlockAttach?: (blockId: string, data: unknown) => void;
|
||||
onBlockDetach?: (blockId: string, data: unknown) => void;
|
||||
throwOnUpdate?: string; // label to throw "Not Found" on
|
||||
}) {
|
||||
const blocks = options.blocks ?? [];
|
||||
const ownedBlocks = options.ownedBlocks ?? [];
|
||||
|
||||
return {
|
||||
agents: {
|
||||
@@ -34,7 +47,7 @@ function createMockClient(options: {
|
||||
if (options.throwOnUpdate === label) {
|
||||
return Promise.reject(new Error("Not Found"));
|
||||
}
|
||||
options.onBlockUpdate?.(label, data);
|
||||
options.onAgentBlockUpdate?.(label, data);
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
attach: mock((blockId: string, data: unknown) => {
|
||||
@@ -60,6 +73,20 @@ function createMockClient(options: {
|
||||
return Promise.resolve(block);
|
||||
}),
|
||||
delete: mock(() => Promise.resolve({})),
|
||||
list: mock((params?: { tags?: string[] }) => {
|
||||
// Filter by tags if provided
|
||||
if (params?.tags?.length) {
|
||||
const filtered = ownedBlocks.filter((b) =>
|
||||
params.tags!.some((tag) => b.tags?.includes(tag)),
|
||||
);
|
||||
return Promise.resolve(filtered);
|
||||
}
|
||||
return Promise.resolve(ownedBlocks);
|
||||
}),
|
||||
update: mock((blockId: string, data: unknown) => {
|
||||
options.onBlockUpdate?.(blockId, data);
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -343,3 +370,145 @@ describe("memory filesystem paths", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block tagging", () => {
|
||||
test("block creation includes owner tag", async () => {
|
||||
const createdBlockData: unknown[] = [];
|
||||
const mockClient = createMockClient({
|
||||
blocks: [],
|
||||
ownedBlocks: [],
|
||||
onBlockCreate: (data) => {
|
||||
createdBlockData.push(data);
|
||||
return { id: "new-block-id" };
|
||||
},
|
||||
});
|
||||
|
||||
// Verify mock client tracks tags in block creation
|
||||
await mockClient.blocks.create({
|
||||
label: "test-block",
|
||||
value: "test content",
|
||||
tags: ["owner:agent-123"],
|
||||
});
|
||||
|
||||
expect(createdBlockData.length).toBe(1);
|
||||
expect((createdBlockData[0] as { tags: string[] }).tags).toContain(
|
||||
"owner:agent-123",
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks.list filters by owner tag", async () => {
|
||||
const mockClient = createMockClient({
|
||||
blocks: [],
|
||||
ownedBlocks: [
|
||||
{ id: "block-1", label: "owned", value: "v1", tags: ["owner:agent-1"] },
|
||||
{ id: "block-2", label: "other", value: "v2", tags: ["owner:agent-2"] },
|
||||
{
|
||||
id: "block-3",
|
||||
label: "also-owned",
|
||||
value: "v3",
|
||||
tags: ["owner:agent-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await mockClient.blocks.list({ tags: ["owner:agent-1"] });
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.map((b) => b.label)).toContain("owned");
|
||||
expect(result.map((b) => b.label)).toContain("also-owned");
|
||||
expect(result.map((b) => b.label)).not.toContain("other");
|
||||
});
|
||||
|
||||
test("detached blocks are discovered via owner tag", async () => {
|
||||
const agentId = "agent-123";
|
||||
|
||||
// Attached blocks (returned by agents.blocks.list)
|
||||
const attachedBlocks = [
|
||||
{
|
||||
id: "attached-1",
|
||||
label: "persona",
|
||||
value: "v1",
|
||||
tags: [`owner:${agentId}`],
|
||||
},
|
||||
];
|
||||
|
||||
// All owned blocks (returned by blocks.list with tag filter)
|
||||
const ownedBlocks = [
|
||||
...attachedBlocks,
|
||||
{
|
||||
id: "detached-1",
|
||||
label: "notes",
|
||||
value: "v2",
|
||||
tags: [`owner:${agentId}`],
|
||||
},
|
||||
{
|
||||
id: "detached-2",
|
||||
label: "archive",
|
||||
value: "v3",
|
||||
tags: [`owner:${agentId}`],
|
||||
},
|
||||
];
|
||||
|
||||
const mockClient = createMockClient({
|
||||
blocks: attachedBlocks,
|
||||
ownedBlocks: ownedBlocks,
|
||||
});
|
||||
|
||||
// Get attached blocks
|
||||
const attached = await mockClient.agents.blocks.list();
|
||||
const attachedIds = new Set(attached.map((b) => b.id));
|
||||
|
||||
// Get all owned blocks via tag
|
||||
const allOwned = await mockClient.blocks.list({
|
||||
tags: [`owner:${agentId}`],
|
||||
});
|
||||
|
||||
// Calculate detached = owned - attached
|
||||
const detached = allOwned.filter((b) => !attachedIds.has(b.id));
|
||||
|
||||
expect(detached.length).toBe(2);
|
||||
expect(detached.map((b) => b.label)).toContain("notes");
|
||||
expect(detached.map((b) => b.label)).toContain("archive");
|
||||
expect(detached.map((b) => b.label)).not.toContain("persona");
|
||||
});
|
||||
|
||||
test("backfill adds owner tag to blocks missing it", async () => {
|
||||
const updatedBlocks: Array<{ blockId: string; data: unknown }> = [];
|
||||
const agentId = "agent-123";
|
||||
|
||||
const mockClient = createMockClient({
|
||||
blocks: [
|
||||
{ id: "block-1", label: "persona", value: "v1", tags: [] }, // No owner tag
|
||||
{
|
||||
id: "block-2",
|
||||
label: "human",
|
||||
value: "v2",
|
||||
tags: [`owner:${agentId}`],
|
||||
}, // Has owner tag
|
||||
{ id: "block-3", label: "project", value: "v3" }, // No tags at all
|
||||
],
|
||||
onBlockUpdate: (blockId, data) => {
|
||||
updatedBlocks.push({ blockId, data });
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate backfill logic
|
||||
const blocks = await mockClient.agents.blocks.list();
|
||||
const ownerTag = `owner:${agentId}`;
|
||||
|
||||
for (const block of blocks) {
|
||||
const tags = block.tags || [];
|
||||
if (!tags.includes(ownerTag)) {
|
||||
await mockClient.blocks.update(block.id, {
|
||||
tags: [...tags, ownerTag],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Should have updated block-1 and block-3, but not block-2
|
||||
expect(updatedBlocks.length).toBe(2);
|
||||
expect(updatedBlocks.map((u) => u.blockId)).toContain("block-1");
|
||||
expect(updatedBlocks.map((u) => u.blockId)).toContain("block-3");
|
||||
expect(updatedBlocks.map((u) => u.blockId)).not.toContain("block-2");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user