fix(mqtt): key tag definitions by RawPath, not the TagConfig blob
Task 2 review follow-up. The plan specified MqttEquipmentTagParser.TryParse(reference)
with def.Name = the TagConfig blob, and told us to mirror a type named
ModbusEquipmentTagParser. Both are plan defects:
- EquipmentTagRefResolver documents that a v3 driver reference "is now always a
RawPath" and that the blob-parse fallback is retired. Keying Name by the blob
would make OnDataChange publish under a reference that never matches the
RawPath-keyed fan-out in DriverHostActor - silently dead in production with
every unit test still green.
- ModbusEquipmentTagParser does not exist. The six sibling drivers all use
<Driver>TagDefinitionFactory.FromTagConfig(tagConfig, rawPath, out def).
Changes:
- Rename MqttEquipmentTagParser -> MqttTagDefinitionFactory; TryParse(reference,
out def) -> FromTagConfig(tagConfig, rawPath, out def) setting Name: rawPath,
matching ModbusTagDefinitionFactory's structure, param docs and guard order.
- Pin the identity contract with a dedicated test so a regression to blob-keying
goes red.
- Read qos with the same strictness as the enums: a present-but-invalid qos
("high" / 1.5 / 5 / null) now rejects the tag and is warned by Inspect,
instead of being silently absorbed into the driver-level default and handing
the operator a weaker delivery guarantee than the one they authored.
- No ToTagConfig inverse: the siblings carry one solely for their Driver.<X>.Cli
project, the MQTT plan defines none, and the AdminUI editor template
references no driver factory. Recorded as an explicit YAGNI call in the type
doc rather than added speculatively.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two entry points of <see cref="MqttEquipmentTagParser"/> and their deliberately
|
||||
/// different strictness contracts: <c>TryParse</c> is the runtime path (never throws, a
|
||||
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
|
||||
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
|
||||
/// deploy instead of going dark at runtime).
|
||||
/// </summary>
|
||||
public sealed class MqttEquipmentTagParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
|
||||
{
|
||||
// NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
|
||||
// DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is
|
||||
// therefore a typo the strict read rejects — pinned by TryParse_TypoedDataType_RejectsStrict.
|
||||
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
|
||||
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
|
||||
def!.Topic.ShouldBe("factory/oven/temp");
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
def.JsonPath.ShouldBe("$.value");
|
||||
def.DataType.ShouldBe(DriverDataType.Float64);
|
||||
def.Qos.ShouldBe(1);
|
||||
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TypoedPayloadFormat_RejectsStrict()
|
||||
=> MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TypoedDataType_RejectsStrict()
|
||||
=> MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_WildcardTopic_ReturnsWarning()
|
||||
{
|
||||
// The blob is otherwise clean (payloadFormat parses, dataType absent), so the wildcard check
|
||||
// is the ONLY thing that can produce a warning here — the assertion cannot pass vacuously.
|
||||
var warnings = MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
|
||||
warnings.ShouldNotBeEmpty();
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#")]
|
||||
[InlineData("+/b/c")]
|
||||
[InlineData("a/+/c")]
|
||||
public void Inspect_EachWildcardForm_Warns(string topic)
|
||||
=> MqttEquipmentTagParser.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void TryParse_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
|
||||
{
|
||||
// A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
|
||||
// designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
|
||||
// silent BadNodeIdUnknown with no operator-visible cause.
|
||||
MqttEquipmentTagParser.TryParse("""{"topic":"a/+/c","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
|
||||
def!.Topic.ShouldBe("a/+/c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_RawFormat_AppliesDefaults()
|
||||
{
|
||||
const string r = """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""";
|
||||
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
|
||||
def!.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
|
||||
def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
|
||||
def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
|
||||
def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
|
||||
def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ExplicitRetainSeedFalse_IsHonoured()
|
||||
{
|
||||
MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", out var def).ShouldBeTrue();
|
||||
def!.RetainSeed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
|
||||
{
|
||||
// P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future
|
||||
// mode discriminator cannot mistake a plain tag for a Sparkplug one.
|
||||
MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw"}""", out var def).ShouldBeTrue();
|
||||
def!.GroupId.ShouldBeNull();
|
||||
def.EdgeNodeId.ShouldBeNull();
|
||||
def.DeviceId.ShouldBeNull();
|
||||
def.MetricName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
|
||||
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
|
||||
[InlineData("{ not json at all")] // unparseable — must not throw
|
||||
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
|
||||
[InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
|
||||
[InlineData("""{"topic":"a/b","qos":5}""")] // QoS outside 0–2 is an illegal subscription
|
||||
public void TryParse_MalformedReference_ReturnsFalseAndNeverThrows(string? reference)
|
||||
=> MqttEquipmentTagParser.TryParse(reference!, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_CleanBlob_ReturnsEmpty()
|
||||
=> MqttEquipmentTagParser.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64"}""").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_TypoedEnums_WarnsPerField()
|
||||
{
|
||||
var warnings = MqttEquipmentTagParser.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
|
||||
warnings.Count.ShouldBe(2);
|
||||
warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
|
||||
warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_UnparseableBlob_Warns()
|
||||
=> MqttEquipmentTagParser.Inspect("{ not json at all").ShouldNotBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")]
|
||||
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
|
||||
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
|
||||
=> MqttEquipmentTagParser.Inspect(reference!).ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two entry points of <see cref="MqttTagDefinitionFactory"/> and their deliberately
|
||||
/// different strictness contracts: <c>FromTagConfig</c> is the runtime path (never throws, a
|
||||
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
|
||||
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
|
||||
/// deploy instead of going dark at runtime).
|
||||
/// </summary>
|
||||
public sealed class MqttTagDefinitionFactoryTests
|
||||
{
|
||||
private const string RawPath = "Plant/Mqtt/oven/Temp";
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_PlainJsonBlob_PopulatesTopicAndPath()
|
||||
{
|
||||
// NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is
|
||||
// DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is therefore a
|
||||
// typo the strict read rejects — pinned by FromTagConfig_TypoedDataType_RejectsStrict.
|
||||
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}""";
|
||||
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("factory/oven/temp");
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
def.JsonPath.ShouldBe("$.value");
|
||||
def.DataType.ShouldBe(DriverDataType.Float64);
|
||||
def.Qos.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red.
|
||||
/// <see cref="MqttTagDefinition.Name"/> MUST be the RawPath the driver was handed — it is the key
|
||||
/// <c>EquipmentTagRefResolver</c> looks up, the key <c>OnDataChange</c> publishes under, and the
|
||||
/// key <c>DriverHostActor</c> fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob
|
||||
/// (the pre-v3, now-retired shape) would leave the driver silently dead in production while every
|
||||
/// other test here still passed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob()
|
||||
{
|
||||
const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}""";
|
||||
MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
|
||||
def.Name.ShouldBe(RawPath);
|
||||
def.Name.ShouldNotBe(blob);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The identity is whatever RawPath the caller supplies — the factory must never re-derive it
|
||||
/// from the blob's contents (e.g. from <c>topic</c>), or two tags sharing a topic would collide.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Plant/Mqtt/oven/Temp")]
|
||||
[InlineData("SiteA/Mqtt/line3/Oven/Setpoint")]
|
||||
public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue();
|
||||
def.Name.ShouldBe(rawPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_TypoedPayloadFormat_RejectsStrict()
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_TypoedDataType_RejectsStrict()
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_WildcardTopic_ReturnsWarning()
|
||||
{
|
||||
// The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard
|
||||
// check is the ONLY thing that can produce a warning — this cannot pass vacuously.
|
||||
var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""");
|
||||
warnings.ShouldNotBeEmpty();
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/#")]
|
||||
[InlineData("+/b/c")]
|
||||
[InlineData("a/+/c")]
|
||||
public void Inspect_EachWildcardForm_Warns(string topic)
|
||||
=> MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_WildcardTopic_StillParses_WarningIsDeployTimeOnly()
|
||||
{
|
||||
// A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the
|
||||
// designed surface for it. Rejecting it at runtime would turn an authoring mistake into a
|
||||
// silent BadNodeIdUnknown with no operator-visible cause.
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.Topic.ShouldBe("a/+/c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_RawFormat_AppliesDefaults()
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw);
|
||||
def.JsonPath.ShouldBe("$"); // absent ⇒ the root default
|
||||
def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins
|
||||
def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message
|
||||
def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_ExplicitRetainSeedFalse_IsHonoured()
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.RetainSeed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void FromTagConfig_EachLegalQos_IsAccepted(int qos)
|
||||
{
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.Qos.ShouldBe(qos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTagConfig_PlainBlob_LeavesSparkplugDescriptorFieldsNull()
|
||||
{
|
||||
// P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future
|
||||
// mode discriminator cannot mistake a plain tag for a Sparkplug one.
|
||||
MqttTagDefinitionFactory.FromTagConfig(
|
||||
"""{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue();
|
||||
def.GroupId.ShouldBeNull();
|
||||
def.EdgeNodeId.ShouldBeNull();
|
||||
def.DeviceId.ShouldBeNull();
|
||||
def.MetricName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{')
|
||||
[InlineData("[1,2,3]")] // valid JSON, wrong root kind
|
||||
[InlineData("{ not json at all")] // unparseable — must not throw
|
||||
[InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to
|
||||
[InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic
|
||||
[InlineData("""{"topic":" ","payloadFormat":"Raw"}""")] // whitespace-only topic
|
||||
public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// A present-but-invalid <c>qos</c> is rejected in every malformed shape, not just the
|
||||
/// out-of-range numeric one. Silently absorbing <c>"high"</c> / <c>1.5</c> into the driver-level
|
||||
/// default would hand the operator a WEAKER delivery guarantee than the one they authored.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("5")] // out of range
|
||||
[InlineData("-1")] // out of range
|
||||
[InlineData("\"high\"")] // wrong JSON type
|
||||
[InlineData("\"1\"")] // stringly-typed number
|
||||
[InlineData("1.5")] // non-integer
|
||||
[InlineData("true")] // wrong JSON type
|
||||
[InlineData("null")] // an explicit null is not "absent"
|
||||
public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken)
|
||||
=> MqttTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_CleanBlob_ReturnsEmpty()
|
||||
=> MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_TypoedEnums_WarnsPerField()
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""");
|
||||
warnings.Count.ShouldBe(2);
|
||||
warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal));
|
||||
warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("\"high\"")]
|
||||
[InlineData("1.5")]
|
||||
public void Inspect_MalformedQos_Warns(string qosToken)
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
$$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""");
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive);
|
||||
}
|
||||
|
||||
/// <summary>Every warning source fires together — the pass reports all of them, not just the first.</summary>
|
||||
[Fact]
|
||||
public void Inspect_MultipleProblems_ReportsAll()
|
||||
{
|
||||
var warnings = MqttTagDefinitionFactory.Inspect(
|
||||
"""{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}""");
|
||||
warnings.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_UnparseableBlob_Warns()
|
||||
=> MqttTagDefinitionFactory.Inspect("{ not json at all").ShouldNotBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("factory/oven/temp")]
|
||||
[InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus)
|
||||
public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference)
|
||||
=> MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty();
|
||||
}
|
||||
Reference in New Issue
Block a user