chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { deleteColumn, renameColumn } from '@/lib/table/columns/service'
|
||||
import {
|
||||
batchInsertRows,
|
||||
insertRow,
|
||||
replaceTableRows,
|
||||
updateRow,
|
||||
upsertRow,
|
||||
} from '@/lib/table/rows/service'
|
||||
import type { TableDefinition } from '@/lib/table/types'
|
||||
import { getUniqueColumns } from '@/lib/table/validation'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
// Capacity is exercised in billing.test.ts; here it's a no-op so the timeout-scaling
|
||||
// suites can use large synthetic row counts without tripping the plan limit.
|
||||
vi.mock('@/lib/table/billing', () => ({
|
||||
assertRowCapacity: vi.fn().mockResolvedValue(1_000_000),
|
||||
notifyTableRowUsage: vi.fn(),
|
||||
getMaxRowsPerTable: vi.fn().mockResolvedValue(1_000_000),
|
||||
wouldExceedRowLimit: () => false,
|
||||
TableRowLimitError: class TableRowLimitError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/validation', () => ({
|
||||
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
coerceRowValues: vi.fn(),
|
||||
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
getUniqueColumns: vi.fn(() => []),
|
||||
checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||||
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Inspects the queued `trx.execute(...)` calls for SQL containing `substring`.
|
||||
* Works with both `sql\`...\`` (produces `{ strings, values }`) and `sql.raw(...)`
|
||||
* (produces `{ rawSql }`) from the global drizzle mock.
|
||||
*/
|
||||
function findExecutedSqlContaining(substring: string): boolean {
|
||||
return dbChainMockFns.execute.mock.calls.some(([arg]) => {
|
||||
if (!arg || typeof arg !== 'object') return false
|
||||
const a = arg as Record<string, unknown>
|
||||
if (Array.isArray(a.strings)) {
|
||||
return (a.strings as string[]).some((s) => typeof s === 'string' && s.includes(substring))
|
||||
}
|
||||
if (typeof a.rawSql === 'string') {
|
||||
return (a.rawSql as string).includes(substring)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function findExecutedRawSql(substring: string): string | undefined {
|
||||
for (const [arg] of dbChainMockFns.execute.mock.calls) {
|
||||
if (!arg || typeof arg !== 'object') continue
|
||||
const raw = (arg as { rawSql?: unknown }).rawSql
|
||||
if (typeof raw === 'string' && raw.includes(substring)) return raw
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const EXISTING_ROW = {
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 30 },
|
||||
position: 1,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
const TABLE: TableDefinition = {
|
||||
id: 'tbl-1',
|
||||
name: 'People',
|
||||
description: null,
|
||||
schema: {
|
||||
columns: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'age', type: 'number' },
|
||||
],
|
||||
},
|
||||
metadata: null,
|
||||
rowCount: 0,
|
||||
maxRows: 1000,
|
||||
workspaceId: 'ws-1',
|
||||
createdBy: 'user-1',
|
||||
archivedAt: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
describe('updateRow — partial merge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
dbChainMockFns.limit.mockResolvedValue([EXISTING_ROW])
|
||||
})
|
||||
|
||||
it('preserves columns not included in the partial update', async () => {
|
||||
const result = await updateRow(
|
||||
{ tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({ name: 'Alice', age: 31 })
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: 'Alice', age: 31 } })
|
||||
)
|
||||
})
|
||||
|
||||
it('allows updating a single column without affecting others', async () => {
|
||||
const result = await updateRow(
|
||||
{ tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({ name: 'Bob', age: 30 })
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: 'Bob', age: 30 } })
|
||||
)
|
||||
})
|
||||
|
||||
it('allows explicitly nulling a field while preserving others', async () => {
|
||||
const result = await updateRow(
|
||||
{ tableId: 'tbl-1', rowId: 'row-1', data: { age: null }, workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({ name: 'Alice', age: null })
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { name: 'Alice', age: null } })
|
||||
)
|
||||
})
|
||||
|
||||
it('handles a full-row update correctly (idempotent merge)', async () => {
|
||||
const result = await updateRow(
|
||||
{ tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob', age: 25 }, workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({ name: 'Bob', age: 25 })
|
||||
})
|
||||
|
||||
it('throws when the row does not exist', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
await expect(
|
||||
updateRow(
|
||||
{ tableId: 'tbl-1', rowId: 'row-missing', data: { age: 31 }, workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toThrow('Row not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertRow — position race safety (migration 0198 + advisory lock)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
resetDbChainMock()
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('auto-position inserts acquire the per-table advisory lock before reading max(position)', async () => {
|
||||
await expect(
|
||||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||||
})
|
||||
|
||||
it('explicit-position inserts also acquire the advisory lock to serialize order-key minting', async () => {
|
||||
await expect(
|
||||
insertRow(
|
||||
{ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1', position: 5 },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
// A position-based insert resolves its order_key from the neighbor at that
|
||||
// rank; the lock serializes concurrent minting at the same slot.
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('batchInsertRows acquires the advisory lock (always auto-positioned)', async () => {
|
||||
await expect(
|
||||
batchInsertRows(
|
||||
{ tableId: 'tbl-1', rows: [{ name: 'a' }, { name: 'b' }], workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('upsertRow skips the advisory lock on the update path (match found)', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 30 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'row-1',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 31 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
])
|
||||
|
||||
await upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Alice', age: 31 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false)
|
||||
})
|
||||
|
||||
it('upsertRow acquires the advisory lock on the insert path (no match)', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
// Initial existing-row check + post-lock re-check both find no match.
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
await expect(
|
||||
upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 25 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
})
|
||||
|
||||
it('upsertRow re-checks after acquiring the lock and switches to UPDATE when a racing tx inserted the row', async () => {
|
||||
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
|
||||
// Initial existing-row check: no match (another tx has not committed yet).
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
// Post-lock re-check: a racing tx just inserted the row.
|
||||
const racedRow = {
|
||||
id: 'row-raced',
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 25 },
|
||||
position: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([racedRow])
|
||||
// UPDATE returning the patched row.
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{ ...racedRow, data: { name: 'Bob', age: 26 } },
|
||||
])
|
||||
|
||||
const result = await upsertRow(
|
||||
{
|
||||
tableId: 'tbl-1',
|
||||
workspaceId: 'ws-1',
|
||||
data: { name: 'Bob', age: 26 },
|
||||
conflictTarget: 'name',
|
||||
},
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(result.operation).toBe('update')
|
||||
expect(result.row.id).toBe('row-raced')
|
||||
expect(dbChainMockFns.update).toHaveBeenCalled()
|
||||
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutation paths — SET LOCAL timeouts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('insertRow sets the default 10s/3s/5s timeouts', async () => {
|
||||
await expect(
|
||||
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined()
|
||||
expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined()
|
||||
expect(
|
||||
findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'")
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('batchInsertRows raises statement_timeout to 60s', async () => {
|
||||
await expect(
|
||||
batchInsertRows(
|
||||
{ tableId: 'tbl-1', rows: [{ name: 'a' }], workspaceId: 'ws-1' },
|
||||
TABLE,
|
||||
'req-1'
|
||||
)
|
||||
).rejects.toBeDefined()
|
||||
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => {
|
||||
const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 }
|
||||
const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` }))
|
||||
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload },
|
||||
bigTable,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
// (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => {
|
||||
const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 }
|
||||
|
||||
await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1')
|
||||
|
||||
// 10M × 3ms = 30M ms, capped at 600_000ms (10 min)
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows uses the 120s floor on small tables', async () => {
|
||||
const smallTable: TableDefinition = { ...TABLE, rowCount: 10 }
|
||||
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }, { name: 'b' }] },
|
||||
smallTable,
|
||||
'req-1'
|
||||
)
|
||||
|
||||
// 12 × 3ms = 36ms → floored at 120_000ms
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('renameColumn is metadata-only — no per-row JSONB rewrite regardless of row count', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }])
|
||||
|
||||
await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1')
|
||||
|
||||
// Row data is keyed by the column's stable id (unchanged by a rename), so no
|
||||
// `user_table_rows` key rewrite is executed — and thus no scaled timeout.
|
||||
expect(findExecutedSqlContaining('jsonb_build_object')).toBe(false)
|
||||
})
|
||||
|
||||
it('deleteColumn uses the 60s floor on small tables', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 100 }])
|
||||
|
||||
await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1')
|
||||
|
||||
// 100 × 2ms = 200ms → floored at 60_000ms
|
||||
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
|
||||
})
|
||||
|
||||
it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => {
|
||||
await replaceTableRows(
|
||||
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] },
|
||||
{ ...TABLE, rowCount: 5 },
|
||||
'req-1'
|
||||
)
|
||||
|
||||
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
|
||||
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user