@@ -800,6 +800,288 @@ describe("diagnostic recording during deliverArticles execution", () => {
800800 } ) ;
801801} ) ;
802802
803+ describe ( "filter execution order - filters should operate on formatted content" , ( ) => {
804+ it ( "should match filter on formatted markdown content, not raw HTML" , async ( ) => {
805+ const { startDeliveryPreviewContext, getDeliveryPreviewResultsForArticle } =
806+ await import ( "../delivery-preview" ) ;
807+ const { deliverArticles } = await import ( "." ) ;
808+
809+ const store = createInMemoryDeliveryRecordStore ( ) ;
810+ // Article with raw HTML that will be formatted to markdown
811+ const article : Article = {
812+ flattened : {
813+ id : "article-html-1" ,
814+ idHash : "hash-html-1" ,
815+ description : "<strong>Important</strong> announcement" ,
816+ } ,
817+ raw : { } ,
818+ } ;
819+
820+ // Filter looking for markdown bold syntax (after formatting)
821+ const filterExpression : LogicalExpression = {
822+ type : ExpressionType . Logical ,
823+ op : LogicalExpressionOperator . And ,
824+ children : [
825+ {
826+ type : ExpressionType . Relational ,
827+ op : RelationalExpressionOperator . Contains ,
828+ left : { type : RelationalExpressionLeft . Article , value : "description" } ,
829+ right : { type : RelationalExpressionRight . String , value : "**Important**" } ,
830+ } ,
831+ ] ,
832+ } ;
833+
834+ const mediumWithFilter = {
835+ id : "medium-html-format-test" ,
836+ filters : { expression : filterExpression } ,
837+ details : {
838+ guildId : "guild-123" ,
839+ channel : { id : "channel-123" } ,
840+ } ,
841+ } ;
842+
843+ let previews : DeliveryPreviewStageResult [ ] = [ ] ;
844+
845+ await startDeliveryPreviewContext ( "hash-html-1" , async ( ) => {
846+ await store . startContext ( async ( ) => {
847+ await deliverArticles ( [ article ] , [ mediumWithFilter ] , {
848+ feedId : "feed-1" ,
849+ feedUrl : "https://example.com/feed.xml" ,
850+ articleDayLimit : 100 ,
851+ discordClient : createTestDiscordRestClient ( ) ,
852+ deliveryRecordStore : store ,
853+ } ) ;
854+ } ) ;
855+ previews = getDeliveryPreviewResultsForArticle ( "hash-html-1" ) ;
856+ } ) ;
857+
858+ const filterDiagnostic = previews . find (
859+ ( d ) => d . stage === DeliveryPreviewStage . MediumFilter
860+ ) ;
861+ assert . notStrictEqual ( filterDiagnostic , undefined ) ;
862+ // Should PASS because HTML <strong> is converted to markdown ** before filtering
863+ assert . strictEqual (
864+ filterDiagnostic ! . status ,
865+ DeliveryPreviewStageStatus . Passed ,
866+ "Filter should match on formatted markdown (**Important**), not raw HTML"
867+ ) ;
868+ } ) ;
869+
870+ it ( "should NOT match raw HTML tags in filter after formatting" , async ( ) => {
871+ const { startDeliveryPreviewContext, getDeliveryPreviewResultsForArticle } =
872+ await import ( "../delivery-preview" ) ;
873+ const { deliverArticles } = await import ( "." ) ;
874+
875+ const store = createInMemoryDeliveryRecordStore ( ) ;
876+ const article : Article = {
877+ flattened : {
878+ id : "article-html-2" ,
879+ idHash : "hash-html-2" ,
880+ description : "<strong>Text</strong>" ,
881+ } ,
882+ raw : { } ,
883+ } ;
884+
885+ // Filter looking for literal HTML tag - should NOT match after formatting
886+ const filterExpression : LogicalExpression = {
887+ type : ExpressionType . Logical ,
888+ op : LogicalExpressionOperator . And ,
889+ children : [
890+ {
891+ type : ExpressionType . Relational ,
892+ op : RelationalExpressionOperator . Contains ,
893+ left : { type : RelationalExpressionLeft . Article , value : "description" } ,
894+ right : { type : RelationalExpressionRight . String , value : "<strong>" } ,
895+ } ,
896+ ] ,
897+ } ;
898+
899+ const mediumWithFilter = {
900+ id : "medium-html-tag-test" ,
901+ filters : { expression : filterExpression } ,
902+ details : {
903+ guildId : "guild-123" ,
904+ channel : { id : "channel-123" } ,
905+ } ,
906+ } ;
907+
908+ let previews : DeliveryPreviewStageResult [ ] = [ ] ;
909+
910+ await startDeliveryPreviewContext ( "hash-html-2" , async ( ) => {
911+ await store . startContext ( async ( ) => {
912+ await deliverArticles ( [ article ] , [ mediumWithFilter ] , {
913+ feedId : "feed-1" ,
914+ feedUrl : "https://example.com/feed.xml" ,
915+ articleDayLimit : 100 ,
916+ discordClient : createTestDiscordRestClient ( ) ,
917+ deliveryRecordStore : store ,
918+ } ) ;
919+ } ) ;
920+ previews = getDeliveryPreviewResultsForArticle ( "hash-html-2" ) ;
921+ } ) ;
922+
923+ const filterDiagnostic = previews . find (
924+ ( d ) => d . stage === DeliveryPreviewStage . MediumFilter
925+ ) ;
926+ assert . notStrictEqual ( filterDiagnostic , undefined ) ;
927+ // Should FAIL because <strong> tag no longer exists after formatting to **
928+ assert . strictEqual (
929+ filterDiagnostic ! . status ,
930+ DeliveryPreviewStageStatus . Failed ,
931+ "Filter should NOT find raw HTML tags after formatting"
932+ ) ;
933+ } ) ;
934+
935+ it ( "should allow filtering on custom placeholder values" , async ( ) => {
936+ const { startDeliveryPreviewContext, getDeliveryPreviewResultsForArticle } =
937+ await import ( "../delivery-preview" ) ;
938+ const { deliverArticles } = await import ( "." ) ;
939+
940+ const store = createInMemoryDeliveryRecordStore ( ) ;
941+ const article : Article = {
942+ flattened : {
943+ id : "article-cp-1" ,
944+ idHash : "hash-cp-1" ,
945+ title : "Article about cats" ,
946+ } ,
947+ raw : { } ,
948+ } ;
949+
950+ // Filter on custom placeholder value (uppercase version of title)
951+ const filterExpression : LogicalExpression = {
952+ type : ExpressionType . Logical ,
953+ op : LogicalExpressionOperator . And ,
954+ children : [
955+ {
956+ type : ExpressionType . Relational ,
957+ op : RelationalExpressionOperator . Contains ,
958+ left : { type : RelationalExpressionLeft . Article , value : "custom::upper" } ,
959+ right : { type : RelationalExpressionRight . String , value : "CATS" } ,
960+ } ,
961+ ] ,
962+ } ;
963+
964+ const mediumWithFilter = {
965+ id : "medium-cp-test" ,
966+ filters : { expression : filterExpression } ,
967+ details : {
968+ guildId : "guild-123" ,
969+ channel : { id : "channel-123" } ,
970+ customPlaceholders : [
971+ {
972+ id : "cp-1" ,
973+ referenceName : "upper" ,
974+ sourcePlaceholder : "title" ,
975+ steps : [ { type : "UPPERCASE" } ] ,
976+ } ,
977+ ] ,
978+ } ,
979+ } ;
980+
981+ let previews : DeliveryPreviewStageResult [ ] = [ ] ;
982+
983+ await startDeliveryPreviewContext ( "hash-cp-1" , async ( ) => {
984+ await store . startContext ( async ( ) => {
985+ await deliverArticles ( [ article ] , [ mediumWithFilter ] , {
986+ feedId : "feed-1" ,
987+ feedUrl : "https://example.com/feed.xml" ,
988+ articleDayLimit : 100 ,
989+ discordClient : createTestDiscordRestClient ( ) ,
990+ deliveryRecordStore : store ,
991+ } ) ;
992+ } ) ;
993+ previews = getDeliveryPreviewResultsForArticle ( "hash-cp-1" ) ;
994+ } ) ;
995+
996+ const filterDiagnostic = previews . find (
997+ ( d ) => d . stage === DeliveryPreviewStage . MediumFilter
998+ ) ;
999+ assert . notStrictEqual ( filterDiagnostic , undefined ) ;
1000+ // Should PASS because custom placeholder is processed before filtering
1001+ assert . strictEqual (
1002+ filterDiagnostic ! . status ,
1003+ DeliveryPreviewStageStatus . Passed ,
1004+ "Filter should be able to match on custom placeholder values"
1005+ ) ;
1006+ } ) ;
1007+
1008+ it ( "should show formatted content in explainBlocked diagnostic" , async ( ) => {
1009+ const { startDeliveryPreviewContext, getDeliveryPreviewResultsForArticle } =
1010+ await import ( "../delivery-preview" ) ;
1011+ const { deliverArticles } = await import ( "." ) ;
1012+
1013+ const store = createInMemoryDeliveryRecordStore ( ) ;
1014+ const article : Article = {
1015+ flattened : {
1016+ id : "article-explain-1" ,
1017+ idHash : "hash-explain-1" ,
1018+ author : '<a href="http://example.com">John Doe</a>' ,
1019+ } ,
1020+ raw : { } ,
1021+ } ;
1022+
1023+ // Filter that will NOT match (looking for "Jane")
1024+ const filterExpression : LogicalExpression = {
1025+ type : ExpressionType . Logical ,
1026+ op : LogicalExpressionOperator . And ,
1027+ children : [
1028+ {
1029+ type : ExpressionType . Relational ,
1030+ op : RelationalExpressionOperator . Contains ,
1031+ left : { type : RelationalExpressionLeft . Article , value : "author" } ,
1032+ right : { type : RelationalExpressionRight . String , value : "Jane" } ,
1033+ } ,
1034+ ] ,
1035+ } ;
1036+
1037+ const mediumWithFilter = {
1038+ id : "medium-explain-test" ,
1039+ filters : { expression : filterExpression } ,
1040+ details : {
1041+ guildId : "guild-123" ,
1042+ channel : { id : "channel-123" } ,
1043+ } ,
1044+ } ;
1045+
1046+ let previews : DeliveryPreviewStageResult [ ] = [ ] ;
1047+
1048+ await startDeliveryPreviewContext ( "hash-explain-1" , async ( ) => {
1049+ await store . startContext ( async ( ) => {
1050+ await deliverArticles ( [ article ] , [ mediumWithFilter ] , {
1051+ feedId : "feed-1" ,
1052+ feedUrl : "https://example.com/feed.xml" ,
1053+ articleDayLimit : 100 ,
1054+ discordClient : createTestDiscordRestClient ( ) ,
1055+ deliveryRecordStore : store ,
1056+ } ) ;
1057+ } ) ;
1058+ previews = getDeliveryPreviewResultsForArticle ( "hash-explain-1" ) ;
1059+ } ) ;
1060+
1061+ const filterDiagnostic = previews . find (
1062+ ( d ) => d . stage === DeliveryPreviewStage . MediumFilter
1063+ ) ;
1064+ assert . notStrictEqual ( filterDiagnostic , undefined ) ;
1065+ assert . strictEqual ( filterDiagnostic ! . status , DeliveryPreviewStageStatus . Failed ) ;
1066+
1067+ const details = filterDiagnostic ! . details as {
1068+ explainBlocked : Array < { truncatedReferenceValue : string } > ;
1069+ } ;
1070+ assert . ok ( details . explainBlocked . length > 0 ) ;
1071+
1072+ // truncatedReferenceValue should show formatted markdown link, not raw HTML
1073+ const referenceValue = details . explainBlocked [ 0 ] ?. truncatedReferenceValue ?? "" ;
1074+ assert . ok (
1075+ ! referenceValue . includes ( "<a" ) ,
1076+ `truncatedReferenceValue should not contain raw HTML tags, got: ${ referenceValue } `
1077+ ) ;
1078+ assert . ok (
1079+ referenceValue . includes ( "[John Doe]" ) || referenceValue . includes ( "John Doe" ) ,
1080+ `truncatedReferenceValue should contain formatted text, got: ${ referenceValue } `
1081+ ) ;
1082+ } ) ;
1083+ } ) ;
1084+
8031085// Helper to create a delivery state for testing
8041086function createDeliveryState (
8051087 id : string ,
0 commit comments